Create a purr-fect virtual cat with TDD and RSpec

Ed Packard
10 min readJul 30, 2021
Dany the cat with Quack Overflow, the Makers rubber duck — image courtesy of William Day

This week I began the Makers coding bootcamp, and plunged straight into test-driven development (TDD). This, in very simple terms, is the magical art of allowing code to emerge from tests or, in other words, writing tests to match certain specifications or behaviours of your intended code, failing those tests, and then writing code to pass. Indeed, a key mantra of TDD is that you don’t write code until you have a failing test to pass! There’s a lot more to it than that, but it has fascinated and frustrated me in equal measure for the past week. To try and record some of what I’ve learned, I thought I’d try blogging a basic introductory walkthrough of how to test-drive a program into existence.

The specifications for individual tests can be derived from user stories, so let’s start off with one:

As a human,
So that I can have a pet despite a strict landlord
I would like to generate a virtual cat

OK, let’s get the project set up (I’m assuming you have Ruby installed and working). Open up Terminal and make a directory called virtual-cat, then see if RSpec is installed: if rspec -v returns a version of RSpec, you’re good to go, otherwise gem install rspec is the way forward. Once you’re happy that RSpec is installed, initialise the directory with rspec --init. You will notice this creates some new files and folders: leave them be for now. When you run the rspec command in this tutorial, make sure you’re in the virtual-cat directory rather than its subdirectories.

Now onto the user story: let’s try a feature test. Open up irb in the Terminal and try create a new cat:

3.0.0 :001 > Cat.new# Stack trace removedNameError (uninitialized constant Cat)

We have an error, and something I’ve learned the hard way this week is that errors are good! They give us a clue to the next step. In this case: we need to set up a test using RSpec to check that our soon-to-be-written program responds to Cat. Inside the virtual_cat/spec folder that RSpec created, open a new file and call it cat_spec.rb. In this file, write and save the following code:

describe Cat do
end

This is our test file. From the virtual-cat directory (not a sub-directory), run rspec from the command line. You’ll see a test output that looks like this:

An error occurred while loading ./spec/cat_spec.rb.Failure/Error:describe Cat doend
NameError:uninitialized constant Cat# ./spec/cat_spec.rb:1:in `<top (required)>'No examples found.Finished in 0.00002 seconds (files took 0.082 seconds to load)0 examples, 0 failures, 1 error occurred outside of examples

Like the feature test, we see that Cat does not exist, so an error is generated. What is also interesting here is that the error occurred outside of our examples: this isn’t an error being thrown up by the test code in cat_spec.rb.

We should probably create a Cat class to solve this problem. Create a new directory inside virtual-cat called lib and, within this folder, create and save a new file called cat_rb. We are looking to do the simplest thing possible to pass the test, so let’s just put this inside cat.rb:

class Cat
end

Now, if we run RSpec again (from the virtual-cat directory), we get … the same error. This is because spec/cat_spec.rb isn’t talking to lib/cat.rb. This is easily fixed by adding require 'cat' to the top of the cat_spec.rb file — you don’t need to specify a folder path or the file extension, RSpec will find the file if it exists. Try running RSpec again now, you should see:

No examples found.Finished in 0.00012 seconds (files took 0.05508 seconds to load)
0 examples, 0 failures

This is good — no failures, Cat is now recognised! Admittedly, not much else is happening here and we’re not actually running any tests on the behaviour of Cat, but in irb we are getting Cat objects every time we call a new instance of the Cat class (note that you have to require the cat.rb program in irb before you can create your fictional felines):

3.0.0 :001'> require './lib/cat.rb'
=> true
3.0.0 :002 > Cat.new
=> #<Cat:0x000000012fc12368>
3.0.0 :003 > Cat.new
=> #<Cat:0x000000012fad7c78>

We have fulfilled the user story — the user can now generate virtual cats with cute names like 0x000000012fad7c78. But wait, they want more:

As a virtual cat owner,
To experience a sense of contentment
I would like my cat to purr when I pet it

Back to the drawing board — or back to irb:

3.0.0 :001 > require './lib/cat.rb'
=> true
3.0.0 :002 > macavity = Cat.new
=> #<Cat:0x000000013baa9010>
3.0.0 :003 > macavity.pet
# stack trace removedNoMethodError (undefined method `pet' for #<Cat:0x000000013baa9010>)

This time the error suggests the method pet does not exist for the Cat object we have created. Let’s go back into cat_spec.rb and write a unit test:

require 'cat'describe Cat do  it "checks that cat purrs when petted" do
macavity = Cat.new
expect(macavity.pet).to eq "purr"
end
end

