DEV Community

Isa Levine
Isa Levine

Posted on • Updated on

Intro to RSpec in Rails (part 1): Basic Syntax and Strategy for Testing

While working on the character-generator API from my last post, I found myself writing most of my code in the RandomCharacterGenerator service object. As the class got more complicated, I realized that I had a very clear idea of the expected behavior--so this was a perfect opportunity to practice writing RSpec tests for those behaviors!

RSpec is a behavior-driven development (BDD) testing tool for Ruby, and is widely used for testing both plain ol' Ruby and full-on Rails applications. I'm writing this as an intro to some basic patterns and syntax used in RSpec Rails testing, as well as some basic BDD strategy for breaking down code into testable units.

Behavior-Driven Development (BDD)

It's hard to understand RSpec without having some idea about behavior-driven development (BDD), so let's start there!

BDD is a testing philosophy that grew out of test-driven development (TDD). Essentially, the goal of both is: write tests first, then write code to make the tests pass!

The idea behind this is that being able to identify and articulate the behaviors you want to test should help guide you when writing the code itself:

  • If you know what behavior you need to see, you can write tests for it
  • If you write tests for it, you will know when your code is doing that behavior correctly
  • If you know when your code is doing that behavior correctly, you can:
    • write as little code as possible (to get the test to pass)
    • refactor with the confidence that the tests will tell you if the behavior is not working anymore

Applying this to Rails (or Ruby more broadly, or object-oriented programming languages in general), these are the main steps we will use:
1. Decide on the class/method/behavior to test
2. Write tests for that class/method/behavior
3. Write code to make the tests pass
4. When refactoring your code, make sure the tests still pass

RSpec

Adding RSpec to a new or existing Rails app

This is an excellent StackOverflow discussion for both starting a new Rails app with RSpec, and migrating an existing Rails app to RSpec (from test-unit, the default Rails test tool). The following instructions are pulled from the thread's answers.

Starting a new Rails app with no test tool, and adding RSpec

Per user Zabba:

at command line:

rails new MYAPP -T # The -T option tells rails not to include Test::Unit
Enter fullscreen mode Exit fullscreen mode

in Gemfile:

gem 'rspec-rails'
Enter fullscreen mode Exit fullscreen mode

at command line:

bundle install
rails g rspec:install
Enter fullscreen mode Exit fullscreen mode

Migrate an existing Rails app with default test-unit to RSpec

Per user Sayuj:
NOTE: This does include deleting all existing files in test-unit's directory! Make sure to back up or migrate any existing tests before following these steps!!

Create your new rails application as:

rails new <app_name> -T
Enter fullscreen mode Exit fullscreen mode

Or remove your test directory from your existing application:

rm -rf test/
Enter fullscreen mode Exit fullscreen mode

Make an entry in your Gemfile:

gem 'rspec-rails'
Enter fullscreen mode Exit fullscreen mode

From the command line install the gem

$ bundle install
Enter fullscreen mode Exit fullscreen mode

From the command line install rspec into your application:

$ rails g rspec:install
Enter fullscreen mode Exit fullscreen mode

Now your rails application uses RSpec instead of test-unit.

Running RSpec tests

To run all RSpec tests in your Rails project, use this console command:

bundle exec rspec
Enter fullscreen mode Exit fullscreen mode

Per StackOverflow user apneadiving, you can also run individual tests by specifying the filepath and line number:

bundle exec rspec ./spec/controllers/groups_controller_spec.rb:42
Enter fullscreen mode Exit fullscreen mode

RSpec building blocks: describe and it

In RSpec, the main building blocks for test suites are describe (for grouping) and it (for tests). We write both of them as do...end blocks, where we do things like instantiate classes, set variables, and write individual tests.

Here's the first test from random_character_generator_spec.rb:

# /spec/services/random_character_generator_spec.rb

RSpec.describe RandomCharacterGenerator do
    describe "#new_character" do
        # NOTE: Do NOT create your test variables this way!! (See comments for why.) This is just an example for readability...
        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

    end
