Testing with RSpec

January 6th 2018
by Miranda Hawks

The idea of testing code can seem tedious and boring to a lot of developers. If you're excited to jump right in on a new idea, testing may be the last thing you want to think about. However, writing tests doesn't need to be a boring or daunting process. In addition to helping you feel confident your program works, proper testing can also help you think through and break down your code into simpler pieces.

I'm going to give a basic example of an RSpec test and run you through it. Then I'll get to how you can use testing to help you think about your program.

A Basic Test

Let's say you want to create a Pokemon game, and you have a pokemon.rb file. You might have a class like this.

class Pokemon

  def initialize(name, type, moves)
    @name = name    #string
    @type = type    #string
    @moves = moves  #array
  end

  def name
    name = @name
  end

  def type
    type = @type
  end

  def moves
    moves = @moves
  end

end

We're starting out simple. We have a name, a type, and some moves. Now, let's test this.

There's a couple of ways we could go about it. First, let's make a pokemon_spec.rb file. Generally, if you're testing a class, your file will be named [class name]_spec.rb

require_relative "pokemon"

describe Pokemon do

  let(:pokemon) { Pokemon.new("Bulbasaur", "grass", ["tackle", "razor leaf"]) }

  it "has a the expected fields" do
    expect(pokemon.name).to eq "Bulbasaur"
    expect(pokemon.type).to eq "grass"
    expect(pokemon.moves).to eq ["tackle", "razor leaf"]
  end

end

There's a few things going on here. First of all, we need to actually import our Pokemon class so our spec file knows it exists. We do that by using require_relative and giving it the path to our class. Here, I have my spec file and my class in the same directory. If I had my pokemon.rb file in a subdirectory called lib, then the relative path would be lib/pokemon.

Next, we have describe. This is just "describing" what we're testing. In this case, we're testing the Pokemon class.

Then, we have this let. This is creating a new Pokemon with the attributes we want it to have. So now we have an example of a Pokemon that we can test.

The final piece of this is our it block. The neat thing about Ruby is that it reads kind of like plain English, so writing a good test means that it should be self-explanatory to anyone. In this case, we're just testing that the class was successfully created with the attributes we gave it. So if this passes, we can say "it has the expected fields".

Let's run the test. You can do this by opening up terminal, going to the directory with the test, and running rspec pokemon_spec.rb. Here's what happens:

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
.

Finished in 0.00469 seconds (files took 0.12812 seconds to load)
1 example, 0 failures

It passes! That's great. But, we can improve it. There's nothing wrong with the tests necessarily, but we can break these up a bit more and make the test easier to read.

Right now we're saying the Pokemon "has the expected fields". When testing, it's good to be specific, and we can be more specific than that. So let's break things down a little more.

require_relative "pokemon"

describe Pokemon do

  let(:pokemon) { Pokemon.new("Bulbasaur", "grass", ["tackle", "razor leaf"]) }

  it "has a name" do
    expect(pokemon.name).to eq "Bulbasaur"
  end

  it "has a type" do
    expect(pokemon.type).to eq "grass"
  end

  it "has moves" do
    expect(pokemon.moves).to eq ["tackle", "razor leaf"]
  end

end

So, we haven't done anything we didn't learn in the previous examples, we just added more tests, right? We're still testing the same things, but now it becomes a bit more clear. Instead of saying that if these tests pass we know our Pokemon class "has the expected fields," we're being more specific. If our tests pass now, we can say confidently that our Pokemon "has a name," "has a type," and "has moves".

Testing your program in the smallest pieces possible is the idea behind unit testing. If you know that each tiny piece of your program works, you can be more confident in your code and deal with fewer bugs later. Most languages have a unit testing framework so that no matter what language you're working in, you can break your code down into the smallest testable pieces.

Now, let's look at "has moves." Right now, we're checking that it doesn't just have moves, but it has the array of moves we specified, right? What we could do instead, is something like this.

it "has moves" do
  expect(pokemon.moves.length).to be > 0
end

This way, we ensure that the Pokemon does indeed have a list a moves, but we don't really care what they are at the moment. Whether or not you want to test if it has specific moves will depend on what you're doing exactly. For now, I'm fine just making sure that the Pokemon has moves. But maybe in the future I'll want to ensure a Pokemon only has moves that match it's type - so a grass Pokemon only has grass-type moves. There could be a few ways to test that depending on how I wanted to do it, but just be aware that sometimes there's more than one way to test something and try to make sure that it makes sense for your purposes (and is clear to coders who may come after you).

Designing Your Code Around Tests

As you dive further into the world of development and testing, you'll probably hear a lot about Test Driven Development or TDD. This is the idea that you should write your tests before writing a single line of code for your program.

To demonstrate this, let's think about how we would add health to our Pokemon class. Let's say our Pokemon has a max HP and a current HP. Let's figure out how to code this by writing the tests first.

We'll start by testing that our Pokemon has a max HP.

it "has a max HP" do
  expect(pokemon.max_hp).to eq 100
end

When we run this, it should fail. Here's what we get:

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
...F

Failures:

  1) Pokemon has a max HP
     Failure/Error: expect(pokemon.max_hp).to eq 100

     NoMethodError:
       undefined method `max_hp' for #<Pokemon:0x00007f9ca0018f80>
     # ./pokemon_spec.rb:20:in `block (2 levels) in <top (required)>'

Finished in 0.01086 seconds (files took 0.20539 seconds to load)
4 examples, 1 failure

Failed examples:

rspec ./pokemon_spec.rb:19 # Pokemon has a max HP

