Category Archives: Automated Testing

Taming legacy Ruby code using the “Sprout Method” technique (example 1)

Note: after I wrote this post I came up with a better example, which can be found here.

There are a number of reasons why it can often be difficult to add test coverage to legacy projects.

One barrier is the entanglement of dependencies that stands in the way of instantiating the objects you want to test.

Another barrier is the “wall of code” characteristic often present in legacy code: long, unstructured classes and methods with obfuscated variable names and mysterious behavior. Testing big things is a lot harder than testing small things.

In this example I’m going to demonstrate how to take a long, unstructured method, put tests on it, and then improve its design.

Legacy code example

Here’s a piece of crappy simulated legacy code:

require 'date'

class CreditCard
  def initialize(number, expiration_date, brand)
    @number = number
    @expiration_date = expiration_date
    @brand = brand
  end

  def valid?
    month, year = @expiration_date.split('/').map(&:to_i)
    year += 2000
    if DateTime.now.to_date < Date.new(year, month)
      return false
    else
      if @brand == 'American Express'
        @number.gsub(/\s+/, '').length == 15
      else
        @number.gsub(/\s+/, '').length == 16
      end
    end
  end
end

Why is this code crappy? For one, the `valid?` method is too long to easily be understood at a glance. Secondly, it has a bug. If you give the class an expired card it will still tell you the card is valid.

> cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
 => #<CreditCard:0x007fb8d9825890 @number="3843111122223333", @expiration_date="02/17", @brand="Visa"> 
> cc.valid?
 => true

If we want to fix this code there are a couple different approaches we could take.

Two Possible Approaches

  1. Try to debug the code by tediously running it over and over on the console
  2. Write some tests

The second approach has the benefit of being not only less annoying but also permanent. If we write a test to enforce that the expiration date bug is not present, we can reasonably expect that our test will protect against this bug forever. If we just debug the code in the console, we get no such guarantee.

But it’s not always easy to just go ahead and start writing tests.

Obstacles to Writing Tests

There’s a certain obstacle to testing that’s often present in legacy code. That is, when you have a line or file that’s a bazillion lines long, it’s hard to isolate the piece of code you’re interested in testing. There are often dependencies tangled up in the code. You’re afraid to try to untangle the dependencies because you don’t want to change code you don’t understand. But in order to understand the code, you need to untangle the dependencies. It’s a tough catch-22 type situation.

The Solution

The solution I like to apply to this problem is Michael Feathers’ sprout method technique which he describes in Working Effectively with Legacy Code.

