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.
-
beforeis a hook that will run before each test (by default), and thus may be run multiple times when we don't need it to. -
letis 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
beforeblock to create an instance variable, uselet. Usingletthe 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 whatletdoes 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-...