A Quest for Better Specs: Prefer Self-Containment

Test suites all over Ruby land are frequently riddled with a bad obsession: one liner spec examples.

No matter what level of complexity a particular piece of code may intrinsically have, its specs may only be considered worth when composed of a few lines, ideally of expectations only. RSpec has appropriate tools to meet that rather dubious goal: let statements and subjects.

Let’s examine what‘s bad about those tools and work our way up on conceiving better specs, because time is money.

How specs are often structured

There’s a standard recipe that’s usually followed blindly, without much thinking. RSpec practitioners assume there’s a golden path to writing kick-ass specs, which matches with how RSpec itself encourages thereof composition. It goes like this:

RSpec.describe Foo do
  # 1. Open up a describe block
  describe "#foobar" do
    # 2. Declare setup elements within let statements
    let(:foo) { "foo" }
    let(:bar) { "bar" }
    let(:foobar) { foo + bar }

    # 3. Declare a subject
    subject { described_class.new(foobar).foobar }

    # 4. Use a before block to exercise anything you’ve already declared.
    before do
      # Some setup here
    end

    # 5. Write you expectations, preferrably with an implicit subject.
    it { is_expected.to eq "foobar" }

    # 6. Start a new nested context that overwrites part of your let setup.
    context "with another bar" do
      let(:bar) { "baz" }

      it { is_expected.to eq "foobaz" }
    end
  end
end

We can write nice specs with this recipe — that is, only if we are able to explain our thoughts clearly, by writing a well-defined sequence of contexts that translate their English descriptions into well-formed setup arrangements — with the use of let, subject and before blocks.

Nevertheless, I want to make it clear that the benefits of what I’m calling “surrounding context style” usually don’t outweigh its inherent drawbacks. Bear with me: this is a cool concept, but more often than not it implies in serious maintainability problems.

Everyone who has experienced a failing test from a messy spec file with these characteristics can relate to the pain of understanding the context surrounding a spec example.

The surrounding context problem

We have had many years of RSpec to finally be able to understand the main flaws that arise when using some of its tools.

Reading and extending specs tends to get harder over time, mainly because RSpec does not encourage self-containment. The final effect is that the setup of any example gets scattered and shared throughout a spec file, within bunches of tiny, sprinkled let statements. Sharing setup and making it implicit is highly encouraged by the framework.

The worst case scenario happens when developers are obliged to go back across layers of contexts and describe blocks, in order to mentally join and understand the intended puzzle that’s presented in front of them. This is no fun.

Because let statements are lazily evaluated, they encourage developers to write mind-twisting puzzles. RSpec allows us to use let expressions that reference other “ghost” let statements, which are not defined at the same level of context. These “ghost” let statements can be defined thereafter within nested contexts, in order to fill in the puzzle and make our tests pass.

Are these tools the real problem?

You may argue that the problem does not have to do with surrounding contexts or implicit setups, but rather with badly written tests. That’s true and false at the same time. On one hand, it’s evident that RSpec encourages a wonky kind of setup sharing as an adequate DRY measure.

I’ve come to the conclusion that no matter how you manage to write good specs with these tools, there’s a better way — which avoids unnecessary mental roundtrips and is much more straightforward in general. And most importantly: it does not imply in worse maintainability at all, for it’s a DRY mindset more suitable for tests.

A quick word about the RSpec way

RSpec is an awesome BDD framework and encourages a nice way of thinking about tests. It has really helped on moving the testing mindset forward, with lots of innovative concepts.

That said, I strongly believe let and subject are not appropriate means to RSpec’s proposed end results. These tools try hard to extract repetition and to make specs look like sophisticated scientific papers, but what they really do instead is to add great potential to turn it all into a mess. And they frequently end up doing so.

A practical example

Communicating the actual pain that we usually come across is a challenge, because it’s directly proportional to the spec file’s length— nevertheless, we can present a small example and steadily refactor it to a much more readable format.

Consider the following code:

