Testing isn’t Hard. Testing is easy in the presence of good design.
If your code isn’t testable, then that isn’t a good design.
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
letwhen needing the same object for each spec within a context. -
Avoid
letorlet!as yoursubject, as mentioned above. -
Avoid
let!instead oflet. -
Avoid
letfor performing operations. Usebefore,around, orafterinstead.
For example, consider the following use of let!:
let!(:io) { StringIO.new }
Unfortunately, the above leads to these issues:
-
Immediately creates the
ioobject whether you need it or not. This, in turn, infects all of your examples. -
Assumes
iois 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 thelet!or forgot thelet!was there to begin with. -
Obfuscates the order of execution because you might need the
ioobject to be lazily loaded after yoursubjectis created or when you only need theioobject 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
itrequires less typing thanspecifyorexample. -
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 toarticle. That’s an important detail to know. Seeing the explicit message being sent instead is clearer. -
Use of
#not_towas awkward and the inverse of checking to see if the value wastrue. Be direct and straightforward. -
Use of
#be_falseyand#eqallowed 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.rbfile 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.