What we’re about to do is:

  1. Write a test around the buggy area—expiration date validation—and watch it fail
  2. Extract the expiration date code into its own method so we can isolate the incorrect behavior
  3. Fix the bug and watch our test pass
  4. Let’s start by writing the first test.

    Writing the First Test

    I’ll write a test that says, “When the credit card’s expiration date is in the past, expect that card not to be valid.”

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    end

    When I run this test it fails, as expected.

    Next I’ll break up this code a little bit by extracting a chunk of it into its own method.

    Extracting Our First Method

    The highlighted chunk below looks like a pretty good candidate to be separated.

    def valid?
      month, year = @expiration_date.split('/').map(&:to_i)
      year += 2000
      if DateTime.now.to_date < Date.new(year, month)
        return false
      else
        if @brand == 'American Express'
          @number.gsub(/\s+/, '').length == 15
        else
          @number.gsub(/\s+/, '').length == 16
        end
      end
    end

    I’m going to extract it into a `number_is_right_length?` method.

    require 'date'
    
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        month, year = @expiration_date.split('/').map(&:to_i)
        year += 2000
        if DateTime.now.to_date < Date.new(year, month)
          return false
        else
          number_is_right_length?
        end
      end
    
      def number_is_right_length?
        if @brand == 'American Express'
          @number.gsub(/\s+/, '').length == 15
        else
          @number.gsub(/\s+/, '').length == 16
        end
      end
    end

    Now that I have this functionality in its own method, I’ll write a test for just that part of the code, in isolation.

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
              expect(cc.number_is_right_length?).to be true
            end
          end
        end
      end
    end

    If I run this test it will pass.

    There’s something I don’t like about this, though. The expiration date of the card doesn’t have anything to do with the validity of the length of the card number. The extra setup data is distracting. A future maintainer might look at the test and assume that the expiration date is necessary.

    Removing Extraneous Setup Data

    It would be nice if we could test just the number length by itself. It would be nice if our test could look like this:

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              valid = CreditCard.number_is_right_length?('Visa', '3843111122223333')
              expect(valid).to be true
            end
          end
        end
      end
    end

    In order to make our new `number_is_right_length?` method more testable in isolation, I’m going to change it from an instance method to a class method. Instead of using the `@brand` and `@number` instance variables, I’m going to use dependency injection so the `number_is_right_length?` doesn’t rely on a `CreditCard` instance having been created first.

    require 'date'
    
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        month, year = @expiration_date.split('/').map(&:to_i)
        year += 2000
        if DateTime.now.to_date < Date.new(year, month)
          return false
        else
          self.class.number_is_right_length?(@brand, @number)
        end
      end
    
      def self.number_is_right_length?(brand, number)
        if brand == 'American Express'
          number.gsub(/\s+/, '').length == 15
        else
          number.gsub(/\s+/, '').length == 16
        end
      end
    end

    Extracting Expiration-Related Code

    Now I’m going to extract the expiration-related code into its own method. That code is highlighted below.

    def valid?
      month, year = @expiration_date.split('/').map(&:to_i)
      year += 2000
      if DateTime.now.to_date < Date.new(year, month)
        return false
      else
        self.class.number_is_right_length?(@brand, @number)
      end
    end

    That chunk of code will go out of the `valid?` method and into a new `expired?` method.

    require 'date'
    
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        if expired?
          false
        else
          self.class.number_is_right_length?(@brand, @number)
        end
      end
    
      def expired?
        month, year = @expiration_date.split('/').map(&:to_i)
        year += 2000
        DateTime.now.to_date < Date.new(year, month)
      end
    
      def self.number_is_right_length?(brand, number)
        if brand == 'American Express'
          number.gsub(/\s+/, '').length == 15
        else
          number.gsub(/\s+/, '').length == 16
        end
      end
    end

    Similar to how I did with the `number_is_right_length?` method, I’ll now write a test for just the `expired?` method in isolation. The main reasons for extracting code into a smaller method are a) smaller methods are easier to understand, b) smaller methods can more easily be given descriptive and appropriate names, further aiding understanding, and c) it’s more manageable to write tests for small methods with few dependencies than large methods with many dependencies.

    Finding and Fixing the Bug

    This test will say, “When the expiration date is in the past, this method should return false.”

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              valid = CreditCard.number_is_right_length?('Visa', '3843111122223333')
              expect(valid).to be true
            end
          end
        end
      end
    
      describe '#expired?' do
        context 'expired' do
          it 'returns true' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).to be_expired
          end
        end
      end
    end

    If I run this test I notice that it does not pass. I’ll write another test for the opposite scenario: when the expiration date is in the future, the method should return true.

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              valid = CreditCard.number_is_right_length?('Visa', '3843111122223333')
              expect(valid).to be true
            end
          end
        end
      end
    
      describe '#expired?' do
        context 'expired' do
          it 'returns true' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).to be_expired
          end
        end
    
        context 'not expired' do
          it 'returns false' do
            cc = CreditCard.new('3843111122223333', '02/30', 'Visa')
            expect(cc).not_to be_expired
          end
        end
      end
    end

    This test also does not pass. So the method returns true when it should return false and false when it should return true. It’s now clear to me where the mistake lies: I put `<` when I should have put `>`!

    Switching from `<` to `>` fixes the bug and makes both tests pass.

    require 'date'
    
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        if expired?
          false
        else
          self.class.number_is_right_length?(@brand, @number)
        end
      end
    
      def expired?
        month, year = @expiration_date.split('/').map(&:to_i)
        year += 2000
        DateTime.now.to_date > Date.new(year, month)
      end
    
      def self.number_is_right_length?(brand, number)
        if brand == 'American Express'
          number.gsub(/\s+/, '').length == 15
        else
          number.gsub(/\s+/, '').length == 16
        end
      end
    end

    But again, I’m not a huge fan of the fact that I have to bring irrelevant setup data into the picture. Card number and brand have no bearing on whether a date has passed or not.

    I can give this method the same treatment as `number_is_right_length?` by making it a class method and adding an `expiration_date` parameter.

    require 'date'
    
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        if self.class.expired?(@expiration_date)
          false
        else
          self.class.number_is_right_length?(@brand, @number)
        end
      end
    
      def self.expired?(expiration_date)
        month, year = expiration_date.split('/').map(&:to_i)
        year += 2000
        DateTime.now.to_date > Date.new(year, month)
      end
    
      def self.number_is_right_length?(brand, number)
        if brand == 'American Express'
          number.gsub(/\s+/, '').length == 15
        else
          number.gsub(/\s+/, '').length == 16
        end
      end
    end

    Now I can change my `expired?` tests to only deal with date and nothing else.

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              valid = CreditCard.number_is_right_length?('Visa', '3843111122223333')
              expect(valid).to be true
            end
          end
        end
      end
    
      describe '#expired?' do
        context 'expired' do
          it 'returns true' do
            expect(CreditCard.expired?('02/17')).to be true
          end
        end
    
        context 'not expired' do
          it 'returns false' do
            expect(CreditCard.expired?('02/30')).to be false
          end
        end
      end
    end

    Future-Proofing

    Lastly, this most recent test has an unacceptable flaw. I’m checking that the card is not expired if the date is February 2030. As of 2018, February 2030 is in the future, but what happens if this program survives until March 2030? My test suite will break even though the code still works.

    So instead of making the dates hard-coded, let’s make them relative to the current date.

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('3843111122223333', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              valid = CreditCard.number_is_right_length?('Visa', '3843111122223333')
              expect(valid).to be true
            end
          end
        end
      end
    
      describe '#expired?' do
        let(:this_year) { Date.today.strftime('%y').to_i }
    
        context 'expired' do
          it 'returns true' do
            expect(CreditCard.expired?("02/#{this_year - 1}")).to be true
          end
        end
    
        context 'not expired' do
          it 'returns false' do
            expect(CreditCard.expired?("02/#{this_year + 1}")).to be false
          end
        end
      end
    end

    The Current State of the Code

    Here’s what our class looks like right now:

    require 'date'
     
    class CreditCard
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
     
      def valid?
        if self.class.expired?(@expiration_date)
          false
        else
          self.class.number_is_right_length?(@brand, @number)
        end
      end
     
      def self.expired?(expiration_date)
        month, year = expiration_date.split('/').map(&:to_i)
        year += 2000
        DateTime.now.to_date > Date.new(year, month)
      end
     
      def self.number_is_right_length?(brand, number)
        if brand == 'American Express'
          number.gsub(/\s+/, '').length == 15
        else
          number.gsub(/\s+/, '').length == 16
        end
      end
    end

    This code actually doesn’t look super great. It’s arguably not even better than what we started with. Here are some things we could do to improve it:

    • Change the `if/else` in `valid?` to use a guard clause
    • DRY up the length checks in `expired?`
    • Add test coverage for everything that’s not covered yet—which is a lot
    • Put the “magic numbers” of 15 and 16 into constants

    I also am not sure I’m a huge fan of the class methods. What would it look like if we changed those back to instance methods?

    The Fully Refactored Version

    Here’s what I came up with after further refactoring:

    require 'date'
    
    class CreditCard
      NUMBER_LENGTH = 16
      NUMBER_LENGTH_AMEX = 15
    
      def initialize(number, expiration_date, brand)
        @number = number
        @expiration_date = expiration_date
        @brand = brand
      end
    
      def valid?
        number_is_right_length? && !expired?
      end
    
      def expired?
        DateTime.now.to_date > Date.new(*expiration_year_and_month)
      end
    
      def expiration_year_and_month
        month, year = @expiration_date.split('/').map(&:to_i)
        [year + 2000, month]
      end
    
      def number_is_right_length?
        stripped_number.length == correct_card_length
      end
    
      def correct_card_length
        if @brand == 'American Express'
          NUMBER_LENGTH_AMEX
        else
          NUMBER_LENGTH
        end
      end
    
      def stripped_number
        @number.gsub(/\s+/, '')
      end
    end

    In addition to the bullet points above, I made some methods smaller by pulling certain functionality out into new ones.

    Here’s the final test suite:

    require 'rspec'
    require './credit_card'
    
    describe CreditCard do
      let(:this_year) { Date.today.strftime('%y').to_i }
      let(:future_date) { "02/#{this_year - 1}" }
      let(:past_date) { "02/#{this_year + 1}" }
    
      describe '#valid?' do
        context 'expired' do
          it 'is not valid' do
            cc = CreditCard.new('1111111111111111', '02/17', 'Visa')
            expect(cc).not_to be_valid
          end
        end
      end
    
      describe '#number_is_right_length?' do
        context 'no spaces' do
          context 'right length' do
            it 'returns true' do
              cc = CreditCard.new('1111111111111111', future_date, 'Visa')
              expect(cc.number_is_right_length?).to be true
            end
          end
        end
    
        context 'American Express' do
          it 'needs to be 15 numbers long' do
            cc = CreditCard.new('111111111111111', future_date, 'American Express')
            expect(cc.number_is_right_length?).to be true
          end
        end
    
        context 'non-American-Express' do
          it 'needs to be 16 numbers long' do
            cc = CreditCard.new('1111111111111111', future_date, 'Visa')
            expect(cc.number_is_right_length?).to be true
          end
        end
      end
    
      describe '#expired?' do
        context 'expired' do
          it 'returns true' do
            cc = CreditCard.new('1111111111111111', future_date, 'Visa')
            expect(cc).to be_expired
          end
        end
    
        context 'not expired' do
          it 'returns false' do
            cc = CreditCard.new('1111111111111111', past_date, 'Visa')
            expect(cc).not_to be_expired
          end
        end
      end
    end

Testing Anti-Pattern: Setup Data Leak

In general it’s better to have tests than to not have tests. Most people agree on this.

But just like poor-quality code, poor-quality tests get hard to maintain over time.

One anti-pattern I see in testing, an anti-pattern that makes tests harder and harder to maintain as the tests grow, is what I’ll call “setup data leak”. Here’s what it looks like.

Let’s imagine we have a `User` class. Maybe the user class can do things related to accounts, roles and posts.

In order to test accounts, roles and posts, we have to create some account, role and post data.

A Bad Test

describe User do
  let(:user) { create(:user) }
  let(:account) { create(:account, user: user) }
  let(:role) { create(:role, user: user) }
  let(:post) { create(:post, user: user) }

  describe 'account-related stuff' do
    # tests go here
  end

  describe 'role-related stuff' do
    # tests go here
  end

  describe 'post-related stuff' do
    # tests go here
  end
end

Why is this test bad? Because it defines all the test data in one place. Why is that bad? Two reasons.

The “Single Glob of Data” Phenomenon

Let’s imagine that this test file grows over time and instead of setting up 4 things at the top, we set up 15. How could anyone be sure which of those 15 things is needed for any particular test? Maybe some tests only need 3 of those 15 things. Maybe other tests need all 15. Maybe still others need 8 of them.

Since we don’t know what’s needed for any particular test, we basically have to assume that everything is needed for every test until we prove otherwise on a case-by-case basis. So our 15 things aren’t 15 things anymore but one single horrifying glob of data.

Mystery Guests

Let’s again imagine that this test file grows over time. Deep within the bowels of one particular test you see `role`. What’s `role`? You look to the immediate vicinity and find nothing. Eventually you see that `role` is defined at the very top of the file even though it’s only used in one place.

A mystery guest is a value that appears in a test case but is defined far away from the test case.

If a value is truly used everywhere then there’s no reason not to define it at the very top of the test. But if it’s only used in one place, there’s no reason not to define it right next to the place it’s used.

A Better Test

describe User do
  let(:user) { create(:user) }

  describe 'account-related stuff' do
    let(:account) { create(:account, user: user) }
    # tests go here
  end

  describe 'role-related stuff' do
    let(:role) { create(:role, user: user) }
    # tests go here
  end

  describe 'post-related stuff' do
    let(:post) { create(:post, user: user) }
    # tests go here
  end
end

This test is better than the first one because there’s no “setup data leak” issue. All the setup data is defined close to where it’s needed. To put it another way, all the setup data is defined in the narrowest scope possible.

Did you find this article useful? See all my Ruby/Rails testing articles.

Ruby Testing Micro-Course

What This Is and Why It Exists

This free “micro-course” is designed to help you get started with testing in Ruby—just Ruby, no Rails.

The idea is that it’s easier to get comfortable with testing Ruby by itself than it would be to try to get started testing Ruby on Rails which involves so many more parts.

Who This Micro-Course Is For

This course is for people who are new to automated testing, but not new to programming altogether. Experience with Ruby will be helpful to you but it’s not necessary.

Start the Course

Enter your name and email below to start the course.

Ruby Testing Micro-Course, Lesson 4

Lesson 1 / Lesson 2 / Lesson 3 / Lesson 4

Review of Lesson 3

In Lesson 3 we added the concept of success or failure to the `check_in_guest` method.

Then I gave you the chance to write a test to ensure that when a guest is checked out the guest’s room gets freed up.

What We’ll Do In Lesson 4

This final lesson will be a short one. I’m going to show you what my test looks like for checking out a guest. Then I’ll give you a suggestion for work you can continue on your own.

Here’s the test I wrote for freeing up a room:

it 'frees up the room' do
  hotel.check_in_guest('Roy Orbison', 302)
  hotel.check_out_guest('Roy Orbison')
  expect(hotel.check_in_guest('George Harrison', 302)).to be true
end

I’m checking Roy Orbison into room 302, then checking him out. Then I’m saying that if I try to check George Harrison into room 302, the check-in should be successful.

If we run the tests, this new test will fail. I’ll show you that in a second. First here’s my test in the context of the full test suite.

describe Hotel do
  let(:hotel) { Hotel.new }

  describe 'checking in a guest' do
    context 'room is available' do
      it 'allows check-in' do
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end

      it "adds the guest to the hotel's guest list" do
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).to include 'George Harrison'
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  describe 'checking out a guest' do
    it "removes the guest from the hotel's guest list" do
      hotel.check_in_guest('Buddy Holly', 303)
      hotel.check_out_guest('Buddy Holly')
      expect(hotel.guests).not_to include 'Buddy Holly'
    end

    it 'frees up the room' do
      hotel.check_in_guest('Roy Orbison', 302)
      hotel.check_out_guest('Roy Orbison')
      expect(hotel.check_in_guest('George Harrison', 302)).to be true
    end
  end
