The letter A styled as Alchemists logo. lchemists
Published June 1, 2022 Updated July 2, 2026
Cover
RSpec Antipatterns

Testing isn’t Hard. Testing is easy in the presence of good design.

— Michael Feathers

If your code isn’t testable, then that isn’t a good design.

— Michael Feathers

Your specs are not only tests but documentation on the behavior of your implementation — hence them being called specifications. When your specs are hard to write, that is a strong indicator that your implementation is too complicated. In this article, I’ll help identify what those antipatterns are, how to avoid them, and how best to correct them.

💡 This is a companion to an earlier article on Ruby Antipatterns which might be of aid/interest as well.

Subjects

Subjects are the entry point into your spec so the following sections focus on how to make use of good subjects before diving into the body of your specs.

Hard Codes

A hard coded subject is a subject that is duplicated in the subject or — worse — typed over and over again throughout the entirety of the spec. Example:

# No
RSpec.describe Pinger do
  subject(:pinger) { Pinger.new }
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }
end

Using RSpec’s described_class allows you to use the class as described in the RSpec.describe Pinger block which begins your spec. Doing this allows you to quickly refactor or rename your spec should your implementation change thus saving you a lot of time finding and replacing all usage of your subject. You also want to use described_class when testing class methods as well.

Implicits

RuboCop RSpec will catch this violation but is important to emphasize because too many test suites ignore this or not use RuboCop at all. Example:

# No
RSpec.describe Pinger do
  describe "#call" do
    it "answers success status" do
      expect(subject.call).to eq(200)
    end
  end
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    it "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end
end

Avoid using an implicit subject — even though RSpec will support it — because being generic causes confusion and decreases the readability of your spec. Giving your subject a proper name — or a name that will be most commonly used throughout your implementation — along with providing any additional initialization support provides a much more realistic and maintainable spec.

Misused Lets

A common fallacy is thinking both subject and let can be used for the same purpose — to properly memoize and clean up an object between each spec — but this is not true. Avoid using let as a subject. Example:

# No
RSpec.describe Pinger do
  let(:pinger) { described_class.new http: }
  let(:http) { class_spy HTTP }
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new http: }

  let(:http) { class_spy HTTP }
end

Being able to clearly identify and distinguish who the subject of your test suite vastly improves the readability of your test suite.

Misused Method

Your subject should never be the result of message you send to it. Your subject is either your class — for which you can use described_class — or the instance (most common use case). Example:

# No
RSpec.describe Pinger do
  subject(:pinger) { described_class.new.call }
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  before { pinger.call }
end

The reasoning for this antipattern is that I find teams thinking this is a clever way to use the subject to reduce repetition but RSpec has a simple answer to this problem which is to put common functionality within a callback — like a before block — without forcing engineers to be surprised when the subject doesn’t behave the way they would intuit.

Missing

You can definitely write specs without subjects but doing so makes them hard to read and maintain, especially when the subject is repeatedly typed multiple times throughout the specs. Example:

# No
RSpec.describe Pinger do
  describe "#call" do
    it "answers success status" do
      expect(Pinger.new.call).to eq(200)
    end
  end
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    it "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end
end

Notice how the pinger subject is clearly defined at the start of the specs and allows you to reference it throughout the spec. This consistency — by defining a properly labeled subject — allows anyone to read through the spec and know every time they see pinger that it’s referring to the current subject.

Lets

Lets are perfect for temporary memoization and cleanup of ancillary objects to aid your testing. The following provides a few guidelines to keep your specs healthy:

  • Use let when needing the same object for each spec within a context.

  • Avoid let or let! as your subject, as mentioned above.

  • Avoid let! instead of let.

  • Avoid let for performing operations. Use before, around, or after instead.

For example, consider the following use of let!:

let!(:io) { StringIO.new }

Unfortunately, the above leads to these issues:

  • Immediately creates the io object whether you need it or not. This, in turn, infects all of your examples.

  • Assumes io is a precondition for all examples which is easy to overlook. This causes surprising behavior of additional objects showing up in your tests when you farther down your test suite and can no longer see the let! or forgot the let! was there to begin with.

  • Obfuscates the order of execution because you might need the io object to be lazily loaded after your subject is created or when you only need the io object for some examples, but not all.

