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
Leadership
Leadership
Leadership
The business of AI: Solve real problems for real people

After participating in the Perplexity AI Business Fellowship, one thing became clear: the AI hype cycle is missing the business fundamentals. Here are 3 evidence-based insights from practitioners actually building or investing in AI solutions that solve real problems.

by
Cathy Colliver
Leadership
Leadership
Leadership
Pragmatic approaches to agentic coding for engineering leaders

Discover essential practices for AI agentic coding to enhance your team’s AI development learning and adoption, while avoiding common pitfalls of vibe coding.

by
A.J. Hekman
by
Aaron Gough
by
Alex Martin
by
Dave Mosher
by
David Lewis
Developers
Developers
Developers
16 things software developers believe, per a Justin Searls survey

Ruby on Rails developer Justin Searls made a personality quiz, and more than 7,000 software developers filled it out. Here's what it revealed.

by
Justin Searls
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.