Rails model spec tutorial, part one

by Jason Swett,

Overview

Because there are so many tools and concepts involved in Rails model testing, I’ve separated this beginner tutorial into two parts.

Part One will deal with writing tests just for plain old Ruby objects (POROs) in order to get comfortable with the process without bringing specifics of Rails into the picture.

Part Two will repeat what was done in Part One but in the context of an actual Rails model instead of a PORO.

Before we get into the tutorial itself we’ll discuss a little bit of necessary context: what a model is and how model specs are different from other types of specs.

What a model is

It may seem obvious what a Rails model is. To many Rails developers, the model is the MVC layer that talks to the database.

But in my experience, there are actually a lot of different conceptions as to what Rails models are, and not all of them agree with each other. I think it’s important for us to firmly establish what a Rails model is before we start talking about how to test Rails models.

To me, a model is an abstraction that represents a small corner of reality in a simplified way. Models exist to make it easier for a programmer to think about and work with the concepts in a program.

Models are not a Rails idea or even an OOP idea. A model could be represented in any programming language and in any programming paradigm.

In the context of Rails, models don’t have to be just the files that inherit from ActiveRecord::Base. I create most of my models as plain old Ruby objects (POROs) and put them in app/models right alongside Active Record models because to me the distinction between Active Record models and PORO models isn’t very important of a distinction.

Why model specs are different from other types of specs

Because models aren’t a Rails idea but rather a programming idea, testing models in Rails isn’t that conceptually different from testing models in any other language or framework.

In a way this is a great benefit to a learner because it means that if you know how to test models in one language, your testing skills will easily translate to any other language.

On the other hand this is a difficulty because, relative to other types of Rails tests, model tests don’t have such a straight and narrow path to success. The generic-ness of Rails models means that there’s not a rigid template that can be followed in order to write Rails models, unlike, for example, system specs.

System specs are relatively easy to get started with because you can more or less follow a certain step-by-step formula for writing system specs for CRUD features and be well on your way. There’s not as much of a step-by-step formula for writing model tests.

Nonetheless, there are certain principles and tactics you can learn. Let’s take a look at some of the ideas that can help you get started.

The tutorial

Learning objectives

Here are the things you can expect to have a better understanding of after completing this tutorial.

  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

The scenario

The model we’re going to be writing tests for is a model that represents a phone number. Our phone number model will be designed, at least for starters, to mainly deal with just one particular challenge that tends to be common in applications that deal with phone numbers.

This challenge is that it’s usually desired to display phone numbers in a consistent format (e.g. “(555) 555-5555”), but phone numbers are often entered by users in a variety of formats (e.g. “555-555-5555” or “1 555 555 5555”).

A common way of dealing with this problem is to strip down the user input so that it’s all numbers and store the all-numbers version in the database. So, for example, 555-555-5555 would become 5555555555.

We want our phone number model to be able to take phone numbers in any of the following formats:

555-856-8075
(555) 856-8075
+1 555 856 8075

And strip them down to look like this:

5558568075

Our PhoneNumber class won’t know anything about databases, it will just be responsible for converting a “messy” phone number to a normalized one.

The approach

We’re going to take the following three scenarios

555-856-8075
(555) 856-8075
+1 555 856 8075

and write tests for each one.

The shell of the spec and PhoneNumber class

Our beginning spec will contain the bare minimum: an outer describe block and a require_relative that includes our “application code”, phone_number.rb.

require_relative './phone_number.rb'

RSpec.describe PhoneNumber do
end

The phone_number.rb file will only contain the shell of a class.

class PhoneNumber
end

Our first test

A big part of the art of model testing is coming up with various scenarios and deciding how our code should behave under those scenarios.

The first scenario we’ll test here is: “When we have a phone number where the digits are separated by dashes, the dashes should all get stripped out.”

We’ll add a context block that describes our scenario and an it block that describes our expected behavior. Inside the it block, we’ll create our test data (an instance of PhoneNumber) and an expectation: the expectation that we’ll get a dashless version of our original dashed phone number.

require_relative './phone_number.rb'

RSpec.describe PhoneNumber do
  context "phone number contains dashes" do
    it "strips out the dashes" do
      phone_number = PhoneNumber.new("555-856-8075")

      expect(phone_number.value).to eq("5558568075")
    end
  end
end

We haven’t yet defined an initializer for PhoneNumber, so passing a phone number to PhoneNumber when we instantiate an object couldn’t possibly work, but let’s defer worrying about that to the future. We don’t want to try to think about too many things at once. For now all we care about is that our test is constructed properly.

