
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 complicated too. 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.
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 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
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 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 thanspecify
orexample
. -
Consistency prevents the reader from having to constantly distinquish between the significance of all three syntaxes being used.
Expectations
There are several situations in which you need to use block syntax in your expectations. 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 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 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 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.
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_to
was awkward and the inverse of checking to see if the value wastrue
. 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::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 — 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.