Rails model spec tutorial, part two

by Jason Swett,

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.

  1. How to come up with test cases for a model based on the model’s desired behavior
  2. How to translate those test cases into actual working test code, in a methodical and repeatable manner
  3. 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.

Leave a Reply

Your email address will not be published.