When it comes to exercising a piece of application logic with automated unit tests, there’s a well-understood process that most frameworks and testing tools follow:
- Setup: Establishes instances of data objects and preconditions essential for running the test.
- Exercise: Executes the method or logic to be tested.
- Verify: Verifies that the tested method has produced the expected result by making one or more assertions.
- Teardown: Cleans up or resets application state that should not be allowed to persist between tests.
Perform a Google search for “unit test anatomy”, and you’ll see this same pattern described in books and articles for many programming languages and methodologies - sometimes with slightly different terminology, but still following the same basic sequence. But the way that a given tool or testing library realizes each phase can vary a lot - a fact which has launched hundreds of testing frameworks and thousands of flame wars.
The original Ruby standard for testing was established by the Test::Unit library (itself based on the xUnit model) which was part of the Ruby standard library going back many years and many more releases. Minitest follows the same model by providing a
setup method which can be overridden and will be run by the framework before each individual test.
RSpec came along quite a bit later and introduced a more granular scheme of hooks for setting up test state that mapped more naturally to its block-based syntax.
- before(:each) - logic to run before each individual test method
- before(:all) / before(:context) - logic to run at the start of a context/describe block
- before(:suite) - logic to run before the test suite runs
- let - memoizes the result of a block and provides an accessor method for it
Minitest has built-in support for some but not all of these. In this post, I’m going to show you how to achieve the same effects in your own tests using the features that Minitest gives you along with a sprinkling of plain old Ruby. Because in the end, it’s all just Ruby.
Setup Before Running Each Test
You probably already know that Minitest::Test provides a
setup method that you can override to define logic that runs before each test.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Minitest::Spec provides an equivalent in the form of its
1 2 3 4 5 6 7 8 9 10 11 12 13 14
What Exactly Does :let Do Again?
let provides an alternate and some would say more elegant way of setting up testing state with a more declarative syntax. The following would be comparable to the example in the previous section.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
Comparable, but not equivalent. Each
let invocation defines a new method with the specified name that executes the block argument upon the first invocation and caches the result for later access - in other words, a lazy initializer. The main advantage of this technique over the use of instance variables defined in a
setup method or
before block is that the setup logic can be divided into smaller units and executed only in tests where they’re needed.
let gives you the ability to define and redefine the block assigned to each name so that tests can be run against a set of values and preconditions defined within the most immediate block, then the enclosing block, and so on. Take the following sample spec as an example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
Both of these tests will pass since the contents of the list in each case will be determined by whatever is most immediately assigned to
thing in the enclosing block. This can be a really powerful tool, and I’ve found it’s really effective in situations where I need to test the same method with different inputs, but bear in mind that nesting
describe blocks too deeply will make your tests harder to understand and leave you and other developers confused about what’s actually being tested.
(Special thanks to @jemmyw for calling out the fact that this was not well covered in the original version of the post.)
I had always assumed that the memoized result was cached and available for use across all tests in a test case after the first invocation, but because Minitest runs each test using a fresh instance of the test class, the value is associated with a single test instance, not shared across instances.
Setup Before Running the Test Case
RSpec gives developers the ability to define setup code that would only run before the start of each test case using a
before(:all) block – now also aliased as
before(:context). Minitest doesn’t support the same syntax, but it’s easy enough to implement by executing class-level code and using class variables to store references to any shared resources as in the following example.
1 2 3 4 5 6 7 8 9
In this case, we’re assuming that the call to the Facebook API will be slow, so in order to perform that initialization just once rather than before every single test, we assign the class variable
@@fb_client one time at the start of the test case. All instances of the test case will then have access to the shared client resource without creating a new connection.
While this is a nice tool to have at our disposal, it has the potential of being taken too far by, for example, using it for setting up anything involving database access. Overusing class variables in this way reduces test isolation and introduces the potential that tests will begin to fail (or worse, not fail) randomly, and so I’d be somewhat cautious about where and how often you apply this model.
Extra credit homework: Read the GitHub issue that requests the inclusion of support for
before(:all) and the discussion afterward. It specifically describes the technique explained above, and the comments provide a lot of insight about how to take a conservative approach to library design.
Setting Up Before Running the Suite
Setup code intended to run once before all tests in the suite use a similar technique as shown in the previous section, but in this case, we’ll need to modify Minitest::Test in our
test_helper.rb file instead of the individual test cases. The code will look like this:
1 2 3 4 5
The result is a Facebook API client that’s shared between all test cases in the suite and which is set up once before any tests are executed.
The fact that this can be done doesn’t mean that it should be done though. Before using a technique such as this though, you need to ask yourself what effect it will have on your suite. Tests should be written as much as possible in a single file with as much verbosity and repetition as is needed to convey their meaning, and I’d personally be really reluctant to distribute code that’s essential to a clear understanding of my test case into other files.