end

And like I said above, if we try to run this, the new test will fail. We can’t check a new guest into the room because currently our `check_out_guest` method doesn’t do anything to free up the room.

$ rspec hotelier_spec.rb 
.....F

Failures:

  1) Hotel checking out a guest frees up the room
     Failure/Error: expect(hotel.check_in_guest('George Harrison', 302)).to be true
     
       expected true
            got false
     # ./hotelier_spec.rb:62:in `block (3 levels) in <top (required)>'

Finished in 0.02631 seconds (files took 0.14313 seconds to load)
6 examples, 1 failure

Failed examples:

rspec ./hotelier_spec.rb:59 # Hotel checking out a guest frees up the room

What does it mean to free up a room, exactly? Well, the place we keep our list of occupied rooms is in the (appropriately-named) `@occupied_rooms` instance variable. To free up room 302, we’d need to remove 302 from `@occupied_rooms`.

So we’ll add `@occupied_rooms.delete(room_number)` to `check_out_guest`. The `check_out_guest` method doesn’t know anything about a `room_number` right now, so we’ll also have to modify the method to take a `room_number` argument.

Since we modified the function signature for `check_out_guest`, we’ll also have to change all the instances where it’s used. All these changes are highlighted below.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
    @occupied_rooms = []
  end

  def check_in_guest(guest_name, room_number)
    return false if @occupied_rooms.include?(room_number)
    @guests << guest_name
    @occupied_rooms << room_number
    true
  end

  def check_out_guest(guest_name, room_number)
    @guests.delete(guest_name)
    @occupied_rooms.delete(room_number)
  end
