How I write model tests

by Jason Swett,

I recently read a post on /r/ruby that asked for RSpec model testing best practices. I gave an answer, but also wanted to give some concrete examples, so here we go.

What follows is how I’d write a spec for a new model. I actually took a model from an existing application I wrote and just blew away the code so I could start over from scratch.

Once I got a ways into this post I realized that it was getting pretty long and I still hadn’t gotten very far into the “meat” of how I’d write a model spec. So I intend to write a follow-up post that goes more in-depth.

Starting point

Here’s what my `Restaurant` model and test look like when they’re empty.

class Restaurant < ApplicationRecord
end
require 'rails_helper'

RSpec.describe Restaurant, type: :model do
end

And just so you can see what the `Restaurant` class’s attributes are, here’s a snippet of `db/schema.rb`. Most of these attributes won’t come into the picture. We’ll mostly just deal with `name` and `phone`.

create_table "restaurants", force: :cascade do |t|
  t.string "name", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.string "phone", null: false
  t.integer "business_model_id", null: false
  t.index ["business_model_id"], name: "index_restaurants_on_business_model_id"
  t.index ["name"], name: "index_restaurants_on_name", unique: true
  t.index ["phone"], name: "index_restaurants_on_phone", unique: true
end

The first spec

To me the most natural thing to test is the presence of the restaurant name. I’ll write a failing test for presence of name using Should Matchers.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
end

When I run this spec, it fails, as I expect.

rspec spec/models/restaurant_spec.rb    
F                                  

Failures:                          

  1) Restaurant should validate that :name cannot be empty/falsy       
     Failure/Error: it { is_expected.to validate_presence_of(:name) }  
                                   
       Restaurant did not properly validate that :name cannot be empty/falsy.                                                                  
         After setting :name to ‹nil›, the matcher expected the Restaurant to                                                                  
         be invalid, but it was valid instead.                         
     # ./spec/models/restaurant_spec.rb:4:in `block (2 levels) in <top (required)>'                                                            

Finished in 0.29263 seconds (files took 1.34 seconds to load)          
1 example, 1 failure               

Failed examples:                   

rspec ./spec/models/restaurant_spec.rb:4 # Restaurant should validate that :name cannot be empty/falsy

I add the name validator to make the spec pass.

class Restaurant < ApplicationRecord
  validates :name, presence: true
end

And indeed, the spec now passes.

rspec spec/models/restaurant_spec.rb
.

Finished in 0.2793 seconds (files took 1.32 seconds to load)
1 example, 0 failures

Spec for phone presence

Now that name presence is taken care of I turn my attention to phone. The spec in this case is the exact same.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_presence_of(:phone) }
end

I run the spec and it fails. I then add a presence validator:

class Restaurant < ApplicationRecord
  belongs_to :business_model
  validates :name, presence: true
  validates :phone, presence: true
end

The spec passes now.

Phone number format validity

Unlike restaurant name, which could be pretty much anything, the phone number has to have a valid format. For example, “123” of course isn’t a valid phone number. I add a failing test for this case. I’m actually not sure what I expect the error message to be, so I just put “invalid format”. After I run the spec I can update the error message in my test to match the actual error message.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_presence_of(:phone) }

  describe 'when phone number is too short' do
    it 'is not valid' do
      restaurant = build(:restaurant, phone: '123')
      restaurant.valid?
      expect(restaurant.errors[:phone]).to include('invalid format')
    end
  end
end

As I expect, this test fails.

rspec spec/models/restaurant_spec.rb    
..F                                

Failures:                          

  1) Restaurant when phone number is too short is not valid            
     Failure/Error: expect(restaurant.errors[:phone]).to include('invalid format')                                                             
       expected [] to include "invalid format"                         
     # ./spec/models/restaurant_spec.rb:11:in `block (3 levels) in <top (required)>'                                                           

Finished in 0.33318 seconds (files took 1.31 seconds to load)          
3 examples, 1 failure              

Failed examples:                   

rspec ./spec/models/restaurant_spec.rb:8 # Restaurant when phone number is too short is not valid