describe '#candidatable?' do
  let(:user) { build(:candidatable_user, fields) }
  subject(:candidatable) { user.candidatable? }

  it { is_expected.to be_truthy }

  context "without an address" do
    let(:fields) { { address: nil } }

    it { is_expected.to be_falsy }
  end

  context "without a curriculum vitae" do
    let(:fields) { { curriculum_vitae: nil } }

    it { is_expected.to be_falsy }
  end  
end

If you are used to RSpec’s DSL I bet you are able to understand this code. It’s not as bad as what we find in real world projects, but still appropriate enough to illustrate our point. It’s got the perfect form that RSpec practitioners prefer: implicit subjects and scattered setups.

Now comes in an important question: is this a readable spec? I’d bet to say no. Although the structure is interesting, it is anything but readable, and it actually tends to get worse over time as the spec file evolves.

Can you feel how much cognitive load is required to understand what’s going on there? And how many unnecessary indirections there are?

Even if you don’t agree with me by now, hopefully you’ll do after acknowledging the refactored example.

Refactoring our example

First of all, we can start cutting off some let statements. Let’s get rid of “fields”:

describe '#candidatable?' do
  let(:user) { build(:candidatable_user) }
  subject(:candidatable) { user.candidatable? }

  it { is_expected.to be_truthy }

  context "without an address" do
    let(:user) { build(:candidatable_user, address: nil) }

    it { is_expected.to be_falsy }
  end

  context "without a curriculum vitae" do
    let(:user) { build(:candidatable_user, curriculum_vitae: nil) }

    it { is_expected.to be_falsy }
  end  
end

This is a bit easier to read, but examples still aren’t self-contained — thus we can’t understand their essence without looking up the surrounding context. Next up, let’s get rid of the implicit subject.

describe '#candidatable?' do
  let(:user) { build(:candidatable_user) }

  it "is candidatable with an address and a curriculum vitae" do
    expect(user).to be_candidatable
  end

  context "without an address" do
    let(:user) { build(:candidatable_user, address: nil) }

    it "is not candidatable" do
      expect(user).to_not be_candidatable
    end
  end

  context "without a curriculum vitae" do
    let(:user) { build(:candidatable_user, curriculum_vitae: nil) }

    it "is not candidatable" do
      expect(user).to_not be_candidatable
    end
  end  
end

Now it’s easier to understand what’s being tested, even though we’ve introduced a doable fanciness called predicate matchers. Also, we’ve added nice descriptions, making it even more clear what we expect from each example. We still depend on the surrounding context, though.

Now let’s inline all let statements. Let statements encourage developers to overwrite setup bits within nested contexts, and that’s one of the effects we want to avoid.

describe '#candidatable?' do
  it "is candidatable with an address and a curriculum vitae" do
    user = build(:candidatable_user)

    expect(user).to be_candidatable
  end

  context "without an address" do
    it "is not candidatable" do
      user = build(:candidatable_user, address: nil)

      expect(user).to_not be_candidatable
    end
  end

  context "without a curriculum vitae" do
    it "is not candidatable" do
      user = build(:candidatable_user, curriculum_vitae: nil)

      expect(user).to_not be_candidatable
    end
  end
end

That’s it! We’ve cut off all of the unnecessary fanciness. I believe this is closer to the best format we can get to, for readability and maintainability’s sake, but we can still improve it.

Polishing up

Notice that we are hiding a very relevant detail: our first spec example says “a user is candidatable with an address and a curriculum vitae” — but where are the address and curriculum vitae attributes?

Let’s go ahead and make these details explicit; our examples need them in order to become crystal clear:

describe '#candidatable?' do
  it "is candidatable with an address and a curriculum vitae" do
    user = build(:user, address: "Winding Rd", curriculum_vitae: "Hi, I am Bob")

    expect(user).to be_candidatable
  end

  context "without an address" do
    it "is not candidatable" do
      user = build(:user, address: nil, curriculum_vitae: "Hi, I am Bob")

      expect(user).to_not be_candidatable
    end
  end

  context "without a curriculum vitae" do
    it "is not candidatable" do
      user = build(:user, address: "Winding Rd", curriculum_vitae: nil))

      expect(user).to_not be_candidatable
    end
  end
