Loading
All articles
Programming 26 min read

Intro to RSpec and Test-Driven Development: A Visual, Hands-On Guide

Learn RSpec and test-driven development by building a factorial method one failing test at a time: red, green, refactor, reading failures, matchers, edge cases, real run-by-run results, speed measurements, and exercises with answers.

nbkhope
On this page

How do you know your code works? You run it, try a few inputs, and eyeball the answers. That works once. But every time you change the code you'd have to try all those inputs again, and you won't. Automated tests do the trying for you, in a fraction of a second, every time.

RSpec is the most popular testing tool for Ruby, and test-driven development (TDD) is a way of working where you write the test before the code. In this guide you'll build a factorial method the TDD way, one failing test at a time, and see every real message RSpec prints along the way. You'll learn to read failures, use matchers, handle edge cases, and keep your code safe while you clean it up. Exercises with answers wrap it up.

All the outputs below come from real runs with RSpec 3.13 on Ruby 3.3. Your timings ("Finished in…") will differ a little from run to run.

The test-driven development cycle

The test-driven development cycle. 1, Red: write one small test for what the code should do; run it: it fails. 2, Green: write the simplest code that makes the test pass; run all the tests again. 3, Refactor: clean up the code (and the tests); the tests must stay green the whole time. Then repeat until the feature is done. Each trip around the loop takes minutes, not hours. The failing test comes first, so you know it really checks something: a test that has never failed might not be testing anything

TDD has three steps, named after the colors test tools print:

  1. Red: write a small test for something the code should do. Run it and watch it fail (there's no code yet, after all).
  2. Green: write the simplest code that makes that test pass. Run all the tests.
  3. Refactor: clean up the code while keeping every test green.

Then repeat with the next small piece of behavior. The tests pile up into a safety net that checks everything you've built, every time you run them.

Our goal: factorial

Factorial: multiply all the way down to 1. 0! equals 1 by definition. 1! = 1. 2! = 2 × 1 = 2. 3! = 3 × 2 × 1 = 6. 4! = 4 × 3 × 2 × 1 = 24. 5! = 5 × 4 × 3 × 2 × 1 = 120. The rule: n! = n × (n − 1)!, every factorial is the number times the factorial just below it, so 5! = 5 × 4! = 5 × 24 = 120. The base cases 0! = 1 and 1! = 1 become our first tests

The factorial of a number, written n!, multiplies the number by every whole number below it down to 1: 4! = 4 × 3 × 2 × 1 = 24. The base cases are 1! = 1 and, by definition, 0! = 1.

Notice how the table is already a list of tests: each row is an input with the answer we expect.

Setting up

Install RSpec (once per computer) and check the version:

1$ gem install rspec
2$ rspec --version
3RSpec 3.13
4  - rspec-core 3.13.6
5  - rspec-expectations 3.13.5
6  - rspec-mocks 3.13.8
7  - rspec-support 3.13.7

Make a project folder, go into it, and let RSpec set itself up:

1$ mkdir factorial
2$ cd factorial
3$ rspec --init
4  create   .rspec
5  create   spec/spec_helper.rb
Where the files go. The factorial/ project folder is where you run rspec. Inside it: .rspec, the command-line options rspec always uses; factorial.rb, the code being tested; and the spec/ folder, where rspec looks for tests. Inside spec/: spec_helper.rb, shared settings made by rspec --init, and factorial_spec.rb, the tests, whose names must end in _spec.rb

Then create an empty factorial.rb for the code and a spec/factorial_spec.rb for the tests. RSpec runs every file in spec/ whose name ends in _spec.rb, so those two naming rules matter.

Red: the first test

Put this in spec/factorial_spec.rb:

1require_relative "../factorial"
2
3RSpec.describe "factorial" do
4  it "returns 1 for 0" do
5    expect(factorial(0)).to eq(1)
6  end
7end
The anatomy of a spec. require_relative "../factorial" loads the code from the file one folder up. RSpec.describe "factorial" do starts an example group, naming what is being described. it "returns 1 for 0" do is an example: one behavior, one test. expect(factorial(0)).to eq(1) is an expectation: factorial(0) is the actual value and eq(1) is the matcher with the expected value. No magic: describe, it, expect and eq are ordinary Ruby methods, and do … end are ordinary blocks
  • describe names the thing being tested and groups its examples.
  • it is one example: a single behavior, described in words.
  • expect(actual).to matcher is the check. Here the matcher eq(1) means "equals 1".

Read it out loud: factorial returns 1 for 0: expect factorial of 0 to equal 1. That readability is RSpec's whole style, but there's no magic: describe, it, expect and eq are plain Ruby methods taking strings and blocks.

Run the tests from the project folder (the one that holds spec/):

1$ rspec
2F
3
4Failures:
5
6  1) factorial returns 1 for 0
7     Failure/Error: expect(factorial(0)).to eq(1)
8
9     NoMethodError:
10       undefined method `factorial' for #<RSpec::ExampleGroups::Factorial:0x000001f19d775700>
11     # ./spec/factorial_spec.rb:5:in `block (2 levels) in <top (required)>'
12
13Finished in 0.01337 seconds (files took 0.30762 seconds to load)
141 example, 1 failure
15
16Failed examples:
17
18rspec ./spec/factorial_spec.rb:4 # factorial returns 1 for 0

