When we write clean Ruby code, we try to pull out methods with descriptive names that do small amounts of work. It’s possible to do the same in RSpec, just as we would in a less “fluent” test framework like Ruby’s standard testing library Minitest.

RSpec’s describe and context methods define anonymous classes that behave like any other Ruby class as far as scope. Nested blocks even inherit methods from their containers and can use super. Each it block is more like a method, creating an instance of its outer describe/ context and executing in that scope.

With this, we can extract pieces of logic, share them between multiple specs, give them descriptive names, and call them from within it blocks. This leads to descriptive tests that don’t suffer from the Mystery Guest problem: when reading tests, we can’t understand “the connection between fixture and verification logic because it is done outside of the test method.”

This code smell is often introduced by using before or let in RSpec tests:

RSpec.describe PlayerCharacter do
  subject { PlayerCharacter.new }

  context "rogue" do
    subject { PlayerCharacter.new.tap { |pc| pc.add_level(:rogue) } }

    it "has sneak attack" do
      expect(subject).to have_sneak_attack
    end

  end
end

“Isn’t this WET?”

An argument I’ve often heard against this type of approach is that it leads to longer, more complex, less DRY tests. This is a misunderstanding of the problem!

There is a smell associated with complex test setup: generally speaking, if a system is difficult to test, it is overly complex. Usually this is because it has many collaborators, does too many things, or violates the Law of Demeter.

What about let ?

RSpec loves let and its other DSL methods. It’s a shortcut to writing a method, which is part of why defining methods explicitly works. But let is not Ruby, and using it is an unnecessary abstraction. Defining a method is a little bit longer, but it is clearer to the reader what is happening with a method than with a let, some complex before block that isn’t referenced, a shared_context, etc. For one-liners such as let is meant to facilitate, it’s also ~13% longer to write let(:rogue) { create(:rogue) } than it is to use Ruby 3’s new endless method syntax: def rogue = create(:rogue) .

Rather than hiding the setup in a Cambrian explosion of before, around, let, let!, subject, etc., it is beneficial to have this setup as part of the test method. Extracting named methods maintains the benefit here because they are explicitly included and therefore are no longer a mystery.

Extract methods from specs

Writing methods in RSpec is pretty easy, but there are a couple of “gotchas”: polluting the global scope and trying to define methods within it blocks.

We want to avoid defining methods in the global scope so there is no chance of redefining something available in our app, either globally or because of scope within a class. Instead, be sure to write them inside the describe or context block that allows all tests needing the method to access it without providing the method to additional tests. Sometimes it makes sense to build up a new grouping of tests that need to share the method, and other times it is easiest to just write them into the outermost describe block.

I’ve also made the mistake a few times of trying to write methods inside of it blocks, which is akin to writing methods inside of methods. Make sure that helper methods are defined outside of it.

As you can see below, we’re able to define rogue as a helper for the entire context "rogue" block, then override it and call super in a child context because of the class inheritance we talked about earlier. The rogue method itself is defined in terms of the even broader pc helper that can be shared with the snipped specs for other player character classes.

RSpec.describe PlayerCharacter do
  context "rogue" do
    it "has sneak attack" do
      expect(rogue).to have_sneak_attack
    end

    context "at level 6" do
      it "has expertise" do
        expect(rogue).to have_expertise
      end

      def rogue
        super.tap do |r|
          5.times { r.add_level(:rogue) }
        end
      end
    end

    def rogue
      pc(:rogue)
    end
  end

  # (snip specs for other classes...)

  def pc(*levels)
    pc = PlayerCharacter.new
    levels.each { |l| pc.add_level(l) }
    pc
  end
end

As with a class, I prefer to define these helper methods below the tests that use them. Unlike in a class, I recommend not extracting methods to service objects. We want to highlight complexity in our test setup so that we feel the pain of it—and have a desire to reduce that complexity either when writing our tests or later when reading them.

Sharing code between specs

That said, if a method is going to be useful across multiple systems under test, it does make sense to extract those methods into modules under spec/support/. This is because RSpec requires all files in that directory by default, and we can include them into all specs as part of spec_ or rails_helper.

RSpec.configure do |config|
  config.include HelperModule
end

module HelperModule
  def pc(*levels)
    pc = PlayerCharacter.new
    levels.each { |l| pc.add_level(l) }
    pc
  end
end

Bonus: Tooling Compatibility

While some IDEs are able to make an attempt at identifying where a let method is defined, most aren’t. Defining real methods will allow tools like universal-ctags, GitHub’s source popup links, etc. to identify where the method is defined and let you quickly navigate to them with tools that use tags files like Vim’s ] jump to definition command.

Conclusion

Extracting shared setup from RSpec tests with methods helps us to build up a well-documented, mystery-free, clean suite of tests. They’re easy to define, easy to scope, and are a better practice to use than RSpec’s inbuilt tools like before, subject, and let.

Caleb Hearth

Hash An icon of a hash sign Code Name
Agent 00136
Location An icon of a map marker Location
Austin, TX