end

This is more explicit and hence better, but we can still add a final touch of polish. Should the “without an address” context expose a curriculum vitae attribute? I don’t think so.

Let’s define a helper method to hold a candidatable’s valid attributes, and place it right above our first example (the position is really important in this case!). We can then reuse this helper method within the two following neighbor contexts, to explicitly state that we are starting out each one from a valid state and invalidating it thereafter.

describe '#candidatable?' do
  def valid_attributes
    { address: "Winding Rd", curriculum_vitae: "Hi, I am Bob" }
  end

  it "is candidatable with an address and a curriculum vitae" do
    user = build(:user, valid_attributes)

    expect(user).to be_candidatable
  end

  context "without an address" do
    it "is not candidatable" do
      user = build(:user, valid_attributes.merge(address: nil))

      expect(user).to_not be_candidatable
    end
  end

  context "without a curriculum vitae" do
    it "is not candidatable" do
      user = build(:user, valid_attributes.merge(curriculum_vitae: nil))

      expect(user).to_not be_candidatable
    end
  end
end

Where we‘ve got at

Compared to our first twisted spec this one has a much better presentation, and here are the reasons:

  • Examples are self-contained and easier to understand. We’ve stopped depending on a magical context, hence developers will save time should these tests ever fail or need to change in the future.

  • Each example exposes its own setup step. That may seem to be repetitive, but it’s not, because each example is supposed to tell a different story. We aren’t hiding any setup steps anymore, so our examples are much easier to follow and trace.

  • The spec file is easier to extend. Adding a new example anywhere is as easy as introducing a new it block, for we don’t have to worry about fitting examples within complex arrangements of lets and subjects — and the worst case of all — about changing everything, because the existing arrangement is not compatible with our new requirements.

  • We are presenting all the relevant details for each example directly within their bodies, so there’s not much question about what’s going on. Bingo!

When let and subject pay off

Let and subject are appropriate tools for use in shared examples. This is the way to go if we ever need to replay the same test code with similar objects that share the same behavior.

That’s the only situation where an RSpec puzzle is the right tool for the job. Here’s an example:

module Walkable
  def walk
    "#{@name} is coming"
  end
end

class Human
  include Walkable

  def initialize(name:)
    @name = name
  end
end

class Zombie
  include Walkable

  def initialize(name:, mood:)
    @name = name
    @mood = mood
  end
end

shared_examples_for "something that walks" do
  it "walks" do
    expect(subject.walk).to eq "#{name} is coming"
  end
end

RSpec.describe Human do
  describe '#walk' do
    let(:name) { 'Rick' }
    subject { described_class.new(name: name) }

    # Expects #walk to return "Rick is coming"
    it_behaves_like "something that walks"
  end
end

RSpec.describe Zombie do
  describe '#walk' do
    let(:name) { 'Shane' }
    subject { described_class.new(name: name, mood: :indifferent) }

    # Expects #walk to return "Shane is coming"
    it_behaves_like "something that walks"
  end
end

Wrap up

RSpec is a really nice BDD framework and introduces a different way of thinking about tests, even though we can still shoot ourselves in the foot with tools that don’t offer many noticeable benefits — such as let and subject.

The message here really boils down to “keep it simple and avoid unnecessary indirections”! To finish up with an analogy, here’s how I often feel about self-contained specs versus specs written with let and subject:

# self-contained specs
puts "Hello World"

# specs written with let and subject
module HelloWorld
  def brag
    "Hello World"
  end
end

class HelloWorldBearer
  include HelloWorld
end

module Kernel
  alias_method :brag, :puts
end

brag HelloWorldBearer.new.brag

In the next episodes we will talk about general testing anti-patterns and also the semantics of good specs, and how you and your team may structure specs in order to achieve awesome results.

We want to work with you. Check out our "What We Do" section!