end

describe Hotel do
  let(:hotel) { Hotel.new }

  describe 'checking in a guest' do
    context 'room is available' do
      it 'allows check-in' do
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end

      it "adds the guest to the hotel's guest list" do
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).to include 'George Harrison'
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  describe 'checking out a guest' do
    it "removes the guest from the hotel's guest list" do
      hotel.check_in_guest('Buddy Holly', 303)
      hotel.check_out_guest('Buddy Holly', 303)
      expect(hotel.guests).not_to include 'Buddy Holly'
    end

    it 'frees up the room' do
      hotel.check_in_guest('Roy Orbison', 302)
      hotel.check_out_guest('Roy Orbison', 302)
      expect(hotel.check_in_guest('George Harrison', 302)).to be true
    end
  end
end

Now, if we run our test suite, everything passes.

$ rspec hotelier_spec.rb
......

Finished in 0.00723 seconds (files took 0.08605 seconds to load)
6 examples, 0 failures

What Now?

Congratulations on completing this micro-course. If you’d like to keep going, here are some ideas for what you can do next.

Separate the test suite from the application code. In this course I put everything in one file for simplicity and ease. That’s not what you’d do for a production application though. Try to separate the application code and the test suite into two different files.

Keep track of room/guest association. Right now we’re not keeping explicit track of which guests are in which rooms. There’s nothing preventing us from, say, checking Roy Orbison into room 302 and then checking him out of room 563. It also feels unnatural that we have to specify a room number when checking out a guest. One would expect the application to keep track of that. See if you can make this happen.

Add payments. What if checking a guest out required the guest to pay? You could also add some simple reporting capabilities. How much revenue has the hotel earned?