end
Enter fullscreen mode Exit fullscreen mode

Let's walk through this:

  1. At the top, we have RSpec.describe <ClassName> do. This encapsulates our tests for the RandomCharacterGenerator service object class.
  2. Underneath, we have describe "#method_name" do. This encapsulates our tests for the new_character method. It is a Rails convention to add # before this--that way, the test output will read RandomCharacterGenerator#new_character, which helps us know what we're testing.
  3. Inside that describe block for our method, we instantiate a couple objects to test, and run the new_character method so we can assign its output to the variable character. This will give us all the objects we need to test the method's behavior. (NOTE: This is NOT the right way to instantiate variables in an RSpec test! It is here only for an introductory example. See this excellent comment from Andrew Brown [and the next post in this series] to see why and learn the proper alternatives!)
  4. Finally, we have an it "description of expected behavior" do block. This is where we write the test code logic! In a very expressive style, we see that the test itself expects the variable character to be an instance of the class Character. If this line evaluates to True, our test will pass--otherwise, it will fail!

Setting clear expectations

As we saw above, RSpec provides an expressive domain-specific language (DSL) to write our tests with. The idea here is that methods can be chained together in a way that sounds very close to plain English. The code expect(character).to be_an_instance_of Character almost reads like a real sentence!

The most important syntax is expect(foo).to, because this creates the test itself! It's also important because we must know what one specific thing we're testing (foo) in order to write the test.

Here are some common syntax options, all sourced from the excellent resource RSpec Cheatsheet by @rstacruz:

Equal value (or not)

expect(target).to eq 1
expect(target).not_to eq 1
Enter fullscreen mode Exit fullscreen mode

Math comparisons

expect(5).to be < 6
expect(5).to == 5
expect(5).to equal value
expect(5).to be_between(1, 10)
expect(5).to be_within(0.05).of value
Enter fullscreen mode Exit fullscreen mode

Type-specific

expect(x).to be_zero    # FixNum#zero?
expect(x).to be_empty   # Array#empty?
expect(x).to have_key   # Hash#has_key?
Enter fullscreen mode Exit fullscreen mode

Objects

expect(obj).to be_an_instance_of MyClass
expect(obj).to be_a_kind_of MyClass
Enter fullscreen mode Exit fullscreen mode

Errors

expect { user.save! }.to raise_error
expect { user.save! }.to raise_error(ExceptionName, /msg/)
Enter fullscreen mode Exit fullscreen mode

Enumerables

expect(list).to include(<object>)

expect(list).to have(1).things
expect(list).to have_at_least(2).things
expect(list).to have_at_most(3).things
Enter fullscreen mode Exit fullscreen mode

Common RSpec strategy: (1) pick a class, (2) pick a method, (3) outline expected behaviors, (4) write one it block per behavior

In the example test above, our three layers of describe/describe/it blocks correspond to increasingly specific things we're testing:

  • At the highest level, we have the class RandomCharacterGenerator
  • Within that class, we have the method new_character
  • Within that method, we have the expected behavior "creates a new Character instance"

This maps well to the overall BDD strategy we outlined earlier. With RSpec specifically, we can translate this into four steps:
1. Pick a class to test
2. Pick a method on the class to test
3. Outline expected behaviors from that method (input/output, object/state mutations, errors, etc.)
4. Write one it block per behavior. This block can contain ONE or MANY expect(foo).to tests (to cover specific values, test/edge cases, etc.)

Rails makes picking classes straightforward--choose a model, a controller, a service object, anything defined as a class!

Ideally, you'll want to test the functionality of individual methods, as well as an overall input/output from a chain of methods. Doing both will help give you confidence that you know what each step of your code is doing (and provide a safety net for catching errors when refactoring).

In BDD, it is very important to think of expected behaviors AHEAD OF TIME! This is good practice to reinforce approaching your code with a deliberate plan. Be able to break down your methods (and overall functionality) into individual behaviors, as well as test cases (both expected and edge cases).

