The problem
Are you having trouble understanding your test suites? Do you find yourself scrolling up and down over a spec file to find where that method comes from? Are your specs difficult to read? If you use RSpec in your daily job I am pretty sure this will sound familiar to you. RSpec is a great testing gem with amazing community support, but in my opinion some features like before
, let
, subject
(and shared features) sailing together in a ship waving the DRY flag can leak to poor test stories hiding the meaning behind them.
Using this FactoryBot‘s Order factory::
1 2 3 4 5 6 7 8 9 |
factory :order do user trait :monthly_subscription do after(:create) do |order| order.plan = SubscriptionPlan.new(name: "Monthly Subscription", period: 1.month) } end end end |
Let’s take a look to the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
describe Orders::Biller do subject { described_class.new(order) } describe "#invoice" do before(:each) { subject.invoice } context "when order is a subscription" do let(:order) { create(:order, :monthly_subscription) } it "should advance next_invoice_at one month" do expect(order.next_invoice_at).to eq(Date.today.next_month) end end context "when order is a regular order" do let(:order) { create(:order) } it "should not advance next_invoice_at one month" do expect(order.next_invoice_at).not_to eq(Date.today.next_month) end end end end |
Here we are testing the method Orders::Biller#invoice
for regular and monthly subscription orders.
Although I have made this example specially for this post, it is based on “DRY” testing examples I find everyday. I could have written something even more confusing with shared_examples
and stuff disseminated across the spec/support
folder&sons, but I didn’t want to be so mean, lets keep it easy this time.
Focus on the example:
1 2 3 4 5 6 7 |
context "when order is a regular order" do let(:order) { create(:order) } it "should not advance next_invoice_at one month" do expect(order.next_invoice_at).not_to eq(Date.today.next_month) end end |
Ideally I would like to see at a glance what is being tested.
I don’t know if it is me, but I personally can’t say which method is being executed here. If you scroll up you will find it lost in a before
block above in the clouds at the beginning of the describe "#invoice"
block. It is also difficult to know which kind of order
is being tested. Where does that user come from? What kind of order is that? To answer that last question you have at least a context coming to the rescue and telling you that we are dealing with a regular order but, what would happen if you have two or three examples, if not more, within that context? Shall you scroll again? (╯°□°)╯︵ ┻━┻
While DRY is usually a great principle you should apply in your code base, when it comes to testing I prefer to sacrifice it in case it compromises the readability of the test.
Test phases
To write a good test story I use Thoughtbot’s four-phase test pattern. Each of your tests should have the following structure:
- Setup all the conditions required by your test
- Exercise the method being tested
- Verification of the results
- Teardown everything that should not persist after the test has finished
Having that in mind, I have refactored the example above as it follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
describe Orders::Biller do describe "#invoice" do it "should advance next_invoice_at one month for monthly subscription orders" do order = create(:order, :monthly_subscription) described_class.new(order).invoice expect(order.next_invoice_at).to eq(Date.today.next_month) end it "should not advance next_invoice_at for regular orders" do order = create(:order) described_class.new(order).invoice expect(order.next_invoice_at).not_to eq(Date.today.next_month) end end end |
Can you see now what it’s being tested? For me the readability has improved a lot. Now you can see which conditions does the test need to setup before it runs, you can also appreciate the method being tested and also what is being verified.
How do test phases apply to our example?
Lets take a look to the example we saw before refactoring, note I have added a comment with the phase name for clarity:
1 2 3 4 5 6 7 8 9 10 |
it "should advance next_invoice_at one month for monthly subscription orders" do # Setup order = build_monthly_subscription_order # Exercise described_class.new(order).invoice # Verification expect(order.next_invoice_at).to eq(Date.today.next_month) end |
For the setup phase I have extracted everything needed to build a monthly subscription order out to build_monthly_subscription_order
method, then the method being executed is there, clear, on its own exercise line and last you verify that everything went as expected. In this case, and in most cases, no teardown phase will be needed, as garbage collection will do its job after the show finishes to leave everything neat and pretty. In case your tests hit a database, you could wrap the teardown methods in an around
block or use a gem like database-cleaner as in most cases this won’t be relevant for your test story’s readability.
I hope you enjoyed this post, if you did, please share it!
Very good post! Thanks!!