Now I add a format validator using a regex I found on the internet.

class Restaurant < ApplicationRecord
  belongs_to :business_model
  validates :name, presence: true
  validates :phone, presence: true, format: {
    with: /\A(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\z/
  }
end

The spec now fails because the expected error message, “invalid format” doesn’t match the actual error message, “is invalid”.

rspec spec/models/restaurant_spec.rb
..F

Failures:

  1) Restaurant when phone number is too short is not valid
     Failure/Error: expect(restaurant.errors[:phone]).to include('invalid format')
       expected ["is invalid"] to include "invalid format"
     # ./spec/models/restaurant_spec.rb:11:in `block (3 levels) in <top (required)>'

Finished in 0.40668 seconds (files took 2.03 seconds to load)
3 examples, 1 failure

Failed examples:

rspec ./spec/models/restaurant_spec.rb:8 # Restaurant when phone number is too short is not valid

So I update my expected error message.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_presence_of(:phone) }

  describe 'when phone number is too short' do
    it 'is not valid' do
      restaurant = build(:restaurant, phone: '123')
      restaurant.valid?
      expect(restaurant.errors[:phone]).to include('is invalid')
    end
  end
end

And now the spec passes.

rspec spec/models/restaurant_spec.rb    
...                                

Finished in 0.38825 seconds (files took 2.03 seconds to load)          
3 examples, 0 failures         

Tests for other phone cases

By adding this phone regex I’ve actually broken a rule of TDD: only write enough code to make the test pass. If you only write enough code to make the test pass, you know that all the code you’ve written is covered by tests.

In this case I “wrote some code” (copy/pasted a regex from Stack Overflow) that didn’t have tests. So I’m going to go back and add more test cases. One case will say, “when the phone number is valid, the restaurant object should be valid”. The other will say “when the phone number is all numbers, the restaurant object should not be valid”.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_presence_of(:phone) }

  describe 'phone' do
    describe 'when phone number is valid' do
      it 'is valid' do
        restaurant = build(:restaurant, phone: '(555) 555-5555')
        expect(restaurant).to be_valid
      end
    end

    describe 'when phone number is too short' do
      it 'is not valid' do
        restaurant = build(:restaurant, phone: '123')
        restaurant.valid?
        expect(restaurant.errors[:phone]).to include('is invalid')
      end
    end

    describe 'when phone number is all letters' do
      it 'is not valid' do
        restaurant = build(:restaurant, phone: '(AAA) AAA-AAAA')
        restaurant.valid?
        expect(restaurant.errors[:phone]).to include('is invalid')
      end
    end
  end
end

These tests all pass. There’s a little duplication in my test, though, so I’m going to refactor.

require 'rails_helper'

RSpec.describe Restaurant, type: :model do
  it { is_expected.to validate_presence_of(:name) }
  it { is_expected.to validate_presence_of(:phone) }

  describe 'phone' do
    let(:restaurant) { build(:restaurant) }

    describe 'when phone number is valid' do
      it 'is valid' do
        restaurant.phone = '(555) 555-5555'
        expect(restaurant).to be_valid
      end
    end

    describe 'when phone number is too short' do
      it 'is not valid' do
        restaurant.phone = '123'
        restaurant.valid?
        expect(restaurant.errors[:phone]).to include('is invalid')
      end
    end

    describe 'when phone number is all letters' do
      it 'is not valid' do
        restaurant.phone = '(AAA) AAA-AAAA'
        restaurant.valid?
        expect(restaurant.errors[:phone]).to include('is invalid')
      end
    end
  end
end

All the tests still pass.

rspec spec/models/restaurant_spec.rb    
.....                              

Finished in 0.30868 seconds (files took 1.37 seconds to load)          
5 examples, 0 failures

To be continued

What I’ve written in the post is representative of the start of what I’d put in a model test but it’s certainly not the whole thing. What if my model contains some non-trivial methods? This post is already getting kind of long, so I plan to continue this in a Part 2.

Leave a Reply

Your email address will not be published. Required fields are marked *