Reducing Code Duplication in RSpec
Using the let
Keyword in Example Groups
RSpec is a popular testing library that has the distinction of being the most popular Ruby gem with over 200 million downloads. It is fantastic for behavior-driven development (BDD) and allows us to improve the quality of our code while acting as living documentation for the codebase. When used correctly, developers who are new to a codebase should be able to learn everything they need to know about the functionality of the program by reading the files inside thespec
folder.
I love RSpec for its beautiful english-like syntax and how it’s designed to test classes and methods in a specific domain. In this blog, we will look at some different ways to define instances of a class within a test example group, and how (spoilers) using the let
keyword is best for most testing purposes.
Creating Instances in Examples
Let’s say we have a simple class in our Ruby project called Player
. Player will eventually have two values: a String representing the player’s name
, and an integer representing the player’s age
.
Now before we go any further with implementing our class with attributes and methods, let’s write some RSpec for the Player
class. We first define an example group using the .describe
method and pass in a reference to the name of the class we are testing. Next, we will create our first example within an it
block and declare what behaviors we expect an instance of our class to have.
Obviously, this test will fail because while we have a Player class, it has no name
property than can be read using a getter, and we can’t initialize Player to have a name property of “Axel”. Let’s add a getter and constructor method to our Player
class so that these tests can pass.
Great! Now our first example in RSpec:it ‘has a name’
, should be passing. To recap, first we wrote a test example inside our player_spec.rb
file where all the tests for Player
will be. Next, we added some code to player.rb
which contains the Player class in order to allow these tests to pass. This process of writing failing tests, adding code to let them pass, then streamlining the code we wrote is known as Red, Green, Refactor. Mastering this technique is the key to becoming a better developer through RSpec testing.
Now, let’s add one more example inside our spec file to test for an age
attribute.
Notice how we now pass in a second argument of type Int for the Player’s age in both examples. Our Player
class has no age
attribute nor initializes it inside the constructor, so we will go back and add more code to player.rb
.
Once again, our tests are passing, but there is something wrong with our test code insideplayer_spec.rb
. Notice how in both it
blocks, we declare the same new instance of Player
(assigning ‘Axel’ as name and 20 as age). While this doesn’t break our tests, it is clear that we are repeating ourselves in multiple blocks. If we had 50 examples for Player
inside this example group, that would be a lot of repeated code!
Instead of explicitly writing code to create an object inside each example, we have some other options in RSpec.
Using Instance Variables
One way to avoid code duplication like this is employing RSpec’s before
hooks. A before
block, by default, performs whatever action inside of it before every single example in its example group aka the.describe
block. That means that by using a Ruby instance variable, we can declare an instantiation of Player
before each example runs.
Instance variables are necessary for before
hooks because they can be passed into other it
blocks unlike a local-scoped variable. Now, by calling @player,
we can reference the name
and age
property like before without having to write another Player.new
inside each example.
This is definitely an improvement upon our code from before, but since instance variables can be easily mutated, it is still not optimal for RSpec testing.
Using Helper Methods
Another way to instantiate a class while keeping code duplication to a minimum is through the use of helper methods. Helpers are just like any other regular method in Ruby, but we define them using the same name as the class being tested. For example, let’s write a helper method for the Player
class.
Using a player
helper method allows our tests to pass, but just like with instance variables, it is not the best implementation. In this case, what happens if we try to change the value of our Player’s name?
We will add this logic into our first example like so:
After initializing a player with the same values from before, we use a setter to change the value of the name
property in player
to be “Choco”. Before running our updated tests, let’s go ahead and also update our Player
class with an attr_accessor
containing both getter and setter for name.
However, when we run our tests on the updated player, we see the following error message:
Failures: 1) Player has a name
Failure/Error: expect(player.name).to eq('Choco')
expected: "Choco"
got: "Axel"
(compared using ==)
# ./spec/player_spec.rb:11:in `block (2 levels) in <top (required)>'
What happened? Didn’t we change the name of our Player instance to “Choco”. Therein lies the issue with both instance variables and helper methods albeit in different ways: mutation.
In this case, when we try to mutate the value of name
into a different value, it isn’t passed to the instance of Player
inside the player method. Instead, it is trying to call a setter method on the player
method itself. Since the method of player
is simply contained within the example group, it does not have an attribute for name
at all.
This means that any instances created within a helper method cannot be mutated for the sake of testing. So, what is the best solution for providing an instance of Player
to each example block while keeping our code DRY?
Using The let Method
Enter the let
keyword: a special helper method within RSpec that allows us to save and use instances of a class through memoization. To put it simply, memoization is a term in Computer Science for catching the value of an operation (usually one that is computationally expensive) and saving it for future use.
When we call let
at the beginning of an example group context and pass in a symbol name and block, we can save values of an instance, string, or really any value within an RSpec test and reference it whenever we need in examples.
Here is how a let
is declared:
let(:variable) { 'Hello World' }
We add let
first and pass it a symbol representing the name of our variable, followed by a block containing the specific value we want let
to hold (in this case, the string ‘Hello World’).
let
is lazy-loaded, which means Ruby does not load the method into memory unless it is specifically called on. Let’s use let
to pass an instance of Player
into our example group.
Now if we run our tests, everything passes like clockwork! Note that while name
was changed in the first example block, let
is extremely useful because the original value of “Axel” is still contained within other example blocks outside of its scope (ie. it ‘has an age’). In other words, this method prevents against mutation between different examples in the same example group!
Conclusion
While let
is generally interchangeable with subject
, another component of the RSpec library, it is a method that should be a main building block of unit testing done in Ruby. I hope you use it to maximum effect in tandem with your next Sinatra, Rails, or vanilla Ruby project!