You can alleviate all of the above issues by using using let instead:

let(:io) { StringIO.new }

In rare situations in which you need io to exist before an example is run, use a before block. Example:

before { io }

If you never need to reference io directly, you can avoid the let! (or let) by using the before block to create what you need:

before { Bundler.root.join("tmp/test.txt").touch }

Even better, you can avoid the before block entirely by lazy loading the io object in your expectation. Example (assuming writer is your subject):

expect(writer.call(io:)).to eq("A test.")

What’s nice about the above is the io object is initialized and immediately used at time of expectation without using a let! or before block. This also avoids the order of operation issue because the io is initialized first followed by writer (subject) which keeps all of this logic in a single line!

Describes

Use of describe blocks — at the top level of your spec — help describe behavior for your subject — the purpose of your spec — along with all class and instance methods. Class methods go at the top of your spec while instance methods follow after. The order of each describe needs to match the order which the method was defined in your implementation. This simplifies split viewing, within your editor, so your implementation is loaded on the left and the corresponding spec is loaded on the right allowing you to scroll up and down, roughly, at the same line level.

By describing each method of your object, you provide documentation on all possible behavior — in addition to having good test coverage — which is important for readability and understanding. Even if there is only a single public method on your object, this detail is important. This also makes running this command infinitely more useful:

rspec spec --dry-run --format doc > tmp/rspec-overview.txt

Now you have a way to quickly get a bird’s eye view of your entire implementation complete with usage and behavior. The sections below illustrate this further.

Missing

Always — and this is important — describe the methods on your object. Example:

# No
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  it "answers success status" do
    expect(pinger.call).to eq(200)
  end
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    it "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end
end

Class Methods

Class methods must be tested like an instance method would be tested. Always use a dot (.) to describe a class method. Example:

# No
RSpec.describe Pinger do
  describe "for stage" do
    it "answers success" do
      expect(described_class.for_stage).to eq(200)
    end
  end
end

# Yes
RSpec.describe Pinger do
  describe ".for_stage" do
    it "answers success" do
      expect(described_class.for_stage).to eq(200)
    end
  end
end

If you find your spec has more class methods than instance methods, this might be a sign that you have behavior that could be extracted into a new object

Instance Methods

As with class methods, instance methods must be clearly defined using hash notification (#). Example:

# No
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "a command" do
    it "answers success" do
      expect(pinger.call).to eq(200)
    end
  end
end

# Yes
RSpec.describe Pinger do
  describe "#call" do
    it "answers success" do
      expect(pinger.call).to eq(200)
    end
  end
end

Being able to visually call attention to class or instance methods improves readability of your specs.

Contexts

If you can avoid using contexts, do so. That said, contexts can be useful when calling out alternate behavior such as using custom subject, let, before/after blocks, and so forth.

Deep Nests

Avoid using nested contexts. If you have to nest a context within another context, consider making your implementation easier to test instead. Example:

# No
context "with level one" do
  context "with level two" do
    context "with level three" do
      # Spec details.
    end
  end
end

# Yes
context "with alternative behavior" do
  # Spec details.
end

Empty Nests

You want to avoid using a context without a subject, let, before, or other kinds of blocks because it causes unnecessary nesting. Example:

# No
RSpec.describe Pinger do
  context "with console logging" do
    describe "#call" do
      it "sends request" do
        http = class_spy HTTP
        Pinger.new(http:)

        expect(http).to have_received(:get).with("https://www.example.com")
      end
    end
  end
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new http: }

  context "with alternative HTTP client" do
    let(:http) { class_spy HTTP }

    describe "#call" do
      it "sends request" do
        expect(http).to have_received(:get).with("https://www.example.com")
      end
    end
  end
end

The sole purpose of a context is to provide an alternate setup to the main flow of your specs. A context brings attention to these differences but shouldn’t be used for the sake nesting purposes only.

