by Kevin Murphy
Testing Around the Clock (Sunday)
Tests will sometimes require us to bend the truth and modify the state of the world in order to have repeatable, consistent results and feedback.
We're building an application that sends a notification on Sunday. In order to test it sending a notification, we need it to be Sunday. The problem with that is I don't really like working on Sundays.
If I'm writing code on Wednesday, I want to make sure I don't break notifications. Rather than waiting until Sunday to run my tests, I can trick my test into thinking it's Sunday. Rails has built-in helpers we can use to simulate that.
travel_to Time.zone.local(2021, 8, 1, 1, 2, 34)
notifier = Notifier.new
expect(notifier.send_notification?).to eq true
When we do this, we want to ensure that the results are temporary. Other tests, or even areas within a given test, shouldn't be affected by this state change. It should still be whatever day the tests are really run otherwise.
Now, in the case of these helpers from Rails, according to the documentation:
The stubs are automatically removed at the end of the test.
However, we want to be clear to readers who may not have perused the Rails documentation (nor have encyclopedic knowledge of it). Additionally, we may only want part of the test to think it's Sunday. In order to isolate this change, we can limit its impact to be within a block.
notifier = Notifier.new
travel_to Time.zone.local(2021, 8, 1, 1, 2, 34) do
expect(notifier.send_notification?).to eq true
end
travel_to Time.zone.local(2021, 8, 2, 1, 2, 34) do
expect(notifier.send_notification?).to eq false
end
Benefits of the Block (Little Thoughts)
The block helps visually distinguish the scope of the change, in addition to ensuring that the change is limited to the code within the block. I find it to be more readable and clear using a block. We have temporarily changed the state of the world, but only in a limited capacity.
As far as the test is concerned, it's Sunday in the first block, Monday in the second block, and whatever day of the week you're reading this before and after the blocks.
Disregarding Bullet Recommendations (Real Talk)
If you've used Bullet before for eager loading optimization, you may have run into situations in which you wished it wouldn't tell you something either needed or didn't need to be eagerly loaded.
Now, some times that's the tool doing its job: giving you feedback you may not have expected (or wanted). However, in tests, it may be giving you a false positive because of your test setup.
In a test that calls a method where only one record exists in your database, Bullet may suggest that you don't need the eager loading. And Bullet is correct - in this case, for this test. However, the setup in that test may not reflect reality or the expectation most of the time. It's much more likely that there are many records, which will cause Bullet to recommend the includes
were you to get rid of it.
How can we keep our test and satisfy Bullet? We don't; instead, let's turn Bullet off. But, we don't want Bullet to stay off for the rest of the test run. We only want it off when calling that one method in this one test.
If you're using RSpec, you can use tags to turn Bullet off
for an entire test, file, or context. It's a clever and succinct approach.
This is a post about blocks, so let's discuss another way.
Turn It Off And On Again (Flux)
We've discussed how to use a method that takes a block to temporarily
change state in our application, specifically tests. Let's start by writing the
code we want:
it "doesn't complain about Bullet warnings" do
disable_bullet do
expect(Notification.method_that_eager_loads_associations.size).to eq 1
end
end
Let's write an implementation of disable_bullet
. We can use yield
to wrap
turning Bullet off and on again inside of disable_bullet
:
def disable_bullet
Bullet.enable = false
yield
Bullet.enable = true
end
Calling this method turns Bullet off, runs whatever code is in the block passed
to it, and then turns Bullet back on after the block completes. We can make the
block optional by calling the block_given?
method:
def disable_bullet
Bullet.enable = false
yield if block_given?
Bullet.enable = true
end
However, we're going to remove that in this case because there's no use case for calling this method otherwise. We'll get an error without passing a block to disable_bullet
, and that's ok.
Why Did I Do This Again? (I Still Remember)
When you're writing the test that you need to disable Bullet, you likely have the context of why Bullet needed to be turned off. However, when you're reading it later, you may forget. And maybe someone else wrote this test and you don't know to begin with. Is it reasonable to have turned Bullet off? We can force this to be documented in the test by making an addition to our method.
def disable_bullet(reason:)
Bullet.enable = false
Bullet.enable = true
end
Callers are now required to pass in a reason why Bullet has been turned off. It's not used in the method, but it helps enforce a thoughtful understanding of why this tool is being turned off when the method is called.
it "doesn't complain about Bullet warnings" do
disable_bullet(reason: "Trust me. But seriously, here's why...") do
expect(Notification.method_that_eager_loads_associations.size).to eq 1
end
end
By using a block here, we have provided a way to temporarily change the state of the world with an aesthetic that clearly denotes the scope of the change, and we also added a way to force users to justify their use of the method to themselves and others.
Inspiration (Pioneers)
Thanks to Rob Whittaker for asking me about
other ways I like to use blocks, based on an earlier post.
Learn more about how The Gnar builds Ruby on Rails applications.