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.
Thank you so much. You have helped me in my studying of RSpec!