DEV Community

Isa Levine
Isa Levine

Posted on • Edited on

Intro to RSpec in Rails (part 2): Improving Tests with `let` and `context`

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, use let. Using let 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 what let 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:

Got any tips, tricks, or instances for testing with RSpec? Please feel free to comment and share below! <3

Top comments (1)

Collapse
 
cabloo profile image
Cabloo

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-...