Its

There are three ways to write specs: it, example, and specify. The most common — and best — approach is the it block. Example:

RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    # Yes
    it "answers success status" do
      expect(pinger.call).to eq(200)
    end

    # No
    example "answers success status" do
      expect(pinger.call).to eq(200)
    end

    # No
    specify "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end
end

The advantages to using it blocks are:

  • Use of it requires less typing than specify or example.

  • Consistency prevents the reader from having to constantly distinguish between the significance of all three syntaxes being used.

Expectations

Expectations are a core aspect of writing specs which are important to get right in order to keep readability and maintenance high. The following details what to watch out for and what steps you can take to ensure your expectations remain clean.

Misused Blocks

There are several situations in which you need to use block syntax in your expectations. Unfortunately, this leads to hard to read code due to the complex nest of brackets required to write the expectation. Example:

# No - One Line
RSpec.describe User do
  describe ".create" do
    it "creates new record" do
      expect { described_class.create! name: "Jill Smith" }.to change { described_class.count }.from(0).to(1)
    end
  end
end

# No - Multiple Lines
RSpec.describe User do
  describe ".create" do
    it "creates new record" do
      expect {
        described_class.create! name: "Jill Smith"
      }.to change {
        described_class.count
      }.from(0).to(1)
    end
  end
end

# Yes
RSpec.describe User do
  describe ".create" do
    it "creates new record" do
      expectation = proc { described_class.create! name: "Jill Smith" }
      count = proc { described_class.count }

      expect(&expectation).to change(&count).from(0).to(1)
    end
  end
end

Using a Proc is an elegant way to explain and describe your setup through local variables while allowing you to use a single line for your expect. Doing so makes your spec easier to read and maintain instead of having to sift through nested brackets whether they be on one line or spread across multiple lines.

Unnecessary Multiples

When writing expectations, some teams will include multiple expectations for the same spec. Please avoid this! For example, let’s say we have a situation where we need to verify multiple attributes at once:

it "answers record" do
  record = creator.call

  expect(record.label).to eq("Jupiter")
  expect(record.kind).to eq("planet")
  expect(record.position).to eq(5)
  expect(Planets.count).to eq(1)
end

If there is a failure with any one of the above expectations, you’ll only see the first failure while not realizing there are other failures until you run the spec again. Unfortunately, this forces everyone to deal with a whack-a-mole situation to figure out which, out of all expectations, is faulty. This is confusing and frustrating to maintain.

One way teams solve this is to aggregate the failures. For example, here’s the same spec again using :aggregate_failures metadata:

it "answers record" :aggregate_failures do
  record = creator.call

  expect(record.label).to eq("Jupiter")
  expect(record.kind).to eq("planet")
  expect(record.position).to eq(5)
  expect(Planets.count).to eq(1)
end

Even worse, some teams wrap this in a block which incurs additional nesting within your spec:

it "answers record" do
  record = creator.call

  aggregate_failures do
    expect(record.label).to eq("Jupiter")
    expect(record.kind).to eq("planet")
    expect(record.position).to eq(5)
    expect(Planets.count).to eq(1)
  end
end

Both of the above do the same thing in that they aggregate all failures at once so when the spec fails, you’ll see all expectations that are failing at once. You might think this simplifies solving the spec failures and you’d be partially correct while not solving the root problem which is you should only have one expectation per spec. Here’s how to rewrite the above spec so we’re using a single expectation only:

it "answers record" do
  expect(creator.call).to have_attributes(
    label: "Jupiter",
    kind: "planet",
    position: 5
  )
end

Notice how much nicer this is. Not only do you have a single expectation for your spec but you’ve clarified what you are testing at the same time. We no longer need to check for record count either because that’s implicit in the spec but, even if you needed to check for record count, that would need to be a separate spec anyway.

With all of this said, there can be situations where you need to aggregate failures because there is no convenient alternative or is necessary for performance reasons. In most cases, you can make aggregation a last resort by striving to have one expectation per spec first.

Custom Methods

