Many ways to write a unit test

Posted by Danny Olson

Wherein we explore multiple ways to write a unit test, and further, discovering an alternative that more fully expresses the intent of the code.

We all practice test-driven development (right?). We write a failing test, make it pass, refactor our code, and repeat until the code does what we want it to do. We now have regression tests to know if any changes break existing functionality, and we have documentation for the functionality we want. This means that a new developer — or us in a week — can reason about the code, and therefore know how to effectively use it.

Given these benefits, and given that regression tests are easy to check (just run them!), we should optimize our tests for readability and maintainability. This means refactoring our test code in addition to our application code. Here’s an example to show how and why.

Example

An investor can view available listings in our marketplace to decide which one to invest in. The listing domain model can go through a few states, depending on the current time compared to its attributes:

  • pending — when the current time is before the preview date
  • preview — when the current time is after the preview date
  • live — when the current time is after the live date
  • expired — when the current time is after the expiration date

How do we test these various state changes? Writing the tests first would have us create an instance with the relevant attributes set to certain times and then asserting that the state is correct.

describe "#status" do
  describe "when after the expiration date" do
    let(:listing) { Fabricate.build(:listing, expiration_date: 1.day.ago) }

    it "is expired" do
      expect(listing.status).to eq(:expired)
    end
  end
end

We would then write a test for each status, and voilà, test-driven development.

Next steps

Now that we have passing tests, we can refactor our code and know that it still works, as long as the tests are green. But what about our tests? Do we ever want to change them to reflect the code?

In our case, what we’re trying to communicate is that a listing changes its state as time progresses, but our tests don’t show that — they are almost context-free since they only care about a specific moment in time. That’s usually a good thing, but we can do better to show our coworkers, and our future selves, what the code is trying to tell us.

An alternative approach

describe "#status" do
  let(:preview_date) { 7.days.ago }
  let(:live_date) { 3.days.from_now }
  let(:expiration_date) { 7.days.from_now }
  let(:listing) {
    Fabricate.build(:listing,
      preview_date: preview_date,
      live_date: live_date,
      expiration_date: expiration_date
    )
  }

  it "starts in pending" do
    Timecop.travel(preview_date - 1.day)

    expect(listing.status).to eq(:pending)
  end

  it "transitions to preview after pending" do
    Timecop.travel(preview_date)

    expect(listing.status).to eq(:preview)
  end

  it "transitions to live after preview" do
    Timecop.travel(live_date)

    expect(listing.status).to eq(:live)
  end

  it "transitions to expired after the live date" do
    Timecop.travel(expiration_date)

    expect(listing.status).to eq(:expired)
  end
end

These tests show us the intent and act as documentation more than our previous example. Is it better than the original version? We find these tests explain the intent better than the previous example, and they cover the same functionality, so I would say, yes, they are better. Better in the sense that

Programs must be written for people to read, and only incidentally for machines to execute.