Add dates. It would make sense to keep track of the dates when guests check in and check out. If combined with payments and reporting, this would give you the ability to show the hotel’s revenue for a certain time period.

Conclusion

Thanks for taking the time to learn about Ruby testing. If you have any questions, I usually suggest using Stack Overflow, but you’re also welcome to leave a question in the comments.

If you made it this far, I’d encourage you to use the link below to get this full micro-course via email so you have it forever.

Ruby Testing Micro-Course, Lesson 3

Lesson 1 / Lesson 2 / Lesson 3 / Lesson 4

Review of Lesson 2

In Lesson 2 I gave you a chance to compare the test you wrote in Lesson 1 to my version of the same test, and I showed you exactly how I came up with that test. Then we began planning features for checking guests into specific rooms.

Also in Lesson 2, we established that the following things should be true for room functionality:

  1. Add the guest to the hotel’s guest list (we’re already testing for this)
  2. Disallow another guest from checking into that same room
  3. Decrease the total number of available rooms

What We’ll Do In Lesson 3

In Lesson 3 we’re going to write a test that ensures we don’t allow a guest to check into a room that’s already checked out by another guest.

Then I’ll give you a chance to write your own test that says, “When we check a guest out of a room, that room should get freed up.”

Test for Room Availability

Right now our `check_in_guest` method takes whatever `guest_name` we give it and happily adds that `guest_name` onto the `@guests` array. There’s no concept of success or failure. The `check_in_guest` method just always works, regardless of whether it really should work or not.

Let’s change this. Let’s make it so if we try to check a new guest into a room that’s already checked out by another guest, `check_in_guest` will return `false`.

And of course, `check_in_guest` should return `true` if the room is free and the new guest was successfully checked in.

We’ll begin by adding two test cases: one for the “room is available” case and one for the “room is not available” case.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name, room_number)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  describe 'checking in a guest' do
    it "adds the guest to the hotel's guest list" do
      hotel = Hotel.new
      hotel.check_in_guest('George Harrison', 302)
      expect(hotel.guests).to include 'George Harrison'
    end

    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

When we run our test suite, both of our new tests will fail. We’re expecting `true` and `false` but `check_in_guest` doesn’t return `true` or `false`. It returns the value of `@guests`.

rspec hotelier_spec.rb                                             
..FF                                           

Failures:                                      

  1) Hotel checking in a guest room is available allows check-in                              
     Failure/Error: expect(hotel.check_in_guest('George Harrison', 302)).to be true           
                                               
       expected true                           
            got #<Array:70174165542720> => ["George Harrison"]                                
     # ./hotelier_spec.rb:30:in `block (4 levels) in <top (required)>'                        

  2) Hotel checking in a guest room is not available disallows check-in                       
     Failure/Error: expect(hotel.check_in_guest('George Harrison', 302)).to be false          
                                               
       expected false                          
            got #<Array:70174163166020> => ["Roy Orbison", "George Harrison"]                 
     # ./hotelier_spec.rb:38:in `block (4 levels) in <top (required)>'                        

Finished in 0.02482 seconds (files took 0.13683 seconds to load)                              
4 examples, 2 failures                         

Failed examples:                               

rspec ./hotelier_spec.rb:28 # Hotel checking in a guest room is available allows check-in     
rspec ./hotelier_spec.rb:35 # Hotel checking in a guest room is not available disallows check-in   

Let’s make `check_in_guest` return something closer to what our tests our expecting.

This might seem like a silly move but we can make one of the two tests pass by simply hard-coding a return value of `true` for `check_in_guest`.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name, room_number)
    @guests << guest_name
    true
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  describe 'checking in a guest' do
    it "adds the guest to the hotel's guest list" do
      hotel = Hotel.new
      hotel.check_in_guest('George Harrison', 302)
      expect(hotel.guests).to include 'George Harrison'
    end

    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Now we only have one test failure instead of two.

rspec hotelier_spec.rb                                             
...F                                           

Failures:                                      

  1) Hotel checking in a guest room is not available disallows check-in                       
     Failure/Error: expect(hotel.check_in_guest('George Harrison', 302)).to be false          
                                               
       expected false                          
            got true                           
     # ./hotelier_spec.rb:39:in `block (4 levels) in <top (required)>'                        

Finished in 0.0173 seconds (files took 0.0875 seconds to load)                                
4 examples, 1 failure                          

Failed examples:                               

rspec ./hotelier_spec.rb:36 # Hotel checking in a guest room is not available disallows check-in

Again, this might seem like a silly thing to have done but I like to do stuff like this when I’m developing tests. It gives me one less thing to think about when I’m working on the next step. There’s also just something nice and neat about “expected false and got true” versus “expected false and got some crazy unexpected value”.

Returning False If Room Is Occupied

Now we have to do the “harder” work of making `check_in_guest` actually return false if the room is occupied.

Right now a `Hotel` instance doesn’t have a concept of occupied rooms at all. All it knows is which guests are checked into the hotel.

Let’s add a list of occupied rooms to our class. When we check in a guest, `check_in_guest` will now not only add `guest_name` to our list of guests but it will add `room_number` to a list of occupied rooms.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
    @occupied_rooms = []
  end

  def check_in_guest(guest_name, room_number)
    @guests << guest_name
    return false if @occupied_rooms.include?(room_number)
    @occupied_rooms << room_number
    true
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

Now all our tests will pass.

$ rspec hotelier_spec.rb 
....

Finished in 0.00483 seconds (files took 0.08766 seconds to load)
4 examples, 0 failures