Let’s run the spec.

$ rspec phone_number_spec.rb

Very unsurprisingly, we can an error.

ArgumentError: wrong number of arguments (given 1, expected 0)

Let’s write just enough code to make this error go away and nothing else.

class PhoneNumber
  def initialize(value)
  end
end

When we run the test again we get a new error:

NoMethodError: undefined method 'value' for #<PhoneNumber:0x00007f8541abcb60>

Indeed, when we do expect(phone_number.value).to eq("5558568075"), we’re sending a message (value) to which PhoneNumber doesn’t respond. Let’s fix this error by making PhoneNumber respond to value. The simplest way we can do this is by adding an attr_reader.

class PhoneNumber
  attr_reader :value

  def initialize(value)
    @value = value
  end
end

Now we get yet a different error when we run the spec.


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"

At this point it finally feels like we’re getting somewhere. The error feels like something we can work with. Instead of getting the expected value of 5558568075, we’re getting 555-856-8075.

Let’s write the code that fixes this error—and actually makes the test pass.

class PhoneNumber
  attr_reader :value

  def initialize(value)
    @value = value.gsub(/-/, "")
  end
end

Now when we run the test it passes.

The other two formats

Of our three scenarios, we have so far covered just the first one. Let’s address the next two.

555-856-8075
(555) 856-8075
+1 555 856 8075

We can add a second context block to our test, very similar to our first context block.

require_relative "./phone_number.rb"

RSpec.describe PhoneNumber do
  context "phone number contains dashes" do
    it "strips out the dashes" do
      phone_number = PhoneNumber.new("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 = PhoneNumber.new("(555) 856-8075")

      expect(phone_number.value).to eq("5558568075")
    end
  end
end

When we run this, it fails. We get:

1) PhoneNumber phone number contains parentheses strips out the dashes
     Failure/Error: expect(phone_number.value).to eq("5558568075")
     
       expected: "5558568075"
            got: "(555) 8568075"
     
       (compared using ==)

This makes sense because we’re of cours not doing anything yet to strip out parentheses or spaces. Let’s add parentheses and spaces to our regex.

class PhoneNumber
  attr_reader :value

  def initialize(value)
    @value = value.gsub(/[- ()]/, "")
  end
end

Now, when we run the test, it passes.

Refactoring

And in fact, we can use the safety net of this test to do a little refactoring. Our regular expression is a little bit more complicated than it needs to be. We can change it to just \D, which means “anything that’s not a number”.

class PhoneNumber
  attr_reader :value

  def initialize(value)
    @value = value.gsub(/\D/, "")
  end
end

The last scenario

The final scenario we want to address is when the phone number has a country code. Again, we can add a context block that’s similar to the other two context blocks.

require_relative "./phone_number.rb"

RSpec.describe PhoneNumber do
  context "phone number contains dashes" do
    it "strips out the dashes" do
      phone_number = PhoneNumber.new("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 = PhoneNumber.new("(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 = PhoneNumber.new("+1 555 856 8075")

      expect(phone_number.value).to eq("5558568075")
    end
  end
end

When we run this, it fails. The plus sign and spaces get taken care of but not the 1.

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 ==)

To get rid of the 1, we can just grab everything that’s not the 1, i.e. the last 10 digits of the phone number.

class PhoneNumber
  attr_reader :value

  def initialize(value)
    @value = value.gsub(/\D/, "").split("").last(10).join
  end
end

The 10 is a “magic number” though, which is bad, so let’s assign it to a constant instead of letting it be mysterious.

class PhoneNumber
  attr_reader :value
  EXPECTED_NUMBER_OF_DIGITS = 10

  def initialize(value)
    @value = value.gsub(/\D/, "")
      .split("")
      .last(EXPECTED_NUMBER_OF_DIGITS)
      .join
  end
end

Now all our tests pass and our code is pretty good as well.

Takeaways

  1. A Rails model is an abstraction that represents a small corner of reality in a simplified way.
  2. Because models are so much more of an open-ended concept than certain other components of Rails (e.g. controllers), writing model specs can often be a more ambiguous task for beginners.
  3. One good way to write model specs is to list the specific fine-grained behaviors that you want to exist and to write test cases for each of those behaviors.
  4. Writing tests before we write the application code can make the process of writing the application code easier.

What’s next

In Part Two of the tutorial, we’ll go through the same process we did in Part One, but with a real Active Record model instead of just a plain old Ruby object.

Continue to Part Two

Leave a Reply

Your email address will not be published.