Red, as expected: there's no factorial method yet. (The 0x0000… number is a memory address and changes every run.) Each F in the first line is a failing example; a passing one prints a dot.

Green, one tiny step at a time

In TDD you let the failure tell you what to do next, and you do only that. Define the method:

def factorial
end
1$ rspec
2F
3
4Failures:
5
6  1) factorial returns 1 for 0
7     Failure/Error: expect(factorial(0)).to eq(1)
8
9     ArgumentError:
10       wrong number of arguments (given 1, expected 0)
11     # ./factorial.rb:1:in `factorial'
12     # ./spec/factorial_spec.rb:5:in `block (2 levels) in <top (required)>'
13
14Finished in 0.01436 seconds (files took 0.31405 seconds to load)
151 example, 1 failure
16
17Failed examples:
18
19rspec ./spec/factorial_spec.rb:4 # factorial returns 1 for 0

A new failure is progress. The test passes one argument but the method takes none, so add a parameter:

def factorial(number)
end
1$ rspec
2F
3
4Failures:
5
6  1) factorial returns 1 for 0
7     Failure/Error: expect(factorial(0)).to eq(1)
8
9       expected: 1
10            got: nil
11
12       (compared using ==)
13     # ./spec/factorial_spec.rb:5:in `block (2 levels) in <top (required)>'
14
15Finished in 0.04566 seconds (files took 0.29809 seconds to load)
161 example, 1 failure
17
18Failed examples:
19
20rspec ./spec/factorial_spec.rb:4 # factorial returns 1 for 0

Now the method runs, but an empty method returns nil. What's the simplest code that returns 1? Returning 1:

def factorial(number)
  1
end
1$ rspec
2.
3
4Finished in 0.02012 seconds (files took 0.44638 seconds to load)
51 example, 0 failures

Green! Returning a constant feels like cheating, and it is, on purpose: the code is only as smart as the tests demand. The next tests will force it to get smarter.

Here's the journey so far:

RunRSpec saidWhat it meansThe fix
1NoMethodError: undefined method 'factorial'The method doesn't existDefine it
2ArgumentError: wrong number of arguments (given 1, expected 0)It takes the wrong number of argumentsAdd a parameter
3expected: 1, got: nilIt runs but returns the wrong valueReturn 1
41 example, 0 failuresGreenWrite the next test

How to read a failure

How to read a failure. The first line, 1) factorial returns 2 for 2, says which example failed: the group and example names. Failure/Error: expect(factorial(2)).to eq(2) is the line that failed. expected: 2 is what the test wanted, and got: 1 is what the code gave. The # ./spec/factorial_spec.rb:13 line gives the file and line number. The last line, rspec ./spec/factorial_spec.rb:12 # factorial returns 2 for 2, can be pasted to rerun just that example. Read top to bottom: which behavior broke, which expectation, the difference, and where

Nicer output: the documentation format

rspec --init created a .rspec file holding options RSpec uses every run. Add a second line so it prints each example by name:

--require spec_helper
--format documentation

More tests, smarter code

Add two more examples to the spec:

1require_relative "../factorial"
2
3RSpec.describe "factorial" do
4  it "returns 1 for 0" do
5    expect(factorial(0)).to eq(1)
6  end
7
8  it "returns 1 for 1" do
9    expect(factorial(1)).to eq(1)
10  end
11
12  it "returns 2 for 2" do
13    expect(factorial(2)).to eq(2)
14  end
15end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2 (FAILED - 1)
7
8Failures:
9
10  1) factorial returns 2 for 2
11     Failure/Error: expect(factorial(2)).to eq(2)
12
13       expected: 2
14            got: 1
15
16       (compared using ==)
17     # ./spec/factorial_spec.rb:13:in `block (2 levels) in <top (required)>'
18
19Finished in 0.0123 seconds (files took 0.40719 seconds to load)
203 examples, 1 failure
21
22Failed examples:
23
24rspec ./spec/factorial_spec.rb:12 # factorial returns 2 for 2

The new test for 1 passed straight away (our constant happens to be right), and the one for 2 fails. The simplest fix? Still a little cheeky:

1def factorial(number)
2  return 1 if number <= 1
3
4  number
5end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7
8Finished in 0.00293 seconds (files took 0.37575 seconds to load)
93 examples, 0 failures

This trick is called fake it till you make it. To force a real solution, add examples that no shortcut can satisfy. This is called triangulation:

1  it "returns 6 for 3" do
2    expect(factorial(3)).to eq(6)
3  end
4
5  it "returns 24 for 4" do
6    expect(factorial(4)).to eq(24)
7  end
8
9  it "returns 5040 for 7" do
10    expect(factorial(7)).to eq(5040)
11  end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3 (FAILED - 1)
8  returns 24 for 4 (FAILED - 2)
9  returns 5040 for 7 (FAILED - 3)
10
11Failures:
12
13  1) factorial returns 6 for 3
14     Failure/Error: expect(factorial(3)).to eq(6)
15
16       expected: 6
17            got: 3
18
19       (compared using ==)
20     # ./spec/factorial_spec.rb:17:in `block (2 levels) in <top (required)>'
21
22  2) factorial returns 24 for 4
23     Failure/Error: expect(factorial(4)).to eq(24)
24
25       expected: 24
26            got: 4
27
28       (compared using ==)
29     # ./spec/factorial_spec.rb:21:in `block (2 levels) in <top (required)>'
30
31  3) factorial returns 5040 for 7
32     Failure/Error: expect(factorial(7)).to eq(5040)
33
34       expected: 5040
35            got: 7
36
37       (compared using ==)
38     # ./spec/factorial_spec.rb:25:in `block (2 levels) in <top (required)>'
39
40Finished in 0.01422 seconds (files took 0.33051 seconds to load)
416 examples, 3 failures
42
43Failed examples:
44
45rspec ./spec/factorial_spec.rb:16 # factorial returns 6 for 3
46rspec ./spec/factorial_spec.rb:20 # factorial returns 24 for 4
47rspec ./spec/factorial_spec.rb:24 # factorial returns 5040 for 7

Look at the "got" values: 3, 4, 7. The method just hands back its input. Time to write it for real, multiplying from the number down to 2:

1def factorial(number)
2  product = 1
3  multiplier = number
4
5  while multiplier > 1
6    product *= multiplier
7    multiplier -= 1
8  end
9
10  product
11end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10
11Finished in 0.0032 seconds (files took 0.33583 seconds to load)
126 examples, 0 failures

All green. The base cases are handled too: for 0 and 1 the loop never runs and product stays 1.

Refactor: clean up with a safety net

The while loop works, but Ruby can say "multiply 1 through n" in one line with reduce. Because the tests exist, we can try it without fear:

def factorial(number)
  (1..number).reduce(1, :*)
end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10
11Finished in 0.00318 seconds (files took 0.32349 seconds to load)
126 examples, 0 failures

Still green, so the shorter version behaves exactly the same on everything we've specified. reduce(1, :*) starts at 1 and multiplies in each number of the range; for 0 the range 1..0 is empty, so the answer is the starting 1.

Edge cases: huge numbers and negative input

What else should factorial do? Two good questions to turn into tests:

  • Big numbers. Factorials grow absurdly fast.
  • Bad input. Factorial isn't defined for negative numbers.
