Expressing Sameness and Difference in RSpec

There are many simultaneous goals in testing software. One is certainly to verify that your system works, but its also important that tests express what your system does. When tests are expressive, they enable developers to understand unfamiliar parts of the system, and provide an anchor for discussions about how new behavior should be added.

Here’s an example of a test that is correct, but not very expressive:

describe Invoice do
  subject { described_class.new(user: user, items: items) }
  let(:user) { FactoryGirl.create(:user, state: state) }
  let(:items) { 2.times.map { FactoryGirl.create(:item, price: 100) } }
  let(:state) { 'NY' }

  describe '#sales_tax' do
    context 'in California' do
      let(:state) { 'CA' }

      it 'charges sales tax' do
        invoice.finalize!
        expect(subject.sales_tax).to eq(200 * 0.0875)
      end
    end

    context 'in Oregon' do
      let(:state) { 'OR' }

      it 'does not charge sales tax' do
        invoice.finalize!
        expect(subject.sales_tax).to eq(0)
      end
    end
  end
end

Sure, if someone came along and broke sales tax calculation rules, these tests would fail, but where would you look if you wanted to understand invoicing? If the whole file looked like this, you would probably just go straight to the code.

Whats wrong with that?

  • By looking into our code to understand its behavior, we take on unnecessary cognitive load. We look at the nitty-gritty of how our system behaves rather than thinking in abstractions.
  • We get ourselves used to thinking about implementation rather than interfaces. Systems that are built up from successive layers of properly defined interfaces, can be modified easily. By reading tests before looking at the implementation, you get used to thinking of every class you write as a public interface.

So lets try to make these specs more expressive with the hope that they will become the de-facto source of knowledge about our system.

What are we trying to express?

These tests are trying to express that in different contexts, there is a mix of similar and different behavior. Our current tests muddle these two together though, and you have to read them very closely to notice what they’re doing. As you read, you go back and forth comparing the two tests. You notice that they both set a state on the user, but to different values. Then they both call finalize! on the invoice in the same way. Then there’s a similar expectation but with different values. Phew!

With these simple tests, its pretty easy actually, but as things become more complex, it can be hard to spot what aspects of sameness and difference each test is highlighting. Like a child’s picture book of Can You Spot the Difference, each test changes one aspect of the input or expected output, but hides it in a forest of similarities.

Refactoring to express sameness and difference

Lets start by pulling out the differences in user state:

describe Invoice do
  subject { described_class.new(user: user, items: items) }
  let(:user) { FactoryGirl.create(:user, state: state) }
  let(:items) { 2.times.map { FactoryGirl.create(:item, price: 100) } }
  let(:state) { 'NY' }

  describe '#sales_tax' do
    context 'in California' do
      let(:state) { 'CA' }

      it 'charges sales tax' do
        invoice.finalize!
        expect(subject.sales_tax).to eq(200 * 0.0875)
      end
    end

    context 'in Oregon' do
      let(:state) { 'OR' }

      it 'does not charge sales tax' do
        invoice.finalize!
        expect(subject.sales_tax).to eq(0)
      end
    end
  end
end

We set the state on the user when we create them and leave a sane default of ‘NY’ for other tests. Now when someone reads these tests, they can clearly see that the state is being changed in these two contexts. We’ve also paved the way for writing other location-dependent invoice tests without having to repeat ourselves.

This is better, but the tests are still a mix of sameness and difference. What is the same? They both call invoice.finalize! in the same way, and subject.sales_tax is still called in the expectation. The only difference is in the amount expected. In order to pull these apart further, lets monkey patch RSpec:

module RSpec
  module Core
    module MemoizedHelpers
      module ClassMethods
        alias_method :expected, :let
      end
    end
  end
end

This just creates an alias so that we can use expected instead of let. Here’s why:

describe Invoice do
  subject { described_class.new(user: user, items: items) }
  let(:user) { FactoryGirl.create(:user, state: state) }
  let(:items) { 2.times.map { FactoryGirl.create(:item, price: 100) } }
  let(:state) { 'NY' }

  describe '#sales_tax' do
    shared_examples_for 'calculates sales tax' do
      it 'calculates sales tax' do
        invoice.finalize!
        expect(subject.sales_tax).to eq(sales_tax)
      end
    end

    context 'in CA' do
      let(:state) { 'CA' }
      expected(:sales_tax) { 200 * 0.0875 }
      it_behaves_like 'calculates sales tax'
    end

    context 'in OR' do
      let(:state) { 'OR' }
      expected(:sales_tax) { 0 }
      it_behaves_like 'calculates sales tax'
    end
  end
end

In my opinion this is much more expressive. Everything that is the same lives in a shared example. When reading from top to bottom, the reader is now prepared that we’re going to be calculating sales tax in the same way in the tests to follow. Then we use let and its aliased sibling expected to specify differences in inputs and outputs.

I like the use of expected here because it draws a careful distinction between inputs and outputs. It makes it much more obvious what is being tested. In other words, its expressive!

Benefits of this technique

An argument could certainly be made that in the case of these contrived examples, we’ve made our tests unnecessarily complex, with an unjustified layer of indirection. As with most refactorings, this technique really shines as complexity grows. The next time you notice that your tests are repetitive to the point of not expressing the behavior of your system, consider pulling more out into shared_examples_for, shared_context, and let. And don’t be afraid to use let to encapsulate data for your expected results, not just your inputs. By doing this your tests will become easier to understand and extend, and engineers will be encouraged to think in more abstract ways about their code.

Leave a Reply

Your email address will not be published. Required fields are marked *