Here’s the full `hotelier_spec.rb` file in case you got lost in the course of following the above steps.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
    @occupied_rooms = []
  end

  def check_in_guest(guest_name, room_number)
    @guests << guest_name
    return false if @occupied_rooms.include?(room_number)
    @occupied_rooms << room_number
    true
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  describe 'checking in a guest' do
    it "adds the guest to the hotel's guest list" do
      hotel = Hotel.new
      hotel.check_in_guest('George Harrison', 302)
      expect(hotel.guests).to include 'George Harrison'
    end

    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Bug: Guest Will Get Added Even If Room Is Occupied

You might have noticed an issue with our latest version of `check_in_guest`.

def check_in_guest(guest_name, room_number)
  @guests << guest_name
  return false if @occupied_rooms.include?(room_number)
  @occupied_rooms << room_number
  true
end

Notice how it adds `guest_name` to `@guests` before it checks to see whether the room is actually available or not.

I added this mistake intentionally to show that our current test suite is not as complete as it needs to be.

Let’s fix this bug, but first let’s add a test that ensures that attempting to check in a guest to an unavailable room does not add that guest to the hotel’s guest list.

describe Hotel do
  describe 'checking in a guest' do
    it "adds the guest to the hotel's guest list" do
      hotel = Hotel.new
      hotel.check_in_guest('George Harrison', 302)
      expect(hotel.guests).to include 'George Harrison'
    end

    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Our test will of course fail because we haven’t fixed `check_in_guest` yet.

rspec hotelier_spec.rb:45
Run options: include {:locations=>{"./hotelier_spec.rb"=>[45]}}
F

Failures:

  1) Hotel checking in a guest room is not available does not add the guest to the hotel's guest list
     Failure/Error: expect(hotel.guests).not_to include 'George Harrison'
       expected ["Roy Orbison", "George Harrison"] not to include "George Harrison"
     # ./hotelier_spec.rb:49:in `block (4 levels) in <top (required)>'

Finished in 0.01358 seconds (files took 0.09659 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./hotelier_spec.rb:45 # Hotel checking in a guest room is not available does not add the guest to the hotel's guest list

Now let’s fix `check_in_guest` by moving the “is this room occupied?” check to be on the first line.

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
    @occupied_rooms = []
  end

  def check_in_guest(guest_name, room_number)
    return false if @occupied_rooms.include?(room_number)
    @guests << guest_name
    @occupied_rooms << room_number
    true
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

Now all five of our tests pass.

rspec hotelier_spec.rb                                             
.....                                          

Finished in 0.00603 seconds (files took 0.09014 seconds to load)                              
5 examples, 0 failures       
describe Hotel do
  describe 'checking in a guest' do
    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end

      it "adds the guest to the hotel's guest list" do
        hotel = Hotel.new
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).to include 'George Harrison'
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Here’s the full `hotelier_spec.rb` file for reference.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
    @occupied_rooms = []
  end

  def check_in_guest(guest_name, room_number)
    return false if @occupied_rooms.include?(room_number)
    @guests << guest_name
    @occupied_rooms << room_number
    true
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  describe 'checking in a guest' do
    it "adds the guest to the hotel's guest list" do
      hotel = Hotel.new
      hotel.check_in_guest('George Harrison', 302)
      expect(hotel.guests).to include 'George Harrison'
    end

    context 'room is available' do
      it 'allows check-in' do
        hotel = Hotel.new
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel = Hotel.new
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Refactoring

Our test suite is a little repetitive. We have `hotel = Hotel.new` all over the place. Let’s make our tests a little more DRY.

describe Hotel do
  let(:hotel) { Hotel.new }

  describe 'checking in a guest' do
    context 'room is available' do
      it 'allows check-in' do
        expect(hotel.check_in_guest('George Harrison', 302)).to be true
      end

      it "adds the guest to the hotel's guest list" do
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).to include 'George Harrison'
      end
    end

    context 'room is not available' do
      it 'disallows check-in' do
        hotel.check_in_guest('Roy Orbison', 302)
        expect(hotel.check_in_guest('George Harrison', 302)).to be false
      end

      it "does not add the guest to the hotel's guest list" do
        hotel.check_in_guest('Roy Orbison', 302)
        hotel.check_in_guest('George Harrison', 302)
        expect(hotel.guests).not_to include 'George Harrison'
      end
    end
  end

  it 'can check a guest out' do
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Exercise: Freeing Up a Room

If checking a guest into a room makes that room unavailable, checking a guest out of a room should make that room available again. Right now our code doesn’t do that.

See if you can write a test that ensures that checking out a guest frees up that room. Feel free to also write the code that makes that test pass, but not before you write the test.

I’ll see you in the final lesson, Lesson 4, where you can see how I wrote my test for freeing up a room.

Continue to Lesson 4 >>>

Ruby Testing Micro-Course, Lesson 2

Lesson 1 / Lesson 2 / Lesson 3 / Lesson 4

Review of Lesson 1

At the end of Lesson 1 you were asked to write a test for checking a guest out.

If you haven’t completed Lesson 1, go back and complete it before you proceed, or at least try. Don’t feel bad if you tried and failed to come up with a test in Lesson 1. I’m about to show you my test and how I came up with it. You’ll have more chances soon to make another attempt.

What We’ll Do In Lesson 2

In Lesson 2 I’m going to let you compare your test to mine. Then you’ll have a chance to see how I came up with the test I wrote.

Then we’ll start to add a new feature to our hotel application, room numbers.

Compare Your Test to Mine

Here’s the test (and new application code) I wrote for checking out a guest. The new code is highlighted.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison')
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly')
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Now let me show you the steps I took to come up with this test.

How I Got Here

Before I wrote any code I asked myself, “How would I verify that a check-out feature is working?”

