Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Testing

How to write clean Ruby RSpec tests

Discover the secrets to writing clean, efficient, and maintainable Ruby RSpec tests. Learn to extract shared setup and avoid common pitfalls in your testing process.
Caleb Hearth
|
June 28, 2022
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

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) .

Nine times out of ten, clarity beats brevity when writing code. pic.twitter.com/fiyjPWSx1z— Dare Obasanjo (@Carnage4Life) May 27, 2022

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.

Related Insights

🔗
How to stop hating your tests
🔗
Breaking up (with) your test suite
🔗
The failures of introducing test driven development

Explore our insights

See all insights
Developers
Developers
Developers
You’re holding it wrong! The double loop model for agentic coding

Joé Dupuis has noticed an influx of videos and blog posts about the "correct" way of working with AI agents. Joé thinks most of it is bad advice, and has a better approach he wants to show you.

by
Joé Dupuis
Leadership
Leadership
Leadership
Don't play it safe: Improve your continuous discovery process to reduce risk

We often front-load discovery to feel confident before building—but that’s not real agility. This post explores how continuous learning reduces risk better than perfect plans ever could.

by
Doc Norton
Leadership
Leadership
Leadership
How an early-stage startup engineering team improved the bottom line fast

A fast-growing startup was burning cash faster than it could scale. Here’s how smart engineering decisions helped them improve the bottom line.

by
Jonathon Baugh
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.