Factorials grow fast: digits in n!, computed with Ruby 3.3. 5! has 3 digits, 10! has 7, 15! has 13, 20! has 19, 21! has 20, 25! has 26, 50! has 65 and 100! has 158. A dashed line marks the 64-bit integer limit of 19 digits: 20! fits, 21! does not. Many languages overflow at 21!. Ruby integers grow as needed, so factorial(100), all 158 digits of it, just works, and a test can check a 26-digit answer like 25!
1  it "handles numbers too big for 64 bits" do
2    expect(factorial(25)).to eq(15_511_210_043_330_985_984_000_000)
3  end
4
5  it "rejects negative numbers" do
6    expect { factorial(-1) }.to raise_error(ArgumentError)
7  end

Notice the curly braces in the second test: expect { … } hands RSpec a block of code to run, so it can watch for an error. With parentheses, the error would happen before RSpec could catch it.

1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10  handles numbers too big for 64 bits
11  rejects negative numbers (FAILED - 1)
12
13Failures:
14
15  1) factorial rejects negative numbers
16     Failure/Error: expect { factorial(-1) }.to raise_error(ArgumentError)
17       expected ArgumentError but nothing was raised
18     # ./spec/factorial_spec.rb:33:in `block (2 levels) in <top (required)>'
19
20Finished in 0.01434 seconds (files took 0.31673 seconds to load)
218 examples, 1 failure
22
23Failed examples:
24
25rspec ./spec/factorial_spec.rb:32 # factorial rejects negative numbers

The big-number test passed immediately: Ruby integers grow as large as needed. The negative test is red, because factorial(-1) quietly returns 1, a wrong answer that looks right. Raise an error instead:

1def factorial(number)
2  raise ArgumentError, "factorial is not defined for negative numbers" if number.negative?
3
4  (1..number).reduce(1, :*)
5end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10  handles numbers too big for 64 bits
11  rejects negative numbers
12
13Finished in 0.00767 seconds (files took 0.33693 seconds to load)
148 examples, 0 failures

Refactoring the tests: a table of cases

Tests are code too, and they deserve cleanup. Those six nearly identical examples can come from a hash of inputs and answers, and context groups the examples for one situation:

1require_relative "../factorial"
2
3RSpec.describe "factorial" do
4  {
5    0 => 1,
6    1 => 1,
7    2 => 2,
8    3 => 6,
9    4 => 24,
10    7 => 5040,
11    25 => 15_511_210_043_330_985_984_000_000,
12  }.each do |number, expected|
13    it "returns #{expected} for #{number}" do
14      expect(factorial(number)).to eq(expected)
15    end
16  end
17
18  context "with a negative number" do
19    it "raises an ArgumentError" do
20      expect { factorial(-1) }.to raise_error(ArgumentError, /negative/)
21    end
22  end
23end
1$ rspec
2
3factorial
4  returns 1 for 0
5  returns 1 for 1
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10  returns 15511210043330985984000000 for 25
11  with a negative number
12    raises an ArgumentError
13
14Finished in 0.00831 seconds (files took 0.44918 seconds to load)
158 examples, 0 failures

Same eight checks, less repetition, and adding a case is now one line. The /negative/ also checks the error's message. (The hashes guide covers the each with two block parameters.)

The whole session at a glance

Our TDD session, run by run: examples passing and failing after each of the 12 rspec runs in this article. Runs 1 to 3 (no method, no parameter, returns nil) had 0 of 1 passing. Run 4 (return 1): 1 of 1. Run 5 (test for 2): 2 of 3. Run 6 (fake it): 3 of 3. Run 7 (tests for 3, 4 and 7): 3 of 6. Run 8 (real loop): 6 of 6. Run 9 (reduce, a refactor): 6 of 6. Run 10 (test for −1): 7 of 8. Run 11 (raise error): 8 of 8. Run 12 (table of cases, a refactor): 8 of 8. Every new test failed first and then passed; the two refactor runs stayed fully green

Every red bar is a test that failed before the code existed to pass it. That's the proof each test can actually catch a problem.

Tests catch a broken refactor

Here's why the safety net matters. Suppose someone "simplifies" the method, reasoning that multiplying by 1 is pointless:

1def factorial(number)
2  raise ArgumentError, "factorial is not defined for negative numbers" if number.negative?
3
4  (2..number).reduce(:*)
5end

It looks fine and works for 2, 3, 4… But:

