Software: Writing Readable RSpec Tests
Prerequisite
This article assumes you know the basics of the Ruby language and are familiar with RSpec. I will be covering my “framework” for writing readable tests that are easy to extend and re-use. All code examples use Ruby 3.2 syntax.
Tests as Documentation
Using the --format documentation
and --color
option, we can see our test cases as full sentences. Adding the --color
option (or --colour
if that’s your thing) makes the output really pop!
bundle exec rspec --format documentation --color
Or better yet, create a .rspec
file in your repo that makes these options the default.
# .rspec
--format documentation
--color
Now, when you run bundle exec rspec
, you’ll get the documenation format and color automatically!
Hey, That’s Out of Context!
Everyone loves a good soundbite. They can be funny, informative, or rage-inducing. But you know what I hate about soundbites? They are taken out of context! When we hear a 10-15 second audio clip or video, we have no idea why the person in the clip is saying what they’re saying. The same goes for our tests.
We can’t just test what our code does, we need to know when it does it. Said another way, we need to know in what circumstance does this code behave this way.
You may have seen tests like this:
Figure A
describe Sun do
describe "#visible?" do
it "returns true" do
# test code
end
it "returns false" do
# test code
end
end
end
What?! The sun is both visible and not visible? This makes no sense! Let’s take a closer look:
Figure B
describe Sun do
describe "#visible?" do
it "returns true" do
sun = Sun.new(time_of_day: "12pm")
expect(sun.visible?).to be(true)
end
it "returns false" do
sun = Sun.new(time_of_day: "12am")
expect(sun.visible?).to be(false)
end
end
end
Ok, now it’s clear that the key difference here is when the sun is visible. We didn’t know that until we looked at the test code. We don’t want to have to look through the inner workings of our tests to know what we’re testing.
Enter Context
This helpful RSpec method allows us to give the reader of the code a detailed explanation of the context surrounding the test.
Let’s write the same tests using context
to define the circumstances of the test:
Figure C
describe Sun do
describe "#visible?" do
context "when the time of day is 12pm" do
it "returns true" do
# test code
end
end
context "when the time of day is 12am" do
it "returns false" do
# test code
end
end
end
end
That’s better, but we can still make some improvements. Just looking at the names of the contexts, we don’t know how much is different between the two tests. What makes the time 12pm? Is that the only thing that’s different?
Take this quote from a Sciencing.com article titled Why Should You Only Test for One Variable at a Time in an Experiment?:
Testing only one variable at a time lets you analyze the results of your experiment to see how much a single change affected the result. If you’re testing two variables at a time, you won’t be able to tell which variable was responsible for the result.
Enter Let
We can define any change-able variables we need using let
. If a nested context redefines a let
, then RSpec is smart enough to use the nested let
. In this way, we can redifine our variables, so we know exactly what is changing between contexts:
Figure D
describe Sun do
let(:sun) { Sun.new(time_of_day:) }
describe "#visible?" do
context "when the time of day is 12pm" do
let(:time_of_day) { "12pm" }
it "returns true" do
expect(sun.visible?).to be(true)
end
end
context "when the time of day is 12am" do
let(:time_of_day) { "12am" }
it "returns false" do
expect(sun.visible?).to be(false)
end
end
end
end
Notice anything about the it
blocks? We are calling the same method on the same object. The only difference is what we’re expecting. And we have very clearly defined the circumstance in which the code should behave this way.
As a bonus, when we run the test using rspec --format documentation
, we get a clean output of how (and when!) our code should behave.
Sun
#visible?
when the time of day is 12pm
returns true
when the time of day is 12am
returns false
Add the --color
option for more pizazz.
Semantic RSpec
Although the tests are now clear, readable, and serve as documentation, we still have a few minor improvements to make.
RSpec gives us the methods subject
and described_class
that we can use to make our intentions clearer. I believe they also help us stick to testing best practices by reminding us the role that each variable is playing.
Use subject
define the object under test. In our example, this is the sun
variable. We can update the let(:sun)
line like this:
subject(:sun) { Sun.new(time_of_day:) }
Similarly, described_class
is automatically assumed to be the class given in the top-most #describe
block. It ensures we’re always referencing the class under test. We can refine our new subject
line further like this:
subject(:sun) { described_class.new(time_of_day:) }
The full test file:
Figure E
describe Sun do
subject(:sun) { described_class.new(time_of_day:) }
describe "#visible?" do
context "when the time of day is 12pm" do
let(:time_of_day) { "12pm" }
it "returns true" do
expect(sun.visible?).to be(true)
end
end
context "when the time of day is 12am" do
let(:time_of_day) { "12am" }
it "returns false" do
expect(sun.visible?).to be(false)
end
end
end
end
Taking It Further
One Caveat
It’s important to note that this Sun
class is only concerned with the visbility of the sun to a viewer on Earth. If we need to know the visibility of the sun on Mars, we may want to make separate EarthSun
and MarsSun
classes to encapsulate the logic for each planet.
A New Requirement
But even our Earth-centric logic might expand. What if we need to distinguish between different parts of Earth? In Alaska, depending on the season, the sun might be visible all day or not visible at all during the day.
We can add new contexts for Alaska in summer and winter. This greatly increases the number of tests in our test suite, but notice how each context only changes one variable, and the individual tests all look the same. The only thing that’s changing are the inputs.
Figure F
describe Sun do
subject(:sun) { described_class.new(time_of_day:, position:, season:) }
describe "#visible?" do
context "when in Kansas" do
let(:position) { Position.new(lat: 39.626945, long: -97.644008) }
context "when the season is summer" do
let(:season) { :summer }
# PREVIOUS TEST CODE
end
context "when the season is winter" do
let(:season) { :winter }
# NOTE: the tests for winter can be the same as the tests for
# summer since the logic for seasons is the same in the mainland
end
end
context "when in Alaska" do
let(:position) { Position.new(lat: 66.160507, long: -153.369141) }
context "when the season is summer" do
let(:season) { :summer }
context "when the time of day is 12pm" do
let(:time_of_day) { "12pm" }
it "returns true" do
expect(sun.visible?).to be(true)
end
end
context "when the time of day is 12am" do
let(:time_of_day) { "12am" }
it "returns true" do
expect(sun.visible?).to be(true)
end
end
end
context "when the season is winter" do
let(:season) { :winter }
context "when the time of day is 12pm" do
let(:time_of_day) { "12pm" }
it "returns false" do
expect(sun.visible?).to be(false)
end
end
context "when the time of day is 12am" do
let(:time_of_day) { "12am" }
it "returns false" do
expect(sun.visible?).to be(false)
end
end
end
end
end
end
Practice on Your Own
Want to practice writing semantic, readable, self-documenting tests? Try wrting a test suite for the Gilded Rose Kata. This classic kata is a great place to practice writing tests because there’s no way you’ll be able to understand the code at first glance. Remember to use context
and let
to isolate and define exactly when the code should behave the way it behaves. Don’t peek at the source code. Write tests based soley on the requirements.