My answer was that I would check a guest in, then check that same guest back out. The hotel should no longer include that guest in its guest list.

Then I translated this sequence of actions—check a guest in, check a guest out, check for that guest—into code. Below is what I added.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name)
    guests << guest_name
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison')
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly')
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Then I ran the test, knowing it wouldn’t work. Here’s what I got.

$ rspec hotelier_spec.rb
.F

Failures:

  1) Hotel can check a guest out
     Failure/Error: hotel.check_out_guest('Buddy Holly')
     
     NoMethodError:
       undefined method `check_out_guest' for #<Hotel:0x007f8b3e021d60 @guests=["Buddy Holly"]>
       Did you mean?  check_in_guest
     # ./hotelier_spec.rb:25:in `block (2 levels) in <top (required)>'

Finished in 0.00649 seconds (files took 0.13307 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./hotelier_spec.rb:22 # Hotel can check a guest out

The test of course didn’t work. The test didn’t even fail, it errored. My next step was to simply get the test to stop erroring.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison')
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly')
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Here’s what this test run looks like:

$ rspec hotelier_spec.rb
.F

Failures:

  1) Hotel can check a guest out
     Failure/Error: expect(hotel.guests).not_to include 'Buddy Holly'
       expected ["Buddy Holly"] not to include "Buddy Holly"
     # ./hotelier_spec.rb:29:in `block (2 levels) in <top (required)>'

Finished in 0.02465 seconds (files took 0.0844 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./hotelier_spec.rb:25 # Hotel can check a guest out

The test no longer errors out. It just fails.

If we were to translate this test failure to English it might be something like “I expected the list of guests not to include Buddy Holly, but it did include Buddy Holly”.

Now all that’s left to do is make the test pass.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison')
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly')
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

Now the test will pass.

$ rspec hotelier_spec.rb
..

Finished in 0.00467 seconds (files took 0.0835 seconds to load)
2 examples, 0 failures

What’s Next

So far you’ve gotten a chance to try writing your own test. Then you watched me write a test for the same feature.

Next I’m going to let you watch me develop a new feature and write tests at the same time. Then I’ll give you a chance to try writing another test of your own.

Room Numbers

In a real hotel you don’t just check into the hotel generally. You of course check into a specific room.

Let’s add some room functionality to our application.

I’m going to begin adding room functionality using coding by wishful thinking. In other words, I’ll ask myself, “What code do I wish I could use to check in a guest?” Below is what I came up with.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison', 302)
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly')
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

The `check_in_guest` method currently doesn’t take a second argument, so one of my tests will fail.

$ rspec hotelier_spec.rb
F.

Failures:

  1) Hotel can check a guest in
     Failure/Error:
       def check_in_guest(guest_name)
         @guests << guest_name
       end
     
     ArgumentError:
       wrong number of arguments (given 2, expected 1)
     # ./hotelier_spec.rb:10:in `check_in_guest'
     # ./hotelier_spec.rb:22:in `block (2 levels) in <top (required)>'