1$ rspec
2
3factorial
4  returns 1 for 0 (FAILED - 1)
5  returns 1 for 1 (FAILED - 2)
6  returns 2 for 2
7  returns 6 for 3
8  returns 24 for 4
9  returns 5040 for 7
10  returns 15511210043330985984000000 for 25
11  with a negative number
12    raises an ArgumentError
13
14Failures:
15
16  1) factorial returns 1 for 0
17     Failure/Error: expect(factorial(number)).to eq(expected)
18
19       expected: 1
20            got: nil
21
22       (compared using ==)
23     # ./spec/factorial_spec.rb:14:in `block (3 levels) in <top (required)>'
24
25  2) factorial returns 1 for 1
26     Failure/Error: expect(factorial(number)).to eq(expected)
27
28       expected: 1
29            got: nil
30
31       (compared using ==)
32     # ./spec/factorial_spec.rb:14:in `block (3 levels) in <top (required)>'
33
34Finished in 0.02148 seconds (files took 0.38502 seconds to load)
358 examples, 2 failures
36
37Failed examples:
38
39rspec './spec/factorial_spec.rb[1:1]' # factorial returns 1 for 0
40rspec './spec/factorial_spec.rb[1:2]' # factorial returns 1 for 1

(2..0) and (2..1) are empty ranges, and reduce without a starting value returns nil for an empty range. The base-case tests caught the bug in milliseconds, long before a user could. (Since the examples come from a loop, RSpec names them by position, like [1:1], instead of by line.)

Running just some of the tests

As a suite grows, you'll often want to run one example or a few:

1$ rspec spec/factorial_spec.rb:19
2Run options: include {:locations=>{"./spec/factorial_spec.rb"=>[19]}}
3
4factorial
5  with a negative number
6    raises an ArgumentError
7
8Finished in 0.00457 seconds (files took 0.33805 seconds to load)
91 example, 0 failures
1$ rspec -e "negative"
2Run options: include {:full_description=>/negative/}
3
4factorial
5  with a negative number
6    raises an ArgumentError
7
8Finished in 0.00404 seconds (files took 0.47814 seconds to load)
91 example, 0 failures
  • rspec path:line runs the example (or group) at that line.
  • rspec -e "words" runs the examples whose full description contains those words.
  • rspec --format progress overrides .rspec and prints just dots: ........

Matchers: more ways to check

eq is only the beginning. RSpec comes with matchers for numbers, collections, strings, types, errors and changes:

1RSpec.describe "everyday matchers" do
2  it "compares values" do
3    expect(2 + 2).to eq(4)
4    expect(10).to be > 3
5    expect(7).to be_between(1, 10)
6    expect(0.1 + 0.2).to be_within(0.001).of(0.3)
7  end
8
9  it "checks true, false and nil" do
10    expect([].empty?).to be true
11    expect([]).to be_empty
12    expect(nil).to be_nil
13    expect(5).not_to be_nil
14  end
15
16  it "looks inside strings and collections" do
17    expect("Hello, Ruby").to include("Ruby")
18    expect("Hello, Ruby").to start_with("Hello")
19    expect("2024-07-07").to match(/\A\d{4}-\d{2}-\d{2}\z/)
20    expect([3, 1, 2]).to contain_exactly(1, 2, 3)
21    expect([2, 4, 6]).to all(be_even)
22    expect({ name: "Ada", born: 1815 }).to include(name: "Ada")
23  end
24
25  it "checks types" do
26    expect(42).to be_an(Integer)
27    expect("42").to be_a(String)
28  end
29
30  it "checks errors and changes" do
31    expect { Integer("forty-two") }.to raise_error(ArgumentError)
32    cart = []
33    expect { cart << "apple" }.to change { cart.size }.from(0).to(1)
34  end
35end
1$ rspec
2
3everyday matchers
4  compares values
5  checks true, false and nil
6  looks inside strings and collections
7  checks types
8  checks errors and changes
9
10Finished in 0.03413 seconds (files took 0.41136 seconds to load)
115 examples, 0 failures

A few worth pointing out:

  • be_within(…).of(…) compares decimals with a tolerance, because 0.1 + 0.2 is 0.30000000000000004, not exactly 0.3.
  • be_empty, be_even are predicate matchers: be_xyz calls the method xyz? and expects true.
  • not_to flips any matcher.

