In our last post, we started to write some RSpec tests for our character-generator API, and its RandomCharacterGenerator
service class.
Dev.to community member Andrew Brown pointed out that several aspects of our code were not written optimally, so let's take a look at a few of his recommendations!
Use let
to wrap your testing variables
In our original test code, we simply created our testing variables inside our describe
block. This is not correct--instead, we are expected to wrap this code in either a before
or let
block. Both of these will help RSpec understand when to create the variables, and help our tests run correctly.
Here's the original test code we wrote (with a few omissions for readability):
# /spec/services/random_character_generator_spec.rb
require 'rails_helper'
RSpec.describe RandomCharacterGenerator do
describe "#new_character" do
# NOTE: Do NOT create your test variables this way!!
rcg = RandomCharacterGenerator.new
player = Player.create(user_name: "Ronald McDonald", display_name: "Mac")
character = rcg.new_character("Ronnie the Rat", player)
it "creates a new Character instance" do
expect(character).to be_an_instance_of Character
end
# For now, we'll ignore the other tests we wrote...
end
end
So, we need to do something about where we create those starting_database_count
, rcg
, player
, and character
variables!
before
or let
We have two options for wrapping our variable-creation: before
or let
blocks.
-
before
is a hook that will run before each test (by default), and thus may be run multiple times when we don't need it to. -
let
is only called when a test needs the variable it creates.
So, we could rewrite our code two different ways:
# /spec/services/random_character_generator_spec.rb
describe "#new_character" do
# OPTION 1 (run before each test):
before do
rcg = RandomCharacterGenerator.new
player = Player.create(user_name: "Ronald McDonald", display_name: "Mac")
character = rcg.new_character("Ronnie the Rat", player)
end
# OPTION 2 (run only when variables are called in a test):
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) {
rcg = RandomCharacterGenerator.new
rcg.new_character("Ronnie the Rat", player)
}
end
One resource Andrew pointed me to is BetterSpecs, an excellent list of guidelines for writing more standardized/readable/maintainable/side-effect-less RSpec tests. Here's what BetterSpecs says about the let
block:
When you have to assign a variable instead of using a
before
block to create an instance variable, uselet
. Usinglet
the variable lazy loads only when it is used the first time in the test and get cached until that specific test is finished. A really good and deep description of whatlet
does can be found in this stackoverflow answer.
So, let
is the preferred alternative to a before
block. Both of these are methods we can wrap around creating our testing variables. But let
is preferred because it runs more efficiently (and, as Andrew pointed out, has fewer side effects).
Sidenote from BetterSpecs -- this is how they describe let
working under-the-hood:
# this:
let(:foo) { Foo.new }
# is very nearly equivalent to this:
def foo
@foo ||= Foo.new
end
So, let
prevents us from re-instantiating classes over and over! Definitely more efficient.
Let's go ahead and update our code with let
, and run the test:
# /spec/services/random_character_generator_spec.rb
require 'rails_helper'
RSpec.describe RandomCharacterGenerator do
describe "#new_character" do
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) {
rcg = RandomCharacterGenerator.new
rcg.new_character("Ronnie the Rat", player)
}
it "creates a new Character instance" do
expect(character).to be_an_instance_of Character
end
end
end
And run bundle exec rspec
:
$ bundle exec rspec
.
Finished in 0.04259 seconds (files took 0.78975 seconds to load)
1 example, 0 failures
Awesome! Our test is still working (and passing) as expected.
Using context
for different cases
Both Andrew and BetterSpecs recommend using context
to organize tests.
context
is an alias for describe
, so there is no under-the-hood different. context
exists solely to make tests more understandable to developers.
One common way to implement context
is to use them for different cases, such as "success" and "failure".
Use case: context "success"
and context "failure"
Let's add some contexts to our tests to reflect when the new_character
method succeeds or fails, based on the uniqueness of our character's name (a validation which is NOT implemented on the Character model yet):
# /spec/services/random_character_generator_spec.rb
describe "#new_character" do
let(:rcg) { RandomCharacterGenerator.new }
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) { rcg.new_character("Ronnie the Rat", player) }
context "success" do
it "creates a new Character instance" do
expect(character).to be_an_instance_of Character
end
end
context "failure (non-unique name)" do
# test code here
end
end
Now, let's make a duplicate
variable by trying to create a new Character with the same name as our first character
. In our test, we'll also expect that duplicate
is equal to an error message string:
# /spec/services/random_character_generator_spec.rb
describe "#new_character" do
let(:rcg) { RandomCharacterGenerator.new }
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) { rcg.new_character("Ronnie the Rat", player) }
let(:duplicate) { rcg.new_character("Ronnie the Rat", player) }
context "success" do
it "creates a new Character instance" do
expect(character).to be_an_instance_of Character
end
end
context "failure (non-unique name)" do
it "returns a message that Character is not created" do
expect(character).to be_an_instance_of Character
expect(duplicate).to eq "Character not created -- name already exists!"
end
end
end
Now, our context "failure (non-unique name)"
has an it
block that instantiates character
again (remember, the let
variables don't exist until we call them in a test!), and tries to create duplicate
with the same name. Instead of being a Character, duplicate
should equal a string containing our error message.
Let's run our tests to make sure they work, and are not passing:
$ bundle exec rspec
.F
Failures:
1) RandomCharacterGenerator#new_character failure does not create a new Character instance
Failure/Error: expect(duplicate).to eq "Character not created -- name already exists!"
expected: "Character not created -- name already exists!"
got: #<Character id: 2, name: "Ronnie the Rat", strength: 2, dexterity: 3, intelligence: 3, charisma: 1, created_at: "2019-12-08 19:05:55", updated_at: "2019-12-08 19:05:55", player_id: 1>
(compared using ==)
Diff:
@@ -1,2 +1,11 @@
-"Character not created -- name already exists!"
+#<Character:0x00007f9e56d37880
+ id: 2,
+ name: "Ronnie the Rat",
+ strength: 2,
+ dexterity: 3,
+ intelligence: 3,
+ charisma: 1,
+ created_at: Sun, 08 Dec 2019 19:05:55 UTC +00:00,
+ updated_at: Sun, 08 Dec 2019 19:05:55 UTC +00:00,
+ player_id: 1>
# ./spec/services/random_character_generator_spec.rb:63:in `block (4 levels) in <top (required)>'
Finished in 0.1283 seconds (files took 1.76 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./spec/services/random_character_generator_spec.rb:61 # RandomCharacterGenerator#new_character failure does not create a new Character instance
Perfect--our test works, and is failing because duplicate
is being created as a Character instance, instead of the error-message string we were expecting.
Let's add a uniqueness validation for :name
to our Character model:
# /app/models/character.rb
class Character < ApplicationRecord
belongs_to :player
validates :name, uniqueness: true
end
And in RandomCharacterGenerator.new_character()
, let's add a rescue
that returns our error-message string:
# /app/services/random_character_generator.rb
class RandomCharacterGenerator
def new_character(name, player)
Character.new(name: name, player: player).tap do |character|
roll_stats(character, @stats_array, @points_pool, @max_roll)
character.save!
end
rescue ActiveRecord::RecordInvalid
return "Character not created -- name already exists!"
end
end
Running our tests now:
$ bundle exec rspec
..
Finished in 0.07993 seconds (files took 1.86 seconds to load)
2 examples, 0 failures
Awesome, now our new_character
method has some better error-handling built in, and it's backed up by test coverage!
Using it { expect }
syntax with context
One final suggestion from both Andrew and the BetterSpecs section on keeping descrptions short is to simplify the it
block's syntax with curly brackets {}
around the expect
statement.
When used inside a context
block, it can make the test much more expressive and readable:
# /spec/services/random_character_generator_spec.rb
describe "#new_character" do
let(:rcg) { RandomCharacterGenerator.new }
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) { rcg.new_character("Ronnie the Rat", player) }
let(:duplicate) { rcg.new_character("Ronnie the Rat", player) }
# BEFORE:
context "success" do
it "creates a new Character instance" do
expect(character).to be_an_instance_of Character
end
end
# AFTER:
context "success" do
it { expect(character).to be_an_instance_of Character }
end
end
The second version reduces our it
block from three lines to one, and completely eliminates the descriptive (and possibly redundant) text "creates a new Character instance"
. Instead, we can read the test as "the 'success' context expects variable character
to be an instance of Character". Pretty self-descriptive code!
However, this strategy is best used for one-expectation tests. Our "failure (non-unique name)" test currently relies on two expect
lines inside the same it
block, so this would NOT work:
# /spec/services/random_character_generator_spec.rb
describe "#new_character" do
let(:rcg) { RandomCharacterGenerator.new }
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) { rcg.new_character("Ronnie the Rat", player) }
let(:duplicate) { rcg.new_character("Ronnie the Rat", player) }
# this works:
context "failure (non-unique name)" do
it "returns a message that Character is not created" do
expect(character).to be_an_instance_of Character
expect(duplicate).to eq "Character not created -- name already exists!"
end
end
# this does NOT work:
context "failure (non-unique name)" do
it { expect(character).to be_an_instance_of Character }
it { expect(duplicate).to eq "Character not created -- name already exists!"}
end
end
The second option does not work because each it
line creates its own scope, so duplicate
is created as a Character successfully because the previous character
is not instantiated in duplicate's
scope.
So, our final successful test code looks like this:
# /spec/services/random_character_generator_spec.rb
require 'rails_helper'
RSpec.describe RandomCharacterGenerator do
describe "#new_character" do
let(:rcg) { RandomCharacterGenerator.new }
let(:player) { Player.create(user_name: "Ronald McDonald", display_name: "Mac") }
let(:character) { rcg.new_character("Ronnie the Rat", player) }
let(:duplicate) { rcg.new_character("Ronnie the Rat", player) }
context "success" do
it { expect(character).to be_an_instance_of Character}
end
context "failure (non-unique name)" do
it "returns a message that Character is not created" do
expect(character).to be_an_instance_of Character
expect(duplicate).to eq "Character not created -- name already exists!"
end
end
end
end
Conclusion
As we've covered, using let
to create your testing variables and context
to wrap your test cases can improve both the readability and the efficiency of your RSpec tests. We've also seen one way to simplify it
blocks down to a single line if there's a single expectation!
Thanks to Andrew Brown for his incredibly helpful comment on my previous post!
Additional thanks to Masaki Matsuo and Amy Pivo for helping me practice writing better RSpec tests this week!
Further reading/links/resources about RSpec testing:
- betterspecs.org
- https://stackoverflow.com/a/5359979
- https://www.ombulabs.com/blog/rails/rspec/ruby/let-vs-instance.html
- https://lmws.net/describe-vs-context-in-rspec
- https://thoughtbot.com/blog/my-issues-with-let
Top comments (1)
I take this syntax one step further and use the let(:...) options to override values in each context, building up state in sub contexts: techlead.tips/2021/11/building-up-...