Prerequisites
If you’d like to follow along in this tutorial, I recommend first setting up a Rails application according to my how I set up a Rails application for testing
post. (To make things easier, you could use Instant Rails, a tool I created for generating Rails applications.) It doesn’t matter what you call the application.
The tutorial
Learning objectives
Before we list the learning objectives for Part Two of the tutorial, let’s review the learning objectives for Part One.
- How to come up with test cases for a model based on the model’s desired behavior
- How to translate those test cases into actual working test code, in a methodical and repeatable manner
- How to use a test-first approach to make it easier both to write the tests and to write the application code
In Part One we dealt with plain old Ruby objects (POROs) rather than actual Rails models. Without having done that, it might be unclear where the testing principles stopped and the Rails-specific work began.
In Part Two of the tutorial we’ll layer on the Rails work so you can easily tell which parts are which.
The scenario
We’ll be working on the exact same scenario as Part One: normalizing messy phone numbers. We’ll even be using all the exact same test cases. The reason we’re keeping those things the same is to show the Rails-models-versus-POROs differences in sharp relief.
The PhoneNumber model
As mentioned in the Prerequisites section, you’ll need to create a fresh Rails according to my how I set up a Rails application for testing
post.
Once we’ve done that, we can generate a new model called PhoneNumber
.
$ rails g model phone_number value:string
$ rails db:migrate
This will automatically generate a test file at spec/models/phone_number_spec.rb
.
# spec/models/phone_number_spec.rb
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end
Our first test case
Just like the first test case in Part One, the first test here will verify that a number like 555-856-8075
gets stripped down to 5558568075
.
Unlike the test in Part One where instantiating a PhoneNumber
object just involved doing PhoneNumber.new
, we’ll be using Factory Bot in this test to create an instance of the PhoneNumber
model. We could have gotten away with just doing PhoneNumber.new(value: "555-856-8075")
instead of using Factory Bot in this instance, but using Factory Bot is so common in Rails model tests that I wanted to show an example using it.
# app/models/phone_number.rb
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
context "phone number contains dashes" do
it "strips out the dashes" do
phone_number = FactoryBot.create(
:phone_number,
value: "555-856-8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
end
If we run this test it will fail because we haven’t yet added any code to strip out dashes.
Failures:
1) PhoneNumber phone number contains dashes strips out the dashes
Failure/Error: expect(phone_number.value).to eq("5558568075")
expected: "5558568075"
got: "555-856-8075"
(compared using ==)
Let’s strip out the non-numeric characters via a before_validation
callback.
# app/models/phone_number.rb
class PhoneNumber < ApplicationRecord
before_validation :strip_non_numeric_from_value
def strip_non_numeric_from_value
self.value = self.value.gsub(/\D/, "")
end
end
If we run the test again, it passes.
The other two formats
Now let’s add a test scenario for the format of (555) 856-8075
.
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
context "phone number contains dashes" do
it "strips out the dashes" do
phone_number = FactoryBot.create(
:phone_number,
value: "555-856-8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains parentheses" do
it "strips out the non-numeric characters" do
phone_number = FactoryBot.create(
:phone_number,
value: "(555) 856-8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
end
If we run this test, we’ll see that it already passes, thanks to the before_validation
callback we added above.
Let’s now add the final format, +1 555 856 8075
.
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
context "phone number contains dashes" do
it "strips out the dashes" do
phone_number = FactoryBot.create(
:phone_number,
value: "555-856-8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains parentheses" do
it "strips out the non-numeric characters" do
phone_number = FactoryBot.create(
:phone_number,
value: "(555) 856-8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains country code" do
it "strips out the country code" do
phone_number = FactoryBot.create(
:phone_number,
value: "+1 555 856 8075"
)
expect(phone_number.value).to eq("5558568075")
end
end
end
This one does not pass. Even though we’re stripping out non-numeric characters, we’re not stripping out country codes.
Failures:
1) PhoneNumber phone number contains country code strips out the country code
Failure/Error: expect(phone_number.value).to eq("5558568075")
expected: "5558568075"
got: "15558568075"
(compared using ==)
We can make this test pass using the same exact code we used in Part One.
class PhoneNumber < ApplicationRecord
before_validation :strip_non_numeric_from_value
def strip_non_numeric_from_value
self.value = self.value.gsub(/\D/, "")
.split("")
.last(10)
.join
end
end
And also just like in Part One, we don’t want the magic number of 10
sitting there. Let’s assign that number to a constant.
class PhoneNumber < ApplicationRecord
EXPECTED_NUMBER_OF_DIGITS = 10
before_validation :strip_non_numeric_from_value
def strip_non_numeric_from_value
self.value = self.value.gsub(/\D/, "")
.split("")
.last(EXPECTED_NUMBER_OF_DIGITS)
.join
end
end
Refactoring
There’s a small way we can make our test a little tidier.
Rather than repeatedly creating a new phone_number
variable using FactoryBot.create
, we can DRY up our code a little by putting the FactoryBot.create
in a let!
block at the beginning and then updating the phone number value for each test.
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
let!(:phone_number) do
FactoryBot.create(:phone_number)
end
context "phone number contains dashes" do
before { phone_number.update!(value: "555-856-8075") }
it "strips out the dashes" do
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains parentheses" do
before { phone_number.update!(value: "(555) 856-8075") }
it "strips out the non-numeric characters" do
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains country code" do
before { phone_number.update!(value: "+1 555 856 8075") }
it "strips out the country code" do
expect(phone_number.value).to eq("5558568075")
end
end
end
Takeaways
Rails model tests can be written by coming up with a list of desired behaviors and translating that list into test code.
When learning how to write Rails model tests, it can be helpful to first do some tests with plain old Ruby objects (POROs) for practice.
Writing tests before we write the application code can make the process of writing the application code easier.