Within any spec, you can define methods within them. Generally, these are known as helper methods which are meant to set up or aid with testing. Example:

# No
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    it "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end

  def helper_one
    # Implementation details.
  end

  def helper_two
    # Implementation details.
  end
end

# Yes
RSpec.describe Pinger do
  subject(:pinger) { described_class.new }

  describe "#call" do
    before do
      # Step 1.
      # Step 2.
    end

    it "answers success status" do
      expect(pinger.call).to eq(200)
    end
  end
end

While helper/utility methods start out with good intentions, they inevitably end up making specs hard to maintain. In essence, all of these custom and extra methods end up being a glue layer between your implementation and specs. This glue layer will eventually become a major source of maintenance frustration within your specs as they grow.

A simple solution is to use a callback, like a before block. However, use of a before block is not wise because before blocks don’t accept arguments and are not meant to be used for doing complex operations.

Another solution is to define your helper methods in a module and then configure RSpec to include them. Example:

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

module RSpecHelpers
  def helper_one
    # Implementation details.
  end

  def helper_two
    # Implementation details.
  end
end

Again, this doesn’t solve the problem because complexity is being swept into a module — or series of modules — to reduce duplication but isn’t addressing the root problem of complexity since the complexity is only being moved laterally.

Focus, instead, on making your implementation easier to use and then a lot of these helper methods and glue logic can be eliminated. Lastly, here are some additional alternatives that might give you what you need:

If the above doesn’t solve your problem, then remember to take a hard look at your implementation and fix it instead because your specs are waiving a warning flag. You only need to watch for the signs.

Skip and Pending

Use of skip and pending are useful tools to use when you temporarily need to disable a problematic spec. Reach for pending over skip because the advantage is that your test suite will immediately start failing should your pending spec start working while skip will ignore the spec indefinitely. Example:

# No
it "answers success status" do
  skip "Need to upgrade to the latest HTTP gem version first."
  expect(pinger.call).to eq(200)
end

# Yes
it "answers success status" do
  pending "Need to upgrade to the latest HTTP gem version first."
  expect(pinger.call).to eq(200)
end

Regardless of your choice, strive to resolve these specs quickly so they don’t become permanently disabled and add unnecessary noise to your test suite.

Test Doubles

I’ve written about RSpec Test Doubles before so will add that if you need a good fake for dealing with HTTP requests, consider adding the HTTP Fake gem to your test suite.

Matchers

Matchers are a great way to reduce duplicated effort within your test suite while enhancing the readability of your specs at the same time.

Predicates

Predicate Matchers definitely show off what you can do with the RSpec DSL but at the cost of being ambiguous. You always want to be explicit when writing specs. The more clear you are, the easier your specs are to read and maintain. Example:

# No - Hard to read because the implementation and message being tested are obscured.
expect(article).not_to be_published

# No - We now know the message sent to article but false could be false, nil, or anything that isn't true. That's a wide berth and a spec should be specific.
expect(article.published?).not_to be_falsey

# No - Getting warmer, except `eq` compares via `==` and is still not strict enough.
expect(article.published?).to eq(true)

# Yes - Explicit and strict because `be` checks by object identity instead of equality.
expect(article.published?).to be(true)

As you can see in the above, the spec went from being obscure and not very specific to being easier to read and strictly specific. To recap:

  • The original spec obscured sending the #published? message to article. That’s an important detail to know. Seeing the explicit message being sent instead is clearer.

  • Use of #not_to was awkward and the inverse of checking to see if the value was true. Be direct and straightforward.

  • Use of #be_falsey and #eq allowed wiggle room for the test to produce a false positive due to ambiguity in the equality check. Using #be(true) made this explicit and clear.

Custom

When adding custom matchers to your test suite, you want to focus on keeping them simple to use and easy to find. Structurally, they should go in your spec/support/matchers folder. Then you can require all of these matchers via your spec helper.

using Refinements::Pathname

Pathname.require_tree __dir__, "support/matchers/**/*.rb"

💡 The Pathname refinement, used above, is made possible via the Refinements gem.