Use case: character-generator API

Here's a review of the character-generator API from my last post (with some recent refactors):

Our schema includes a Character model, which belongs to a Player model. Characters have a name string and four stats as integers (strength, dexterity, intelligence, charisma), and a foreign key for their Player. Players have a user name and a display name, both strings.

# /db/schema.rb

ActiveRecord::Schema.define(version: 2019_11_24_071655) do

  create_table "characters", force: :cascade do |t|
    t.string "name"
    t.integer "strength"
    t.integer "dexterity"
    t.integer "intelligence"
    t.integer "charisma"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.integer "player_id", null: false
    t.index ["player_id"], name: "index_characters_on_player_id"
  end

  create_table "players", force: :cascade do |t|
    t.string "user_name"
    t.string "display_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  add_foreign_key "characters", "players"
end
Enter fullscreen mode Exit fullscreen mode

The RandomCharacterGenerator service object is a class that handles all of the logic to generate a new Character, and assign their stats points randomly based on the available @points_pool and @max_roll attributes. There's also an attribute for @stats_array that contains strings for the four Character attributes. (Yes, I know that was lazy of me...)

The public method new_character returns a new Character instance with its stats filled in. There is also a private method, roll_stats, that handles the stat-randomizing:

# /app/services/random_character_generator.rb

class RandomCharacterGenerator
    attr_accessor :stats_array, :points_pool, :max_roll

    def initialize
        @stats_array = ["strength", "dexterity", "intelligence", "charisma"]
        @points_pool = 9
        @max_roll = 6
    end


    def new_character(name, player)
        # more code needed here to make tests pass
    end


    private

    def roll_stats(character, stats_array, points_pool, max_roll)
        # more code needed here to make tests pass
    end

end
Enter fullscreen mode Exit fullscreen mode

For this article, we'll group all our tests together by the method new_character, as we're concerned with the output Character it returns. Because this method relies on the private method roll_stats, we are indirectly testing that too. However, some of these tests could be rewritten to be directly attached to roll_stats as well.

Let's go through the RSpec strategy steps above:

1. Pick a class:

  • RandomCharacterGenerator service object

2. Pick a method on the class:

  • new_character

3. Outline expected behaviors:

  • It creates a new Character instance
  • It randomly allocates all stat points (9) between four Character stats (strength, dexterity, intelligence, charisma)
  • It allocates stat points so stat values are between 1 and max roll (6)
  • It saves the Character to the database

4. Write one it block per behavior:

# /spec/services/random_character_generator_spec.rb

RSpec.describe RandomCharacterGenerator do

    describe "#new_character" do
        # NOTE: Do NOT create your test variables this way!! (See comments for why.) This is just an example for readability...
        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(x).to
        end

        it "randomly allocates all #{rcg.points_pool} stat points between #{rcg.stats_array.to_s}" do
            # expect(x).to
        end

        it "allocates stat points so stat values are between 1 and max roll (#{rcg.max_roll})" do
            # expect(x).to
        end

        it "saves the Character to the database" do
            # expect(x).to
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

Creating our tests

Let's see our tests by running bundle exec rspec. This will execute all the tests available, and return the results to our console:

$ bundle exec rspec
....

Finished in 0.01019 seconds (files took 0.8859 seconds to load)
4 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

Our tests are technically passing, because we haven't filled them in yet! Let's build them one-by-one.

1. it "creates a new Character instance"

We can test whether a variable is an instance of a particular class with be_an_instance_of:

# /spec/services/random_character_generator_spec.rb

    it "creates a new Character instance" do
        expect(character).to be_an_instance_of Character
    end
Enter fullscreen mode Exit fullscreen mode

Run bundle exec rspec:

Failures:

  1) RandomCharacterGenerator#new_character creates a new Character instance
     Failure/Error: expect(character).to be_an_instance_of Character
       expected nil to be an instance of Character
     # ./spec/services/random_character_generator_spec.rb:70:in `block (3 levels) in <top (required)>'

