One day while fixing a broken unit test I began to get a sense of déjà vu. I was in the middle of making a big domain change, and had spent the better part of an afternoon fixing failing tests. The test I was looking at seemed so familiar that I began looking through my changes to figure out why.
I found the cause. Earlier I had fixed the tests for a class called SalesReport
. SalesReport
had query methods over a given time period, which required a good amount of database setup to test. The current set of failing tests were for a SalesReportMailer
that used SalesReport
to generate its data. Its tests had a lot of setup code just for the SalesReport
:
class SalesReportMailer < ActionMailer::Base
def status_report(organization)
@organization = organization
@report = SalesReport.new(organization)
send_mail
end
end
# sales_report_mailer_spec.rb
describe '#status_report' do
it 'displays message if sales have improved from last week to this week' do
organization = create_organization
# setup to make this week sales better than last week sales
customer = create_customer(organization: organization)
product = create_product(organization: organization, unit_price: 2)
this_week_invoice = create_invoice(customer: customer, date: 2.days.ago)
this_week_invoice.add_line_item(product: product, quantity: 2000)
last_week_invoice = create_invoice(customer: customer, date: 8.days.ago)
last_week_invoice.add_line_item(product: product, quantity: 1500)
SalesReportMailer.status_report(organization).deliver_now
expect_sales_improved_message_to_display
end
end
In the SalesReport
test, similar setup:
# sales_report_spec.rb
describe 'sales_improved?' do
it 'is true if this week sales greater than last week sales' do
organization = create_organization
customer = create_customer(organization: organization)
product = create_product(organization: organization, unit_price: 2)
this_week_invoice = create_invoice(customer: customer, date: 2.days.ago)
this_week_invoice.add_line_item(product: product, quantity: 2000)
last_week_invoice = create_invoice(customer: customer, date: 8.days.ago)
last_week_invoice.add_line_item(product: product, quantity: 1500)
sales_report = SalesReport.new(organization)
expect(sales_report.sales_improved?).to be_truthy
end
end
No wonder I had déjà vu. The two tests were full of similar cases like this. The setup in the mailer tests was duplicated from the report tests. I was fixing the same broken SalesReport
test setup I had fixed in SalesReport
's test.
This is what I think of as an accidental integration test. It probably wasn't the author's intent to test SalesReport
again, but it happened as a result of tight coupling between the classes. You can't construct a SalesReportMailer
without constructing a SalesReport
, which means you can't test the mailer without indirectly testing the report. A domain change that affected the report and its tests required fixing the mailer's tests as well.
A small design change that fixes this is to pass the report to the mailer rather than having the mailer construct the report. This is known as dependency injection. One of the benefits is that it allows you to provide alternative implementations of dependencies in tests. With this change, the mailer tests can easily use a stub implementation of the SalesReport
. The stub can generate whatever outcome the tests need without having to seed the database for a real SalesReport
. The SalesReportMailer
tests become much simpler:
class SalesReportMailer < ActionMailer::Base
def status_report(organization, report)
@organization = organization
@report = report
send_mail
end
end
# sales_report_mailer_spec.rb
it 'displays message if sales have improved from last week to this week' do
organization = create_organization
sales_report = double(sales_improved?: true)
SalesReportMailer.status_report(organization, sales_report).deliver_now
expect_sales_improved_message_to_display
end
With this change the coupling between the two classes is reduced to knowledge of the SalesReport
public API. The looser coupling is reflected in the SalesReportMailer
tests, which no longer duplicate domain knowledge from the SalesReport
tests. Now they are simpler and more focused on the behavior of the mailer. They won't break if the implementation of SalesReport
is broken, and they won't have to change the next time that implementation changes.