Shared Contexts

Shared contexts are a great way to reduce duplication when needing the same setup/environment for a group of related specs. Structurally, you want to keep these organized within your support folder so using spec/support/shared_contexts is a good location for these. They can then be required via your spec helper:

using Refinements::Pathname

Pathname.require_tree __dir__, "support/shared_contexts/**/*.rb"

When using shared contexts, refrain from using metadata to include them because if you need to load multiple contexts, this can get out of hand quickly. Example:

# No
RSpec.shared_context "with API", :api do
  # Implementation details
end

RSpec.describe Pinger, :api do
end

# Yes
RSpec.shared_context "with API" do
  # Implementation details
end

RSpec.describe Pinger do
  include_context "with API"
end

It’s much easier to expand, vertically, by adding a new line for a shared context rather than expand, horizontally, by adding more symbols. The horizontal wrapping can get ugly quickly.

Shared Examples

Shared examples, much like matchers and shared contexts, should be part of the same folder structure so they are easy to find and include. Example:

using Refinements::Pathname

Pathname.require_tree __dir__, "support/shared_examples/**/*.rb"

As with shared contexts, you want to mimic a similar pattern when defining and using shared examples. Example:

RSpec.shared_examples "failure requests" do
  # Implementation details.
end

include_examples "failure requests"

Loops

Use of loops are tempting when DRY’ing up your specs. Example:

RSpec.describe Generator do
  subject(:generator) { described_class.new }

  %w[one two three four five].each do |file_name|
    it "checks if #{file_name}.txt was generated" do
      generator.call
      expect(SPEC_ROOT.join("#{file_name}.txt").exist?).to be(true)
      end
    end
  end
end

With the above, we are testing if the generator built the files properly by generating a separate spec based on an array of file names. The problem with this approach is that we’ve made debugging and maintaining these specs much hard especially if the above gets more sophisticated. What you want to do is figure out how to think about testing the implementation via a single spec or improving the implementation itself so it’s easier to test. In this case, the solution would be to let the generator build the files and then change the spec to only check that the files exist:

expect(SPEC_ROOT.glob("*.txt"))).to contain_exactly(
  "one.txt",
  "two.txt",
  "three.txt",
  "four.txt",
  "five.txt"
)

This above is much easier to reason about and what you want to strive for instead of introduction loops or — worse — nested loops.

Dotfile

RSpec can be configured via the .rspec file which you put in the root of your project and fill with the following information (as an example):

--require spec_helper
--color
--format documentation

Don’t do this! There are several reasons to avoid using this file:

  • Adds unnecessary file clutter to the root of your project.

  • Doesn’t adhere to the XDG Base Directory Specification.

  • Bifurcates your RSpec configuration between what is defined in this file and what’s in your spec_helper.rb file which complicates configuration management.

Stick with using spec_helper.rb since you can keep all of your configuration information defined in a single location, use the Ruby language, and more easily swap different helpers in your individual specs if desired.

Shoulda Matchers

The Shoulda Matchers gem is sometimes used for testing purposes when working in the Rails framework. Unfortunately, this gem tests Rails when you should rely on Rails' own test suite for this kind of coverage. There is no need to retest Rails by avoiding this gem dependency altogether. Instead, test coverage falls out naturally through writing specs for your implementation which will end up indirectly testing Rails behavior via associations, scopes, validations, etc.

Resources

If you’d like to step up your game, when it comes to testing, I’d recommend checking out the following:

  • Effective Testing with RSpec - This book is must have for leveling up and learning how to use RSpec effectively.

  • Caliber - Wraps the core and necessary RuboCop tooling within a single gem for convenience so you don’t have to maintain each RuboCop gem individually. This gem also provides a more robust default configuration as well.

  • RuboCop RSpec - If Caliber is not your cup of tea — at a minimum — use this gem to ensure your specs remain consistent.

Conclusion

A lot of ground was covered in this article so thanks taking everything into consideration. Hopefully, this helps increase your awareness and strengthen your diligence in writing well maintained specs so your codebase is a joy to work with.