Finished in 0.03433 seconds (files took 0.91909 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/services/random_character_generator_spec.rb:69 # RandomCharacterGenerator#new_character creates a new Character instance
Enter fullscreen mode Exit fullscreen mode

Great! Now we're ready to fill in the code in new_character to make this test pass. This is the heart of BDD (and TDD): write the tests first, then write the code!

# /app/services/random_character_generator.rb

    def new_character(name, player)
        Character.new(name: name, player: player).tap do |character|
            # roll_stats() will be called here
            # we will save! the character here
        end
    end
Enter fullscreen mode Exit fullscreen mode

This will make our tests pass (even though roll_stats doesn't do anything yet). 1 of 4 complete!

2. it "randomly allocates all 9 stat points between (strength, dexterity, intelligence, charisma)"

Since we have the stats we want to check stored in @stats_array, we can use reduce to iterate through it, access character[stat] to check its value, and add up the integers for each stat. We can then use that with expect(x).to eq value to check for equality:

# /spec/services/random_character_generator_spec.rb

    it "randomly allocates all #{rcg.points_pool} stat points between #{rcg.stats_array.to_s}" do
        expect(rcg.stats_array.reduce(0) { |points, stat| points += character[stat] }).to eq rcg.points_pool 
    end
Enter fullscreen mode Exit fullscreen mode

Let's run bundle exec rspec to see what the failing test outputs:

Failures:

  1) RandomCharacterGenerator#new_character randomly allocates all 9 stat points between ["strength", "dexterity", "intelligence", "charisma"]
     Failure/Error: expect(rcg.stats_array.reduce(0) { |points, stat| points += character[stat] }).to eq rcg.points_pool

     TypeError:
       nil can't be coerced into Integer
     # ./spec/services/random_character_generator_spec.rb:74:in `+'
     # ./spec/services/random_character_generator_spec.rb:74:in `block (4 levels) in <top (required)>'
     # ./spec/services/random_character_generator_spec.rb:74:in `each'
     # ./spec/services/random_character_generator_spec.rb:74:in `reduce'
     # ./spec/services/random_character_generator_spec.rb:74:in `block (3 levels) in <top (required)>'

Finished in 0.01359 seconds (files took 0.86408 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/services/random_character_generator_spec.rb:73 # RandomCharacterGenerator#new_character randomly allocates all 9 stat points between ["strength", "dexterity", "intelligence", "charisma"]
Enter fullscreen mode Exit fullscreen mode

Since nothing is assigned to each character[stat] yet, we're getting an error saying nil cannot be coerced into an integer for addition. Let's add some code to the roll_stats method, and call it inside new_character:

# /app/services/random_character_generator.rb

    def new_character(name, player)
        Character.new(name: name, player: player).tap do |character|
            roll_stats(character, @stats_array, @points_pool, @max_roll)
            # we will save! the character here
        end
    end


    private

    def roll_stats(character, stats_array, points_pool, max_roll)
        stats_array.each do |stat|
            roll = rand(1..10)
            character[stat] = roll
            points_pool -= roll
        end
    end
Enter fullscreen mode Exit fullscreen mode

Run bundle exec rspec:

Failures:

  1) RandomCharacterGenerator#new_character randomly allocates all 9 stat points between ["strength", "dexterity", "intelligence", "charisma"]
     Failure/Error: expect(rcg.stats_array.reduce(0) { |points, stat| points += character[stat] }).to eq rcg.points_pool

       expected: 9
            got: 20

       (compared using ==)
     # ./spec/services/random_character_generator_spec.rb:74:in `block (3 levels) in <top (required)>'

Finished in 0.02692 seconds (files took 0.84623 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/services/random_character_generator_spec.rb:73 # RandomCharacterGenerator#new_character randomly allocates all 9 stat points between ["strength", "dexterity", "intelligence", "charisma"]
Enter fullscreen mode Exit fullscreen mode

Great, we've got a new error! The value we're adding up with reduce isn't matching the expected value (9). Let's add a bit more code to ensure that @points_pool isn't exceeded by each rand(1..max_roll):

# /app/services/random_character_generator.rb

    def roll_stats(character, stats_array, points_pool, max_roll)
        stats_array.each do |stat|
            roll = rand(1..10)

            if roll > points_pool
                roll = points_pool
            end

            character[stat] = roll
            points_pool -= roll
        end
    end
Enter fullscreen mode Exit fullscreen mode

Awesome, our test will now pass!

3. it "allocates stat points so they do not exceed max roll 6"

For this, let's write four separate expect tests in the same it block. Each one will test an individual character[stat], and the test will only pass if all four expects are true:

# /spec/services/random_character_generator_spec.rb

        it "allocates stat points so stat values are between 1 and max roll (#{rcg.max_roll})" do
            expect(character.strength).to be_between(1, rcg.max_roll)
            expect(character.dexterity).to be_between(1, rcg.max_roll)
            expect(character.intelligence).to be_between(1, rcg.max_roll)
            expect(character.charisma).to be_between(1, rcg.max_roll)
        end
Enter fullscreen mode Exit fullscreen mode

Let's check out our result with bundle exec rspec:

Failures:

  1) RandomCharacterGenerator#new_character allocates stat points so stat values are between 1 and max roll (6)
     Failure/Error: expect(character.strength).to be_between(1, rcg.max_roll)
       expected 8 to be between 1 and 6 (inclusive)
     # ./spec/services/random_character_generator_spec.rb:78:in `block (3 levels) in <top (required)>'

Finished in 0.04061 seconds (files took 0.91006 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/services/random_character_generator_spec.rb:77 # RandomCharacterGenerator#new_character allocates stat points so stat values are between 1 and max roll (6)
Enter fullscreen mode Exit fullscreen mode

Okay, so our rolls are going too high! (However, it IS possible for the stats to randomly all be in the correct range--beware of false positives!)

Change roll_stats so that our rand() call cannot exceed @max_roll:

# /app/services/random_character_generator.rb

    def roll_stats(character, stats_array, points_pool, max_roll)
        stats_array.each do |stat|
            roll = rand(1..max_roll)

            if roll > points_pool
                roll = points_pool
            end

            character[stat] = roll
            points_pool -= roll
        end
    end
Enter fullscreen mode Exit fullscreen mode

Perfect!

Sidenote: the final code to allocate points across an arbitrary number of possible stats has roll_stats written to be this (and debugged using .tap!), which is recognizable from the previous article:

# /app/services/random_character_generator.rb

    def roll_stats(character, stats_array, points_pool, max_roll)
        stats_array.each_with_index do |stat, index|
            roll = rand(1..max_roll)                                    .tap {|r| puts "roll: #{r}"}
            remaining_stats = ((stats_array.length - 1) - index)        .tap {|r| puts "remaining_stats: #{r}"}
                                                                        .tap {|r| puts "points_pool (before): #{points_pool}"}
            if remaining_stats == 0
                character[stat] = points_pool
                points_pool = 0
            elsif points_pool - roll < remaining_stats
                max_points = points_pool - remaining_stats
                character[stat] = max_points
                points_pool -= max_points
            else
                character[stat] = roll
                points_pool -= roll
            end                                                         .tap {|r| puts "character[#{stat}]: #{character[stat]}"}
                                                                        .tap {|r| puts "points_pool (after): #{points_pool}\n\n"}
        end
    end
Enter fullscreen mode Exit fullscreen mode

Run the tests again, and they will still pass!

4. it "saves the Character to the database"

We'll check if our Character is successfully saved to the database by checking if the number of Character records stored in the database has increased by 1.

To start, let's get a starting_database_count variable set up in our describe "#new_character block. We'll use the ActiveRecord .count method on the Character model to get this number:

# /spec/services/random_character_generator_spec.rb

RSpec.describe RandomCharacterGenerator do

    describe "#new_character" do
        # NOTE: Do NOT create your test variables this way!! (See comments for why.) This is just an example for readability...
        starting_database_count = Character.count

        rcg = RandomCharacterGenerator.new
        player = Player.create(user_name: "Ronald McDonald", display_name: "Mac")
        character = rcg.new_character("Ronnie the Rat", player)

        ...

        it "saves the Character to the database" do
            # expect(x).to
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

Now, we can use expect(x).to eq value to check if the Character.count value after calling new_character has increased by 1:

# /spec/services/random_character_generator_spec.rb

    it "saves the Character to the database" do
        expect(Character.count).to eq (starting_database_count + 1)
    end
Enter fullscreen mode Exit fullscreen mode

Let's see what the failing test outputs with bundle exec rspec:

Failures:

  1) RandomCharacterGenerator#new_character saves the Character to the database
     Failure/Error: expect(Character.count).to eq (starting_database_count + 1)

       expected: 1736
            got: 1735

       (compared using ==)
     # ./spec/services/random_character_generator_spec.rb:87:in `block (3 levels) in <top (required)>'

Finished in 0.02612 seconds (files took 0.88349 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./spec/services/random_character_generator_spec.rb:86 # RandomCharacterGenerator#new_character saves the Character to the database
Enter fullscreen mode Exit fullscreen mode

Okay, so our Character isn't being saved. Let's toss a save! call in the .tap block called on Character.new:

# /app/services/random_character_generator.rb

    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
    end
Enter fullscreen mode Exit fullscreen mode

And our final test output:

$ bundle exec rspec
....

Finished in 0.01343 seconds (files took 0.86781 seconds to load)
4 examples, 0 failures
Enter fullscreen mode Exit fullscreen mode

Hooray! We now have test coverage for our RandomCharacterGenerator#new_character method!

Conclusion

RSpec is a great tool to help you familiarize yourself with behavior-driven development and testing in Rails.

I highly recommend reading through the resources below, especially the top few (including the RubyGuides tutorial, an excellent intro tutorial by fellow Flatiron alum Aliya Lewis, and the Devhints RSpec cheatsheet!).

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

Latest comments (2)

Collapse
 
andrewbrown profile image
Andrew Brown 🇨🇦

I recommend giving BetterSpecs a read.

Where you've placed this code will cause unexpected side-effects. You'll need to wrap it into the lets function.

        rcg = RandomCharacterGenerator.new
        player = Player.create(user_name: "Ronald McDonald", display_name: "Mac")
        character = rcg.new_character("Ronnie the Rat", player)
Enter fullscreen mode Exit fullscreen mode

If the code reads exactly as it does you can omit the descriptions:

    it { expect(character).to be_an_instance_of(Character) }
Enter fullscreen mode Exit fullscreen mode

You'll likely want to use context.
Another resource to read

Here are adjustments (I didn't add the context since I didn't have time to think about the code):

# /spec/services/random_character_generator_spec.rb

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 { expect(character).to be_an_instance_of(Character) }
  end
end
Enter fullscreen mode Exit fullscreen mode
Collapse
 
isalevine profile image
Isa Levine

Thank you for this super-helpful comment Andrew! It was actually perfectly timed--I had an interview for a Rails position earlier this week, and we spent a lot of time pair programming RSpec tests, so I was REALLY THANKFUL to have you put this on my radar so quickly!! :)

I added some comments to the code and the article noting that my variable-creation is not optimal, and directing them to both this comment and the the followup article I just published covering let and context: dev.to/isalevine/intro-to-rspec-in... . I gave you a little shoutout at the bottom too, let me know if you want me to your profile/anything you've written on the subject/anything else!

Again, really appreciate your feedback and guidance! You were absolutely right, BetterSpec is DEFINITELY the resource I needed. <3