Good matchers also write good failure messages:

1RSpec.describe "failure messages" do
2  it "explains a missing item" do
3    expect(["apple", "banana"]).to include("cherry")
4  end
5
6  it "shows a diff for hashes" do
7    expect({ name: "Ada", born: 1815 }).to eq({ name: "Ada", born: 1816 })
8  end
9end
1$ rspec spec/failing_spec.rb
2
3failure messages
4  explains a missing item (FAILED - 1)
5  shows a diff for hashes (FAILED - 2)
6
7Failures:
8
9  1) failure messages explains a missing item
10     Failure/Error: expect(["apple", "banana"]).to include("cherry")
11       expected ["apple", "banana"] to include "cherry"
12     # ./spec/failing_spec.rb:3:in `block (2 levels) in <top (required)>'
13
14  2) failure messages shows a diff for hashes
15     Failure/Error: expect({ name: "Ada", born: 1815 }).to eq({ name: "Ada", born: 1816 })
16
17       expected: {:born=>1816, :name=>"Ada"}
18            got: {:born=>1815, :name=>"Ada"}
19
20       (compared using ==)
21
22       Diff:
23       @@ -1,2 +1,2 @@
24       -:born => 1816,
25       +:born => 1815,
26        :name => "Ada",
27     # ./spec/failing_spec.rb:7:in `block (2 levels) in <top (required)>'
28
29Finished in 0.02796 seconds (files took 0.3225 seconds to load)
302 examples, 2 failures
31
32Failed examples:
33
34rspec ./spec/failing_spec.rb:2 # failure messages explains a missing item
35rspec ./spec/failing_spec.rb:6 # failure messages shows a diff for hashes

The diff points straight at the one value that differs, which is a lifesaver with big hashes.

A map of everyday RSpec matchers. Equality: eq(4), eql(4), equal(object), be(true). Comparison: be > 3, be <= 10, be_between(1, 10), be_within(0.1).of(x). Truthiness: be true or be false, be_nil, be_truthy, be_falsey. Predicates: be_empty, be_even, be_positive, have_key(:name). Collections: include(3), contain_exactly, all(be_even), start_with(1). Strings: include("Ruby"), start_with("He"), end_with("!"), match a regular expression. Types: be_a(String), be_an(Integer), be_instance_of, respond_to(:size). Blocks: raise_error(KeyError), change { x }, output("hi").to_stdout, not_to raise_error

How fast are unit tests?

Fast tests get run; slow tests get skipped. We generated specs with 10 to 10,000 factorial examples and timed them:

10,000 examples in about half a second. Time rspec reported to run specs of each size, measured for this article with RSpec 3.13 on Ruby 3.3.2, Windows, median of 5 runs: 10 examples, 5.0 milliseconds; 100 examples, 11.7 ms; 1,000 examples, 59.9 ms; 10,000 examples, 561.5 ms. About 56 microseconds per example. Starting Ruby and loading the files added about a third of a second to every run

About 56 microseconds per example. Starting Ruby and loading files cost more (about a third of a second per run) than running a thousand examples. Small, focused unit tests like ours are cheap enough to run every time you save a file.

Your turn: practice

Try these before peeking at the answers. Set up each one like the factorial project: the code in the project folder, the spec in spec/, and --format documentation in .rspec.

Exercise 1: Predict the failure (warm-up). Which example fails, and what will RSpec say "got"? (A year is a leap year if it's divisible by 4, except years divisible by 100, unless they're also divisible by 400.)

def leap_year?(year)
  year % 4 == 0
end
1require_relative "../leap_year"
2
3RSpec.describe "leap_year?" do
4  it "is true for 2024" do
5    expect(leap_year?(2024)).to be true
6  end
7
8  it "is false for 2023" do
9    expect(leap_year?(2023)).to be false
10  end
11
12  it "is false for 1900" do
13    expect(leap_year?(1900)).to be false
14  end
15
16  it "is true for 2000" do
17    expect(leap_year?(2000)).to be true
18  end
19end

Exercise 2: Read the failure. This test fails with expected: 120, got: 0. What's the bug?

1def factorial(number)
2  product = 0
3  (1..number).each { |multiplier| product *= multiplier }
4  product
5end