The unit test begins with it, followed by a short description, and then a block of code between do and end. The code in the block essentially replicates the feature test: creating a new cat object and then checking what happens when pet is called. The test is also checking to see if calling pet on a cat object results in 'purr' which is the behaviour we want as per the user story. Now the test is written, we can run RSpec:

FFailures:1) Cat checks that cat purrs when petted
Failure/Error: expect(macavity.pet).to eq "purr"

NoMethodError:
undefined method `pet' for #<Cat:0x00000001472c2b10>
# ./spec/cat_spec.rb:7:in `block (2 levels) in <top (required)>'
Finished in 0.00199 seconds (files took 0.06242 seconds to load)
1 example, 1 failure
Failed examples:rspec ./spec/cat_spec.rb:5 # Cat checks that cat purrs when petted

The F at the top indicates the test has failed, then RSpec provides plenty of information about why the failure occurred. In particular, as we saw with the feature test, it is clear that the method pet does not exist for our Cat object. It is also interesting to note that RSpec has logged this unit test as an example, and that it is currently failing. In your terminal, this line (and others) will probably be red. It is our job to write the simplest possible code to turn them green. Let’s dip back into cat.rb and sort out that undefined method error:

class Cat  def pet
end
end

Now RSpec will return a different error:

1) Cat checks that cat purrs when petted
Failure/Error: expect(macavity.pet).to eq "purr"

expected: "purr"
got: nil

We’ve defined the pet method, but our virtual cat is a tough one to please — if you pet it, it returns nothing. This is not what RSpec was expecting by the terms of our test, so we have to edit the code further:

class Cat  def pet
"purr"
end
end

RSpec now goes green!

.Finished in 0.00162 seconds (files took 0.05135 seconds to load)
1 example, 0 failures

The F at the top has been replaced by a . which denotes success. We now have a virtual cat that we can pet and receive a response — have a go on irb:

3.0.0 :001 > require './lib/cat.rb'
=> true
3.0.0 :002 > macavity = Cat.new
=> #<Cat:0x00000001553619a0>
3.0.0 :003 > macavity.pet
=> "purr"

It’s not the most exciting pet in the world, so our user soon comes back with more requirements:

As a virtual cat owner,
To remember to feed my cat
I would like it to miaow if it is hungry

OK, if we try using the method hungry? on an instance of the Cat class in irb, we get — surprise — a no method error. Let’s emulate this in a unit test:

require 'cat'describe Cat do  # previous test will be here - keep all previous tests!  it "checks that cat miaows if hungry" do
macavity = Cat.new
expect(macavity.hungry?).to eq "miaow!"
end
end

As before, RSpec will first throw up a no method error for this test, and once an empty hungry? method has been defined in cat.rb, it will say that the expected output (“miaow!”) does not match the actual output (nil). The key point is to respond to the error messages and write code that solves them, without getting tempted to write more code than you need (i.e. in a simple exercise like this, it is easy to anticipate what code you might need — but it is good to stay disciplined and focus on the tests and error messages). So you now make sure the hungry? method in cat.rb returns "miaow!":

class Cat  def pet
"purr"
end
def hungry?
"miaow!"
end
end

RSpec now goes green, and we have two dots to start, to show the two successful tests:

..Finished in 0.00251 seconds (files took 0.06863 seconds to load)
2 examples, 0 failures

Without further prompting from the user, we have assumed our cat is always hungry. This can get pretty noisy:

3.0.0 :004 > macavity.hungry?
=> "miaow!"
3.0.0 :005 > macavity.hungry?
=> "miaow!"
3.0.0 :006 > macavity.hungry?
=> "miaow!"

Unsurprisingly, our user complains:

As a virtual cat owner who is attending a coding bootcamp,
I would like to get some rest
So I would like my virtual cat to stop miaow-ing when I feed it

Same process as before — but we probably need to break this down a bit more: first, we need to be able to feed the cat, and second, we want a fed cat to not be hungry anymore. Let’s go with the first one: what happens when we try Cat.new.feed in irb? That’s right, the method doesn’t exist. The unit test to add to cat_spec.rb is straightforward enough and uses RSpec’s one-liner syntax:

require 'cat'describe Cat do# previous tests will be here - keep all previous tests!  it { is_expected.to respond_to :feed }end

This is a really easy test to pass: just create an empty feed method in your cat.rb program. The next test is a little trickier, both to write and pass. Let’s start with the test:

require 'cat'describe Cat do# previous tests will be here - keep all previous tests!  it "checks that cat stops being hungry if fed" do
macavity = Cat.new
macavity.feed
expect(macavity.hungry?).to eq("yawn!")
end
end

