4+ Years of Using Minitest
In the last few blog posts I wrote about my experiments with Minitest in the last few years. For today’s blog post I thought I’d write how some of Minitest’s design decision affected how I write tests these days and in general how my approach to testing have changed.
Test cases
As I wrote in Sharing Examples in Minitest, in Minitest we usually write test cases by inheriting from base classes like Minitest::Test
, ActionController::TestCase
etc.
In my projects I often needed to add some customizations to the tests, e.g. using a Minitest plugin or custom assertions. I used to add these extensions to test/test_helper.rb
like this:
# my_project/test/test_helper.rb
class Minitest::Test
include MyCustomAssertions
end
class ActionDispatch::IntegrationTest
include Capybara::DSL
end
These days I’d usually write custom test cases instead:
# my_project/test/test_helper.rb
class MyProjectTest < Minitest::Test
include MyCustomAssertions
end
class AcceptanceTest < ActionDispatch::IntegrationTest
include Capybara::DSL
end
This has a few benefits:
- no monkey patching.
- better code organization. By creating an actual class for each test type it’s pretty natural to eventually move it to a separate file (with the name corresponding to the class, somwhere in
test/support/
) if it grows to be too big. ctags
friendliness.- encourages to have even more custom test cases. It’s very useful when there’s a specific subsystem of the app that deserves it’s own test case (e.g.
BillingTest
) that contains helper methods, custom assertions & perhaps extra docs. Again,ctags
friendliness really helps here.
Stubbing & Mocking
I’m speculating here, but I wouldn’t be suprised if there would be as many resources praising stubbing (or more broadly, test isolation) as critizing it.
For me, the biggest problems with stubbing are as follows:
- “stubbing at a distance” in a
setup/before
block somewhere in base test case/mixin can lead to very unexpected and hard to track bugs. - “stub leaking”, when stubbing global objects, can too lead to unexpected and hard to track bugs.
- depending on testing/mocking framework it’s sometimes very easy to stub multiple things at once, leading to complex and coupled tests that are hard to maintain.
Note, none of the issues above is intrisic to a particular testing/mocking library - it boils down to how it’s being used.
Minitest ships with a mocking/stubbing support in the minitest/mock
package. Here’s an example of stubbing from the docs:
def test_stale_eh
obj_under_test = Something.new
refute obj_under_test.stale?
Time.stub :now, Time.at(0) do # stub goes away once the block is done
assert obj_under_test.stale?
end
end
Note the comment, “stub goes away once the block is done”.
This is really important and it actually solves, or rather provides a constraint, for all of the problems I listed above.
Since the stubbing happens locally, within the given block, we solve the problem of “stubbing at a distance” as well as “leaking”.
In order stub multiple things at once we’d need to nest calls to stub which really stands out (as it should) and actually looks pretty terrible. That was the “aha” moment for me.
This one time, I needed to stub a couple of dependencies and started nesting calls to stub
but looking at it, “listening to the tests”, gave me a pause and I reconsidered my design to have at most one stub in that test and thus having it be more decoupled. In fact, I think I ended up with no stubs at all in that case.
As a matter of fact, I seldom write stubs these days. I tend to prefer writing a “fake” object instead:
class PaymentGateway
def initialize(@adapter)
@adapter = adapter
end
def pay(description, amount)
@adapter.pay(description, amount)
end
end
class PaymentGateway::Payment < Struct.new :reference, :amount, :status
end
class PaymentGateway::Stripe
def self.pay(description, amount)
# ...
end
end
class PaymentGateway::Fake
def self.pay(description, amount)
if description =~ /failure/
Payment.new(description, amount, :failure)
else
Payment.new(description, amount, :success)
end
end
end
What’s great about this is that I can re-use the fake in many tests as well as in development. I can make the fake more real (but not too real!) by handling important edge case which I can then trivially invoke in, again, both tests & development.
As far as mocking, I seldom do mocking nowadays. Instead of expecting that some object (usually at the boundary of the system) received some message, I’d inspect how the (deterministic) return value of my fake object is being used. In other cases, especially in larger methods when there’s some data being calculated and then sent over to an object, I’d split the method in two: 1 method that does the calculation (unit tested) and the 2nd method that invokes some other object (usually untested, integration tested instead).
Tooling & Practices
I’m constantly impressed how Minitest community is pushing for better tooling and practices. Perhaps the tools/ideas below have not originated in Minitest, but it’s where I first heard about them. For each item I added the year when it was added/considered in Minitest:
- heckle - mutation testing library. (2006)
- Randomize tests by default. (2008)
- assert_nothing_tested - Minitest doesn’t have
assert_nothing_raised
. Instead of asserting that nothing failed, we should assert what the code is actually doing. (2012) - minitest-bisect - finds the smallest amount of tests to run (in a particular order) to reproduce an order-dependant test failure. (2014)
- minitest-proveit - forces all tests to prove success (via at least one assertion) rather than rely on the absence of failure. (2016)
assert_equal
should not allownil
for “expected” - if your code expects anil
then explicitly useassert_nil
, otherwise you might have anil
value and not even know about it. (2016)
Updates
- updated “Tooling & Practices” section by adding description, mentioning heckle, and a link to
minitest-proveit
(thanks @splattael!)