Exercise 3: TDD FizzBuzz. Write the tests first, then the code: fizzbuzz(number) returns "Fizz" for multiples of 3, "Buzz" for multiples of 5, "FizzBuzz" for multiples of both, and the number as a string otherwise.

Exercise 4: Palindromes and edge cases. TDD a palindrome?(text) method that ignores capital letters, spaces and punctuation, so "A man, a plan, a canal: Panama!" counts. Think about the empty string too.

Exercise 5: Fibonacci (challenge). TDD fibonacci(position), where the sequence is 0, 1, 1, 2, 3, 5, 8… starting at position 0. Use a table of cases including position 100, and reject negative positions.

Quick quiz

  1. What are the three steps of the TDD cycle?
  2. Why should a new test fail before you write the code for it?
  3. When do you write expect { … } with curly braces instead of expect(…)?
  4. What does be_empty actually call?
  5. How do you run only the examples whose names mention "negative"?

Answers

Exercise 1: 1900 is divisible by 4, so the buggy method says true:

1$ rspec
2
3leap_year?
4  is true for 2024
5  is false for 2023
6  is false for 1900 (FAILED - 1)
7  is true for 2000
8
9Failures:
10
11  1) leap_year? is false for 1900
12     Failure/Error: expect(leap_year?(1900)).to be false
13
14       expected false
15            got true
16     # ./spec/leap_year_spec.rb:13:in `block (2 levels) in <top (required)>'
17
18Finished in 0.01163 seconds (files took 0.34913 seconds to load)
194 examples, 1 failure
20
21Failed examples:
22
23rspec ./spec/leap_year_spec.rb:12 # leap_year? is false for 1900

The full rule turns it green:

def leap_year?(year)
  (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
end
1$ rspec
2
3leap_year?
4  is true for 2024
5  is false for 2023
6  is false for 1900
7  is true for 2000
8
9Finished in 0.00365 seconds (files took 0.3437 seconds to load)
104 examples, 0 failures

Notice 2000 passed even with the bug: without the 1900 test, the bug would have gone unnoticed. Good tests hunt for the tricky cases.

Exercise 2: product starts at 0, and 0 times anything is 0. A product must start at 1 (just as a sum starts at 0). Changing product = 0 to product = 1 makes it pass.

1$ rspec
2
3factorial
4  returns 120 for 5 (FAILED - 1)
5
6Failures:
7
8  1) factorial returns 120 for 5
9     Failure/Error: expect(factorial(5)).to eq(120)
10
11       expected: 120
12            got: 0
13
14       (compared using ==)
15     # ./spec/factorial_spec.rb:5:in `block (2 levels) in <top (required)>'
16
17Finished in 0.01086 seconds (files took 0.39225 seconds to load)
181 example, 1 failure
19
20Failed examples:
21
22rspec ./spec/factorial_spec.rb:4 # factorial returns 120 for 5

Exercise 3: One test per rule. Check "both" first in the code, or 15 would stop at "Fizz":

1require_relative "../fizzbuzz"
2
3RSpec.describe "fizzbuzz" do
4  it "returns the number as text" do
5    expect(fizzbuzz(1)).to eq("1")
6  end
7
8  it "returns Fizz for multiples of 3" do
9    expect(fizzbuzz(9)).to eq("Fizz")
10  end
11
12  it "returns Buzz for multiples of 5" do
13    expect(fizzbuzz(10)).to eq("Buzz")
14  end
15
16  it "returns FizzBuzz for multiples of both" do
17    expect(fizzbuzz(30)).to eq("FizzBuzz")
18  end
19end
1def fizzbuzz(number)
2  return "FizzBuzz" if number % 15 == 0
3  return "Fizz" if number % 3 == 0
4  return "Buzz" if number % 5 == 0
5
6  number.to_s
7end
1$ rspec
2
3fizzbuzz
4  returns the number as text
5  returns Fizz for multiples of 3
6  returns Buzz for multiples of 5
7  returns FizzBuzz for multiples of both
8
9Finished in 0.00378 seconds (files took 0.33651 seconds to load)
104 examples, 0 failures

Exercise 4: Each requirement became an example, plus the empty-string edge case:

1require_relative "../palindrome"
2
3RSpec.describe "palindrome?" do
4  it "is true for a simple palindrome" do
5    expect(palindrome?("level")).to be true
6  end
7
8  it "is false for a word that is not one" do
9    expect(palindrome?("ruby")).to be false
10  end
11
12  it "ignores capital letters" do
13    expect(palindrome?("Racecar")).to be true
14  end
15
16  it "ignores spaces and punctuation" do
17    expect(palindrome?("A man, a plan, a canal: Panama!")).to be true
18  end
19
20  it "is true for an empty string" do
21    expect(palindrome?("")).to be true
22  end
23end
1def palindrome?(text)
2  letters = text.downcase.gsub(/[^a-z0-9]/, "")
3  letters == letters.reverse
4end
1$ rspec
2
3palindrome?
4  is true for a simple palindrome
5  is false for a word that is not one
6  ignores capital letters
7  ignores spaces and punctuation
8  is true for an empty string
9
10Finished in 0.0033 seconds (files took 0.33578 seconds to load)
115 examples, 0 failures

(The string methods guide covers downcase, gsub and reverse.)

Exercise 5: A table of cases plus the error case. The 21-digit answer for position 100 is no problem for Ruby:

1require_relative "../fibonacci"
2
3RSpec.describe "fibonacci" do
4  { 0 => 0, 1 => 1, 2 => 1, 3 => 2, 10 => 55, 100 => 354_224_848_179_261_915_075 }.each do |position, expected|
5    it "returns #{expected} at position #{position}" do
6      expect(fibonacci(position)).to eq(expected)
7    end
8  end
9
10  it "rejects negative positions" do
11    expect { fibonacci(-1) }.to raise_error(ArgumentError, /negative/)
12  end
13end
1def fibonacci(position)
2  raise ArgumentError, "position must not be negative" if position.negative?
3
4  previous, current = 0, 1
5  position.times { previous, current = current, previous + current }
6  previous
7end
1$ rspec
2
3fibonacci
4  returns 0 at position 0
5  returns 1 at position 1
6  returns 1 at position 2
7  returns 2 at position 3
8  returns 55 at position 10
9  returns 354224848179261915075 at position 100
10  rejects negative positions
11
12Finished in 0.00654 seconds (files took 0.33746 seconds to load)
137 examples, 0 failures

Quiz: (1) Red (write a failing test), green (make it pass simply), refactor (clean up while staying green). (2) A test that has never failed might not be checking anything; seeing it fail proves it can catch the problem. (3) When you're checking something that happens while the code runs, like raise_error or change: RSpec needs the code as a block so it can run it itself. (4) The empty? method: be_xyz calls xyz?. (5) rspec -e "negative".

Cheat sheet

I want to…Write
Install RSpecgem install rspec
Set up a projectrspec --init (creates .rspec and spec/spec_helper.rb)
Group the tests for somethingRSpec.describe "factorial" do … end
Group tests for one situationcontext "with a negative number" do … end
Write one testit "returns 1 for 0" do … end
Check a valueexpect(factorial(0)).to eq(1)
Check the oppositeexpect(value).not_to be_nil
Check for an errorexpect { code }.to raise_error(ArgumentError)
Run all testsrspec (from the folder that holds spec/)
Run one file / one linerspec spec/factorial_spec.rb / rspec spec/factorial_spec.rb:19
Run tests by namerspec -e "negative"
Show test names--format documentation in .rspec

Wrapping up

Testing turns "I think it works" into "I know it works, and I'll know the moment it doesn't":

  • TDD is a loop: red (a failing test), green (the simplest passing code), refactor (clean up while staying green).
  • Let failures guide you. Each message (no method, wrong arguments, wrong value) tells you the next small step.
  • Fake it, then triangulate. Add examples until only the real solution can pass them all.
  • Specs read like sentences: describe what, it does something, expect(actual).to matcher.
  • Tests are a safety net: they let you refactor boldly and catch bugs like the empty-range nil in milliseconds.
  • Think about edge cases: zero, one, huge values, negative or empty input.
  • Unit tests are fast: about 56 microseconds per example in our measurement.

Want more Ruby? Read What Is the Ruby Programming Language?, then Ruby Arrays, Ruby Hashes and Ruby String Methods.

Looking for a reference text for Ruby? Get the Well-Grounded Rubyist.

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.