The test sets up the variables we need first: it creates a new cat, then feeds it. The hungry method is then called on the fed cat — unlike the earlier test we wrote for hungry?, when the cat wasn’t fed — and we’re expecting a different response. Strictly speaking, the response for a fed cat should be nil as the user hasn’t specified any other behaviours so we might assume nothing, but I’ve decided, for the sake of this blog, to make the cat yawn if it is fed: perhaps this could lead to further user stories including sleep behaviour etc.

How do we pass this test? When it is first run through RSpec, there is no change in the cat’s behaviour — it still miaows when asked if it is hungry:

...FFailures:1) Cat checks that cat stops being hungry if fed
Failure/Error: expect(macavity.hungry?).to eq("yawn!")

expected: "yawn!"
got: "miaow!"

Let’s dabble with the hungry?method in cat.rb:

def hungry?
return "yawn!" if fed
"miaow!"
end

At the moment I’m not going to worry too much about that undefined fed variable — I am just sketching out an idea in the code: i.e. if the cat is fed, it will yawn, otherwise the default response is miaow. Running RSpec might appear a bit alarming — we’re now failing an earlier test as well as the current test:

.F.FFailures:1) Cat checks that cat miaows if hungry
Failure/Error: return "yawn!" if fed

NameError:
undefined local variable or method `fed' for #<Cat:0x0000000132b510e8>
Did you mean? feed
# ./lib/cat.rb:8:in `hungry?'
# ./spec/cat_spec.rb:12:in `block (2 levels) in <top (required)>'
2) Cat checks that cat stops being hungry if fed
Failure/Error: return "yawn!" if fed

NameError:
undefined local variable or method `fed' for #<Cat:0x0000000132b60d90>
Did you mean? feed
# ./lib/cat.rb:8:in `hungry?'
# ./spec/cat_spec.rb:20:in `block (2 levels) in <top (required)>'
Finished in 0.00287 seconds (files took 0.06091 seconds to load)
4 examples, 2 failures

We obviously need to define the fed variable and the best place to do it is in the (currently empty) feed method, and we’ll make it an instance variable so we can pass it around different Cat-related methods:

def feed
@fed = true
"nomnomnom"
end

I’ve taken a bit of artistic licence, so that a message is returned to the console when the feed method is called, otherwise it would just return a boring true. Make sure you also update fed to @fed in hungry?, and try RSpec again:

....Finished in 0.00319 seconds (files took 0.06896 seconds to load)
4 examples, 0 failures

Wonderful! In four tests, we have written a program that not only generates virtual cats, but allows you to pet them, check if they’re hungry, and feed them. Don’t believe me? Try it in irb:

3.0.0 :001 > require './lib/cat.rb'
=> true
3.0.0 :002 > mr_bigglesworth = Cat.new
=> #<Cat:0x000000013e1bb888>
3.0.0 :003 > mr_bigglesworth.pet
=> "purr"
3.0.0 :004 > mr_bigglesworth.hungry?
=> "miaow!"
3.0.0 :005 > mr_bigglesworth.feed
=> "nomnomnom"
3.0.0 :006 > mr_bigglesworth.hungry?
=> "yawn!"
3.0.0 :007 > mr_bigglesworth.pet
=> "purr"

What’s more, this entire program emerged out of writing the simplest possible code in response to tests generated from user stories. This is obviously a trivial and rather amateur example of the power of TDD, but I had fun writing it, and I’m understanding RSpec a bit more now than I was at the start of the week. I also think it’s a useful exercise to think up something you’d like to write a program for and, instead of leaping straight into coding, to sit down and break it down into a diagram and/or user stories. Once you have done this, take it step-by-step, writing and failing feature tests and unit tests, and only then write the smallest and simplest bits of code necessary to move on to the next test. Meanwhile, have fun with your new virtual cat and think about possible ways to expand the program — but do it through further user stories! Sleep, play, coughing up furballs, befouling the neighbour’s garden?

For reference, here’s the complete set of RSpec tests for this program:

require 'cat'describe Cat do  it "checks that cat purrs when petted" do
macavity = Cat.new
expect(macavity.pet).to eq "purr"
end
it "checks that cat miaows if hungry" do
macavity = Cat.new
expect(macavity.hungry?).to eq "miaow!"
end
it { is_expected.to respond_to :feed } it "checks that cat stops being hungry if fed" do
macavity = Cat.new
macavity.feed
expect(macavity.hungry?).to eq("yawn!")
end
end

And here’s the final program code — very very basic, but written totally through the TDD approach:

class Cat  def pet
"purr"
end
def hungry?
return "yawn!" if @fed
"miaow!"
end
def feed
@fed = true
"nomnomnom"
end
end

--

--