Finished in 0.00386 seconds (files took 0.08159 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./hotelier_spec.rb:20 # Hotel can check a guest in

The way to make this test pass of course is to make the `check_in_guest` method take another argument. I’ll also have to change my other test’s use of `check_in_guest` to specify a room number.

require 'rspec'

class Hotel
  attr_accessor :guests

  def initialize
    @guests = []
  end

  def check_in_guest(guest_name, room_number)
    @guests << guest_name
  end

  def check_out_guest(guest_name)
    @guests.delete(guest_name)
  end
end

describe Hotel do
  it 'can check a guest in' do
    hotel = Hotel.new
    hotel.check_in_guest('George Harrison', 302)
    expect(hotel.guests).to include 'George Harrison'
  end

  it 'can check a guest out' do
    hotel = Hotel.new
    hotel.check_in_guest('Buddy Holly', 303)
    hotel.check_out_guest('Buddy Holly')
    expect(hotel.guests).not_to include 'Buddy Holly'
  end
end

This new specifying-a-room-number thing technically works in the sense that the tests pass, but it’s a little unsatisfying. As of right now, specifying a room doesn’t actually do anything.

It seems like checking a guest into a room should do at least three things:

  1. Add the guest to the hotel’s guest list (we’re already testing for this)
  2. Disallow another guest from checking into that same room
  3. Decrease the total number of available rooms

We’ll address these things in Lesson 3.

Continue to Lesson 3 >>>

How to get started with Rails testing

Where to start with Rails testing

Getting started with Rails testing can be overwhelming because there’s so much to learn. I think the learning process can be made easier by following the following three steps.

Get familiar with testing principles

In order to write tests in Rails it’s necessary to have some understanding of the tools that are used for Rails testing. But also, perhaps more than that, you need to have an understanding of the technology-agnostic principles that apply no matter what language or framework you’re using.

The principles of automated testing are quite numerous. Just as you could spend a lifetime studying programming and never run out of things to learn, you could probably spend a lifetime studying testing and never run out of things to learn.

So the trick early on is to find the few testing principles that you need in order to get started and ignore all the rest. You can learn other principles as you go.

Here are a few principles I think are helpful to be familiar with at the beginning.

Test-driven development

I personally think TDD is life-changingly great. At the same time, I wouldn’t recommend being concerned with following TDD to the letter when you’re first getting started with testing.

The fundamental principle of TDD is the red-green-refactor loop. First you write a failing test for the behavior you want to bring into existence. Then you write the code to make it pass, only concerning yourself with making the test pass and not with code quality at all. Then, once the test is passing, go back to the code and refactor it to make it nice.

When I was getting started with testing I had many occasions where I wanted to write the test first but I wasn’t sure how. What should you do if that happens to you? My advice would be to give yourself permission to break the TDD “rules” and just write the code before the test. As you gain more experience with testing it will get easier and easier to write the test first.

Four-phase testing

Automated tests tend to consist of four steps: setup, exercise, verification and teardown. Here’s an example in MiniTest.

class UserTest < Minitest::Test
  def test_soft_delete_user
    user = User.create!(email: 'test@example.com') # setup
    user.update_attributes!(active: false)         # exercise
    assert user.active == false                    # assertion
    user.destroy!                                  # teardown
  end
end

In the first step, setup, we create the data that the test needs in order to do its stuff.

In the second step, exercise, we walk the system under test* through the steps that are necessary to get the system into the state we’re interested in.

In the third step, assertion, we ask, “Did the code do the thing it was expected to do?”

Finally, in the teardown, we put the system back the way we found it. (In Rails testing an explicit teardown step is usually not necessary because tests are often run inside of database transactions. The data doesn’t have to get deleted because it never gets persisted in the first place.)

*System under test (SUT) is a fancy way of saying “the part of the application we’re testing”.

Test independence/deterministic tests

It’s important that when a test runs, we get the same result every time. The passing or failing of the test shouldn’t depend on things like the date when the test was run or whether a certain other test was run before it or not.

That’s why the teardown step above is important. If a test leaves behind data after it runs, that data has the potential to interfere with another test that runs after it. Again, in Rails, developers tend not to explicitly destroy each test’s data but rather make use of database transactions to avoid persisting each test’s data in the first place. The transaction starts at the beginning of each test and at the end of each test the transaction is aborted.

Fixtures and factories

The first step of a test, setup, can get quite tedious and repetitive. There are easier ways of bringing the test data into existence than by instantiating objects and creating database records at the beginning of every single test.

There are two ways that setting up test data is normally handled: factories and fixtures. Both strategies have pros and cons. Most projects I’ve worked on use either one or the other but there’s no reason they couldn’t be used together.

Fixtures in Rails are usually defined in terms of YML files. The fixture data is persisted once at the beginning of the test suite run. Each test still runs in a database transaction so that any data modifications the test makes will be undone before the next test is run.

Fixtures have the advantage of speed, although the trade-off is that the data setup is a little bit distant from the test code itself. It might not always be clear what the data is that a test depends on or where it’s coming from.

With factories, the data for each test is defined inside the test code itself instead of in a separate YML file. Factories have the advantage of clarity. If the tests were written well then it’s very clear what data the test depends on to run. The trade-off is speed.

Mocks and stubs

I bring up mocks and stubs to tell you that you can safely ignore them when you’re getting started.

You can get quite a ways with Rails testing before a lack of mock and stub knowledge will hinder you. But if you’re curious, the books xUnit Patterns and Growing Object-Oriented Software, Guided by Tests do a pretty good job of explaining the concepts.

Get familiar with Rails testing tooling

Testing frameworks

The two dominant testing frameworks for Rails are RSpec and MiniTest. RSpec seems to be much more popular although that certainly doesn’t mean it’s better.

“Should I use RSpec or MiniTest?” is a question I see lot. Luckily you can’t really choose wrong. The underlying testing principles are pretty much the same whether you use RSpec or MiniTest. The main difference between the two is that RSpec is built on a domain-specific language (DSL) while MiniTest is just Ruby. A lot of people find that RSpec’s DSL improves readability although the tradeoff is that you have to learn the DSL.

I personally started out with Test::Unit (very similar to MiniTest) when I was doing Rails projects on my own. Later I switched to RSpec because almost every job or freelance client I’ve had used RSpec. So even if you pick the “wrong” testing framework to start with, you can always switch to the other one later.

Since I’m an RSpec guy I’ll focus mainly on RSpec-related tools.

Factory Bot

Factory Bot is the de-facto standard library for Rails. The main benefits of Factory Bot in my view are that it helps DRY up test setup code and that it eliminates the need to come up with arbitrary test data (e.g. fake names, addresses, etc.).

Faker

Factory Bot only goes so far with its ability to generate fake data. If I want to generate things like fake but valid email addresses, phone numbers, etc., I use Faker in conjunction with Factory Bot.

Capybara

Capybara is a tool that lets us use Ruby to drive the browser. This enables us to write tests that exercise the whole application stack including HTML, CSS and JavaScript.

Build some practice projects

Just like with programming itself, the way to get good at testing is to just start doing it.

Here’s the way I generally go about developing a new feature or resource in Rails. (I do a Git commit after each step if not more often.)

  1. Run rails generate scaffold <scaffold name> <options>
  2. Write some validation specs for the model I just created
  3. Write a feature spec for the “happy path” (i.e. all valid inputs) of the “create” and “update” functionality for the resource I just created

For the first few models I create in a Rails project I might not do much more than that. It’s usually not until a few models in that my application builds behavior that’s “interesting” enough to write a meaningful test for.

If you want to learn more about how I write Rails tests, I run free online Rails testing workshops about once a week. All the videos from previous sessions are up on YouTube (linked here). I also have a free Ruby testing micro-course.

RSpec Hello World

class Squarer
  def square(starting_number)
    starting_number * starting_number
  end
end

describe Squarer do
  it 'square a number' do
    squarer = Squarer.new
    expect(squarer.square(2)).to eq(4)
  end
end

You can run this test by naming the file `squarer_spec.rb` and running `rspec squarer_spec.rb` on the command line.

If you don’t already have RSpec installed you can install it by running `gem install rspec`.