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
at command line:
rails new MYAPP -T # The -T option tells rails not to include Test::Unit
in Gemfile:
gem 'rspec-rails'
at command line:
bundle install
rails g rspec:install
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
Or remove your
test
directory from your existing application:
rm -rf test/
Make an entry in your Gemfile:
gem 'rspec-rails'
From the command line install the gem
$ bundle install
From the command line install rspec into your application:
$ rails g rspec:install
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
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
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
Let's walk through this:
- At the top, we have
RSpec.describe <ClassName> do
. This encapsulates our tests for theRandomCharacterGenerator
service object class. - Underneath, we have
describe "#method_name" do
. This encapsulates our tests for thenew_character
method. It is a Rails convention to add#
before this--that way, the test output will readRandomCharacterGenerator#new_character
, which helps us know what we're testing. - Inside that
describe
block for our method, we instantiate a couple objects to test, and run thenew_character
method so we can assign its output to the variablecharacter
. 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!) - 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 variablecharacter
to be an instance of the classCharacter
. If this line evaluates to True, our test will pass--otherwise, it will fail!
Setting clear expect
ations
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
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
Type-specific
expect(x).to be_zero # FixNum#zero?
expect(x).to be_empty # Array#empty?
expect(x).to have_key # Hash#has_key?
Objects
expect(obj).to be_an_instance_of MyClass
expect(obj).to be_a_kind_of MyClass
Errors
expect { user.save! }.to raise_error
expect { user.save! }.to raise_error(ExceptionName, /msg/)
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
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
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
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
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
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
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
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
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
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"]
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
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"]
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
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
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)
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
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
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
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
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
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
And our final test output:
$ bundle exec rspec
....
Finished in 0.01343 seconds (files took 0.86781 seconds to load)
4 examples, 0 failures
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:
- https://www.rubyguides.com/2018/07/rspec-tutorial/
- https://dev.to/aliyalewis/testing-the-waters-with-rspec-23ga
- https://dev.to/aliyalewis/testing-the-waters-with-rspec-part-2-30po
- https://devhints.io/rspec
- https://stackoverflow.com/questions/6728618/how-can-i-tell-rails-to-use-rspec-instead-of-test-unit-when-creating-a-new-rails/6729210#6729210
- https://stackoverflow.com/questions/6116668/how-to-run-a-single-rspec-test
- https://en.wikipedia.org/wiki/Behavior-driven_development
Top comments (2)
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.If the code reads exactly as it does you can omit the descriptions:
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):
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
andcontext
: 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