The letter A styled as Alchemists logo. lchemists Syndication Icon

Putin's War on Ukraine - Watch President Zelenskyy's speech and help Ukraine fight against the senseless cruelty of a dictator!

Published June 1, 2022 Updated June 1, 2022
Cover
RSpec Antipatterns

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 complicated too. In this article, I’ll help identify what those antipatterns are, how to avoid them, and how best to correct them.

💡 By the way, this is a companion to an earlier article on Ruby Antipatterns which might be of aid/interest as well.

Configuration

In order to talk about the various things you should not do with RSpec, we need to start by discussing how to configure RSpec which will provide a foundation for the rest of this article. Here’s a recommended configuration — as generated by Rubysmith — when working in new or existing projects:

RSpec.configure do |config|
  config.color = true
  config.disable_monkey_patching!
  config.example_status_persistence_file_path = "./tmp/rspec-examples.txt"
  config.filter_run_when_matching :focus
  config.formatter = ENV.fetch("CI", false) == "true" ? :progress : :documentation
  config.order = :random
  config.shared_context_metadata_behavior = :apply_to_host_groups
  config.warnings = true

  config.expect_with :rspec do |expectations|
    expectations.syntax = :expect
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
  end

  config.mock_with :rspec do |mocks|
    mocks.verify_doubled_constant_names = true
    mocks.verify_partial_doubles = true
  end
end

The above is all you need to properly configure RSpec. Some of the configuration might not make sense or be initially intuitive without studying the docs so here’s a line-by-line break down:

config.color = true

Ensures output is colorized for improved readability.

config.disable_monkey_patching!

By default, RSpec will monkey patch your environment which leads to confusion and lack of debugability so keep this disabled so you can use RSpec.describe instead of describe when defining your specs.

config.example_status_persistence_file_path = "./tmp/rspec-examples.txt"

Ensures spec status is written to file so you can quickly fix or refactor broken code. This is a major boon to productivity since having this file allows you to run commands like rspec --only-failures or rspec --next-failure.

config.filter_run_when_matching :focus

Ensures you can add focus: true or :focus (shorthand), or fdescribe/fit (even shorter shorthand) when running only focused tests.

config.formatter = ENV.fetch("CI", false) == "true" ? :progress : :documentation

Ensures RSpec formats output in compressed/dotted progress when running on CI but with full documentation when running locally.

config.order = :random

Ensures all specs are run in random order to ensure no spec depends upon another.

config.shared_context_metadata_behavior = :apply_to_host_groups

Ensures the host group and examples inherit metadata from the shared context. This will be the default in RSpec 4.0.0.

config.warnings = true

Ensures all Ruby warnings are displayed. This can be quite verbose for some folks but is incredible powerful in finding and detecting issues early and often. You’ll also find that some upstream gem dependencies, sadly, do not adhere to this level of rigor. If that’s the case, I’d recommend installing the Warning gem to filter out and silence bad actors or remove those dependencies entirely.

config.expect_with :rspec do |expectations|
  expectations.syntax = :expect
  expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end

The first line ensures you always use expect instead of the should syntax for consistent expectations. The second line ensures custom matcher descriptions — and failure messages — include clauses from defined methods which use chain. Use of chain is not recommended but if it is used, at least you’ll get more descriptive diagnostic information.

config.mock_with :rspec do |mocks|
  mocks.verify_doubled_constant_names = true
  mocks.verify_partial_doubles = true
end

Ensures that doubles — both constants and objects — are properly verified, exist, and haven’t changed which would cause faulty specs, if otherwise. You definitely want both of these enabled so RSpec has your back when using test doubles.

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 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 I want to draw attention to it since I’ve seen many a test suite 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 it’s too generic and difficult to know what you are referencing in the spec. Giving your subject a proper name along with providing any additional initialization support provides a much more maintainable spec.

Misused Lets

While using both subject and let can be used for the same purpose — to properly memoize and clean up an object between each spec — you should not use a 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 generally 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.

Describes

Use of describe blocks — at the top level of your spec — help describe behavior for both 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 makes it easier to use a split view within your editor so your implementation is loaded on the left and corresponding spec is loaded on the right so you can scroll up and down at, roughly, the same line level.

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 and is also a guideline recommended by Better Specs.

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

By describing each method of your object, you provide documentation on all possible behavior of your object — in addition to having good test coverage — which is important to communicate to your team. 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.

Contexts

If you can avoid using contexts, do so. That said, they can be useful when calling out alternate behavior.

Deep Nests

Avoid using nested contexts. If you have 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 examples in RSpec: it, example, and specify. The most common approach is the it block and you should stick with that. 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

Both example and specify have the same functionality as it but when you mix and match, you end up with an inconsistent test suite with little benefit.

Expectations

There are several situations in which you need to use block syntax in your expectations. This leads to very 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 much easier to read and maintain instead of having to sift through the nested brackets whether they be on one line or spread across multiple lines.

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 extra methods end up being a glue layer between your implementation and specs.

A simple solution to this antipattern is to use a callback. However, use of a before block — for example — can be too simple of a solution because before blocks don’t accept arguments and you might need to pass something in. In that case, you might want to reach for any of the following:

If the above doesn’t solve your problem, then take a hard look at your implementation and fix it instead because your specs are waiving a warning flag. You just 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 indefinitely ignore the spec. Example:

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

# Yes
it "answers success status" do
  pending "Need to upgrade to the latest HTTP gem version before this will work again."
  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 this topic before so you might want to read this earlier article if you haven’t already. I will add that if you need a good fake for dealing with HTTP requests, consider adding the HTTP Fake gem to your test suite.

Custom Matchers

Custom matchers are a great way to reduce duplicated effort within your test suite while enhancing the readability of your specs at the same time. 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::Pathnames

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::Pathnames

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::Pathnames

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"

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 - A great book to have — if not already — for leveling up and learning how to use RSpec effectively.

  • Caliber - Wraps a lot of the 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 configuration as well.

  • RuboCop RSpec - If Caliber is not your cup of tea — at a minimum — try using 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.