Because of how we wrote the tests, we can quickly see that the max HP test is what failed, and that the max_hp method is undefined for the Pokemon class. So let's add code to make it work! This is pretty simple, right? It's just like the other attributes for our Pokemon in pokemon.rb.

We'll change our initialize method to add the new attribute:

def initialize(name, type, moves, max_hp)
  @name = name    #string
  @type = type    #string
  @moves = moves  #array
  @max_hp = max_hp
end

Next, we'll add this method:

def max_hp
  max_hp = @max_hp
end

Finally, in our test file, we need to make sure our example pokemon has the max_hp attribute, so we'll add it.

let(:pokemon) { Pokemon.new("Bulbasaur", "grass", ["tackle", "razor leaf"], 100) }

When we run it again, we should get no failures:

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
....

Finished in 0.00919 seconds (files took 0.20915 seconds to load)
4 examples, 0 failures

Great! So we started with a failing test, added the code we need to make it work, and now it's successful.

Now, try adding a test to get the Pokemon's current HP. Then write the code to make that test green. I'll put my finished code at the bottom so you can compare.

Once you've done that, we can now make a method to subtract HP from the Pokemon, so in a battle we can show that it took damage.

Let's start with the test. We know that when our Pokemon takes damage, we need to reduce it's current HP.

describe "#reduce_hp" do
  it "lowers current HP by the given amount" do
    current_hp = pokemon.current_hp
    pokemon.reduce_hp(10)
    expect(pokemon.current_hp).to eq current_hp - 10
  end
end

For this test, I nested a describe block inside our original. Since we're not just testing attributes of the class, it makes things a little more clear. Now, we know this should fail.

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
.....F

Failures:

  1) Pokemon#reduce_hp reduces current HP by the given amount
     Failure/Error: pokemon.reduce_hp(10)

     NoMethodError:
       undefined method `reduce_hp' for #<Pokemon:0x00007fde0284f048>
     # ./pokemon_spec.rb:30:in `block (3 levels) in <top (required)>'

Finished in 0.01188 seconds (files took 0.21759 seconds to load)
6 examples, 1 failure

Failed examples:

rspec ./pokemon_spec.rb:28 # Pokemon#reduce_hp reduces current HP by the given amount

We can see that the nested describe block helps make the error more clear: Our failure is in our Pokemon class, in the reduce_hp method, specifically when we expect it to reduce the current HP.

So let's write a method to make this test work. Since we've already worked out the expected behavior in our test, this is a fairly simple task. In pokemon.rb, we'll add the following:

def reduce_hp(damage)
  @current_hp =  @current_hp - damage
end

Now, the test should work.

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
......

Finished in 0.01173 seconds (files took 0.21679 seconds to load)
6 examples, 0 failures

Great! But, we can clean up our method a little bit. We know the test works properly now, so we can refactor our code to make it a bit cleaner.

Let's try this:

def reduce_hp(damage)
  @current_hp -= damage
end

And when we run the test again:

mirandas-air:rspec-tutorial miranda$ rspec pokemon_spec.rb
......

Finished in 0.01106 seconds (files took 0.21238 seconds to load)
6 examples, 0 failures

Awesome! Since we're confident our test works properly, we can be certain our refactor didn't break anything.

So we started with a failure, wrote code to make it pass, then refactored the code to make it better. This approach is called red, green, refactor and it's a very common way to do TDD!

Plus, since we figured out the expected behavior of our method and defined it in our tests, it was simpler to think about and write.

This is a pretty simple example of TDD testing, but hopefully this shows you how TDD can help you work out harder problems. It forces you to start by breaking down every part into pieces and figuring out the expected behavior of each piece. You can do this on your own without tests of course, but I find if I'm getting overwhelmed by the size of a task, writing tests can be a good starting point. Plus, you'll end up with code you're confident in because it's fully tested. I know from experience that once you've finally finished working on a big feature or chunk of code, it's hard to work up the energy to go and write tests. For myself and many others, it's better to get it out of the way.

Hopefully now you're able to understand basic RSpec tests and why people use techniques like TDD to develop code. It can be hard to get into the habit of doing it, but hopefully this article gives you a little more motivation to make testing a regular part of your development process.

Here's the complete code for this tutorial:

#pokemon.rb
class Pokemon

  def initialize(name, type, moves, max_hp, current_hp)
    @name = name    #string
    @type = type    #string
    @moves = moves  #array
    @max_hp = max_hp
    @current_hp = current_hp
  end

  def name
    name = @name
  end

  def type
    type = @type
  end

  def moves
    moves = @moves
  end

  def max_hp
    max_hp = @max_hp
  end

  def current_hp
    current_hp = @current_hp
  end

  def reduce_hp(damage)
    @current_hp -= damage
  end
end
#pokemon_spec.rb
require_relative "pokemon"

describe Pokemon do

  let(:pokemon) { Pokemon.new("Bulbasaur", "grass", ["tackle", "razor leaf"], 100, 100) }

  it "has a name" do
    expect(pokemon.name).to eq "Bulbasaur"
  end

  it "has a type" do
    expect(pokemon.type).to eq "grass"
  end

  it "has moves" do
    expect(pokemon.moves.length).to be > 0
  end

  it "has a max HP" do
    expect(pokemon.max_hp).to eq 100
  end

  it "has a current HP" do
    expect(pokemon.current_hp).to eq 100
  end

  describe "#reduce_hp" do
    it "reduces current HP by the given amount" do
      current_hp = pokemon.current_hp
      pokemon.reduce_hp(10)
      expect(pokemon.current_hp).to eq current_hp - 10
    end
  end

end