3. Model specs

With RSpec successfully installed, let’s put it to work and begin building a suite of reliable tests. We’ll get started with TasteDrivenDishes’s core building blocks–its models.

In this chapter, we’ll complete the following tasks:

  • First we’ll create model specs for existing models.
  • Then, we’ll write passing tests for a model’s validations, class, and instance methods, and organize our specs in the process.

We’ll create our first spec files for existing models manually. Later, when adding new models to the application, the handy RSpec generators we configured in chapter 2 will generate placeholder files for us.

Anatomy of a model spec

I think it’s easiest to learn testing at the model level, because doing so allows you to examine and test the core building blocks of an application. Well-tested code at this level provides a solid foundation for a reliable overall code base.

To get started, a model spec should include tests for the following:

  • When instantiated with valid attributes, an object of the model should be valid.
  • Objects that don’t meet validation requirements should not be valid.
  • Class and instance methods perform as expected.

This is a good time to look at the basic structure of an RSpec model spec. I find it helpful to think of them as individual outlines. For example, let’s look at our User model’s simplest requirements:

describe User do
  it "is valid with an email, password, nickname, and API token"
  it "requires a nickname"
  it "requires a unique nickname"
  it "requires an email"
  it "requires a unique email"
  it "requires a password"
  it "requires an API token"
  it "sets a new user's API token"
end

We’ll expand this outline in a few minutes, but this gives a lot to work with for starters. It’s a simple spec for an admittedly simple model, but points to our first four best practices:

  • It describes a set of expectations–in this case, what the User model should look like, and how it should behave.
  • Each example (a line beginning with it) only expects one thing. Notice that I’m testing each validation separately. This way, if an example fails, I know it’s because of that specific validation, and I don’t have to dig through RSpec’s output for clues–at least, not as deeply.
  • Each example is explicit. The descriptive string after it is technically optional in RSpec. However, omitting it makes your specs more difficult to read.
  • Each example’s description begins with a verb, not should. Read the expectations aloud: User requires an API token, User requires an email, User sets a new user’s API token. Readability is important, and a key feature of RSpec!

With these best practices in mind, let’s build a spec for the User model.

Creating a model spec

In chapter 2, we set up RSpec to automatically generate boilerplate test files whenever we add new models and controllers to the application. We can invoke generators anytime, though. Here, we’ll use one to generate a starter file for our first model spec.

Begin by using the rspec:model generator on the command line:

$ bin/rails g rspec:model user

RSpec reports creating the new file:

rails g rspec:model user
      create  spec/models/user_spec.rb

Let’s open the new file and take a look.

spec/models/user_spec.rb
1 require 'rails_helper'
2 
3 RSpec.describe User, type: :model do
4   pending "add some examples to (or delete) #{__FILE__}"
5 end

The new file gives us our first look at some RSpec syntax and conventions. First, we require the file rails_helper in this file, and will do so in pretty much every file in our test suite. This tells RSpec that we need the Rails application to load, so it can then run the tests contained in the file. Next, we’re using the describe method to list out a set of things a model named User is expected to do. We’ll talk more about pending in chapter 12, when we begin practice test-driven development. For now, happens if we run this, using bin/rspec?

User
  add some examples to (or delete) /Users/asumner/code/examples/recipes/spec/mode\
ls/user_spec.rb (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User add some examples to (or delete) /Users/asumner/code/examples/recipes/s\
pec/models/user_spec.rb
    # Not yet implemented
    # ./spec/models/user_spec.rb:4

Finished in 0.00058 seconds (files took 0.66131 seconds to load)
1 example, 0 failures, 1 pending

Let’s keep the describe wrapper, but replace its contents with the outline we created a few minutes ago:

spec/models/user_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe User, type: :model do
 4   it "is valid with an email, password, nickname, and API token"
 5   it "requires a nickname"
 6   it "requires a unique nickname"
 7   it "requires an email"
 8   it "requires a unique email"
 9   it "requires a password"
10   it "requires an API token"
11   it "sets a new user's API token"
12 end

We’ll fill in the details in a moment, but if we ran the specs right now from the command line (by typing bin/rspec on the command line) the output would be something like:

User
  is valid with an email, password, nickname, and API token (PENDING: Not yet imp\
lemented)
  requires a nickname (PENDING: Not yet implemented)
  requires a unique nickname (PENDING: Not yet implemented)
  requires an email (PENDING: Not yet implemented)
  requires a unique email (PENDING: Not yet implemented)
  requires a password (PENDING: Not yet implemented)
  requires an API token (PENDING: Not yet implemented)
  sets a new user's API token (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User is valid with an email, password, nickname, and API token
    # Not yet implemented
    # ./spec/models/user_spec.rb:4

  2) User requires a nickname
    # Not yet implemented
    # ./spec/models/user_spec.rb:5

  3) User requires a unique nickname
    # Not yet implemented
    # ./spec/models/user_spec.rb:6

  4) User requires an email
    # Not yet implemented
    # ./spec/models/user_spec.rb:7

  5) User requires a unique email
    # Not yet implemented
    # ./spec/models/user_spec.rb:8

  6) User requires a password
    # Not yet implemented
    # ./spec/models/user_spec.rb:9

  7) User requires an API token
    # Not yet implemented
    # ./spec/models/user_spec.rb:10

  8) User sets a new user's API token
    # Not yet implemented
    # ./spec/models/user_spec.rb:11


Finished in 0.00086 seconds (files took 0.83556 seconds to load)
8 examples, 0 failures, 8 pending

Great! Eight pending specs. RSpec marks them as pending because we haven’t written any actual code to perform the tests. Let’s do that now, starting with the first example.

The RSpec syntax

In 2012, the RSpec team announced a new, preferred alternative to the traditional should, added to version 2.11. Of course, this happened just a few days after I released the first complete version of this book–it can be tough to keep up with this stuff sometimes!

This new approach alleviates some technical issues caused by the old should syntax. Instead of saying something should or should_not match expected output, you expect something to or not_to be something else.

As an example, let’s look at this sample test, or expectation. In this example, 2 + 1 should always equal 3, right? In the old RSpec syntax, this would be written like this:

it "adds 2 and 1 to make 3" do
  (2 + 1).should eq 3
end

The new syntax passes the test value into an expect() method, then chains a matcher to it:

it "adds 2 and 1 to make 3" do
  expect(2 + 1).to eq 3
end

If you’re searching Google or Stack Overflow for help with an RSpec question, or are working with an older Rails application, there’s still a good chance you’ll find information using the old should syntax. This syntax still technically works in current versions of RSpec, but you’ll get a deprecation warning when you try to use it. You can configure RSpec to turn off these warnings, but in all honesty, you’re better off learning to use the preferred expect() syntax.

So what does that syntax look like in a real example? Let’s fill out that first expectation from our spec for the User model:

spec/models/user_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe User, type: :model do
 4   it "is valid with an email, password, nickname, and API token" do
 5     user = User.new(
 6       email: "test@example.com",
 7       password: "password",
 8       nickname: "test",
 9       api_token: "token"
10     )
11 
12     expect(user).to be_valid
13   end
14 
15   it "requires a nickname"
16   it "requires a unique nickname"
17   it "requires an email"
18   it "requires a unique email"
19   it "requires a password"
20   it "requires an API token"
21   it "sets a new user's API token"
22 end

This simple example uses an RSpec matcher called be_valid to verify that our model knows what it has to look like to be valid. We set up an object (in this case, a new-but-unsaved instance of User called user), then pass that to expect to compare to the matcher.

Now, if we run bin/rspec from the command line again, we see one passing example:

User
  is valid with an email, password, nickname, and API token
  requires a nickname (PENDING: Not yet implemented)
  requires a unique nickname (PENDING: Not yet implemented)
  requires an email (PENDING: Not yet implemented)
  requires a unique email (PENDING: Not yet implemented)
  requires a password (PENDING: Not yet implemented)
  requires an API token (PENDING: Not yet implemented)
  sets a new user's API token (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User requires a nickname
    # Not yet implemented
    # ./spec/models/user_spec.rb:15

  2) User requires a unique nickname
    # Not yet implemented
    # ./spec/models/user_spec.rb:16

  3) User requires an email
    # Not yet implemented
    # ./spec/models/user_spec.rb:17

  4) User requires a unique email
    # Not yet implemented
    # ./spec/models/user_spec.rb:18

  5) User requires a password
    # Not yet implemented
    # ./spec/models/user_spec.rb:19

  6) User requires an API token
    # Not yet implemented
    # ./spec/models/user_spec.rb:20

  7) User sets a new user's API token
    # Not yet implemented
    # ./spec/models/user_spec.rb:21


Finished in 0.02298 seconds (files took 0.56166 seconds to load)
8 examples, 0 failures, 7 pending

Congratulations, you’ve written your first test!

Matchers are key components in RSpec, so let’s take a moment to break down what be_valid implies. We could have written the test as

expect(user.valid?).to eq true

or

expect(user.valid?).to be true

In these variations, eq and be are also matchers. Matchers help make tests read like plain English, rather than the assert-style language you might have seen in other frameworks. be_valid has something else happening–before comparing the left side of the matcher to the right, it also calls the valid? method on the user under test. That’s why we don’t need to explicitly call user.valid? in the original test.

RSpec includes many built-in matchers; we’ll explore some of them throughout the book. But for now, let’s write some more tests!

Testing validations

Validations are great for getting comfortable with automated testing. These tests can usually be written in just a few lines of code. Let’s fill in our nickname validation spec:

spec/models/user_spec.rb
1 it "requires a nickname" do
2   user = User.new(nickname: nil)
3 
4   expect(user).to be_invalid
5 end

This time, we expect that a new user with the nickname attribute explicitly set nil will not be valid. In this case, the be_invalid matcher calls user.invalid? before comparing the expected and actual values.

That’s great, but the test doesn’t tell the ready why. Let’s fix that:

spec/models/user_spec.rb
1 it "requires a nickname" do
2   user = User.new(nickname: nil)
3 
4   expect(user).to be_invalid
5   expect(user.errors[:nickname]).to include("can't be blank")
6 end

Now, the test clarifies the reason the user isn’t valid, by ensuring the error message matches what we’d expect from this validation. We check for this using RSpec’s include matcher, which checks to see if a value is contained within an enumerable value (here, errors). And when we run RSpec again, we should be up to two passing specs.

There’s a small problem in our approach so far. We’ve got a couple of passing tests, but we never saw them fail. This can be a warning sign, especially when starting out. We need to be certain that the test code is doing what it’s intended to do, also known as exercising the code under test.

There are a couple of things we can do to prove that we’re not getting false positives. First, let’s flip that expectation by changing to to to_not:

spec/models/user_spec.rb
1 it "requires a nickname" do
2   user = User.new(nickname: nil)
3 
4   expect(user).to be_invalid
5   expect(user.errors[:nickname]).to_not include("can't be blank")
6 end

And sure enough, RSpec reports a failure:

Failures:

  1) User requires a nickname
    Failure/Error: expect(user.errors[:nickname]).to_not include("can't be blank")
      expected ["can't be blank"] not to include "can't be blank"
    # ./spec/models/user_spec.rb:19:in `block (2 levels) in <top (required)>'

Finished in 0.02385 seconds (files took 0.55168 seconds to load)
8 examples, 1 failure, 6 pending

Failed examples:

rspec ./spec/models/user_spec.rb:15 # User requires a nickname

We can also modify the application code, to see how it affects the test. Undo the change we just made to the test (switch to_not back to to), then open the User model and temporarily comment out the nickname validation:

app/models/user.rb
 1 require "open-uri"
 2 
 3 class User < ApplicationRecord
 4   include Clearance::User
 5 
 6   has_many :recipes, dependent: :destroy
 7   has_many :favorites, dependent: :destroy
 8   has_many :favorite_recipes, through: :favorites, source: :recipe
 9   has_many :comments, dependent: :destroy
10   has_one_attached :avatar
11 
12   # validates :nickname, presence: true, uniqueness: true
13   validates :api_token, presence: true, uniqueness: true
14 
15   before_validation :set_api_token, on: :create
16   before_create :set_avatar
17 
18   # remainder of file omitted ...

Run the specs again. This time, you should again see a failure. We told RSpec that a user with no nickname should be invalid, but our application code didn’t support that.

These are easy ways to verify your tests are working as expected, especially as you progress from testing simple validations to more complex logic, and are testing code that’s already been written. If you don’t see a change in test output, then there’s a good chance that the test is not actually interacting with the code, or that the code behaves differently than you expect.

Now we can use the same approach to test the :email validation.

spec/models/user_spec.rb
1 it "requires an email" do
2   user = User.new(email: nil)
3 
4   expect(user).to be_invalid
5   expect(user.errors[:email]).to include("can't be blank")
6 end

You may be thinking that these tests are relatively pointless–how hard is it to make sure validations are included in a model? The truth is, they can be easier to omit than you might imagine. More importantly, though, if you think about what validations your model should have while writing tests (ideally, and eventually, in a Test-Driven Development style of coding), you are more likely to remember to include them.

Let’s build on our knowledge so far to write a slightly more complicated test–this time, to check the uniqueness validation on the nickname attribute:

spec/models/user_spec.rb
 1 it "requires a unique nickname" do
 2   User.create(
 3     nickname: "test",
 4     email: "test1@example.com",
 5     password: "password"
 6   )
 7 
 8   user = User.new(
 9     nickname: "test"
10   )
11 
12   expect(user).to be_invalid
13   expect(user.errors[:nickname]).to include("has already been taken")
14 end

Notice a subtle difference here: In this case, we first persisted a user (calling create on User instead of new) to build our test data, then instantiated a second user as the subject of the actual test. This, of course, requires that the first, persisted user is valid (with a nickname, email, and password) and has the same email address assigned to it. In chapter 4, we’ll look at utilities to streamline this process. In the meantime, run bin/rspec to see the new test’s output.

Now let’s test a more complex validation. To do so, we’ll set aside tests for the User model, and turn to the Recipe model.

Say we want to make sure that users can’t give two of their recipes the same name–the name should be unique within the scope of that user. In other words, I can’t have two recipes named Vegetable Stir Fry, but you and I could each have our own project named Vegetable Stir Fry. How might you test that?

As we did for users, we’ll start by creating a new spec file for the Recipe model:

$ bin/rails g rspec:model recipe

Next, add two examples to the new file. We’ll test that a single user can’t have two recipes with the same name, but two different users can each have a recipe with the same name.

spec/models/recipe_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Recipe, type: :model do
 4   it "does not allow duplicate recipe names per user" do
 5     user = User.create(
 6       nickname: "test-user",
 7       email:      "test-user@example.com",
 8       password:   "password"
 9     )
10 
11     category = Category.create(name: "Test Category")
12 
13     user.recipes.create(
14       name: "Test Recipe",
15       category: category
16     )
17 
18     second_recipe = user.recipes.build(
19       name: "Test Recipe",
20     )
21 
22     expect(second_recipe).to_not be_valid
23     expect(second_recipe.errors[:name]).to include("has already been taken")
24   end
25 
26   it "allows two users to share a project name" do
27     user = User.create(
28       nickname: "test-user",
29       email:      "test-user@example.com",
30       password:   "password"
31     )
32 
33     other_user = User.create(
34       nickname: "another-test-user",
35       email:      "another-test-user@example.com",
36       password:   "password"
37     )
38 
39     category = Category.create(name: "Test Category")
40 
41     user.recipes.create(
42       name: "Test Recipe",
43       category: category
44     )
45 
46     second_recipe = other_user.recipes.build(
47       name: "Test Recipe",
48       category: category
49     )
50 
51     expect(second_recipe).to be_valid
52   end
53 end

This time, since the User and Recipe models are coupled via an Active Record relationship, as are the Recipe and Category models, we need to provide a little extra information. In the case of the first example, we’ve got a user to which both recipes are assigned. In the second, the same project name is assigned to two unique recipes, belonging to unique users. Note that, in both examples, we have to create the users, or persist them in the database, in order to assign them to the projects we’re testing. And though it’s not part of what we’re testing here, in order to fulfill the app’s requirement that each recipe belong to a category, we also need to create a category in each of the tests.

Since the Recipe model has the following validation:

app/models/recipe.rb
validates :name, presence: true, uniqueness: { scope: :user_id }

These new specs will pass without issue. Don’t forget to check your work–try temporarily commenting out the validation, or changing the tests so they expect something different. Do they fail now?

Of course, validations can be more complicated than just requiring a specific scope. Yours might involve a complex regular expression, or a custom validator. Get in the habit of testing these validations–not just the happy paths where everything is valid, but also error conditions. For instance, in the examples we’ve created so far, we tested what happens when an object is initialized with nil values. If you have a validation to ensure that an attribute must be a number, try sending it a string. If your validation requires a string to be four-to-eight characters long, try sending it three characters, and nine.

Testing instance methods

Let’s resume testing the User model now. We’ve got the beginnings of a feature to treat new users differently than users who’ve been around for a little while. Perhaps someday, we might limit the number of recipes or comments a new user can post. For now, we’re just displaying a badge when attributing a recipe to a new user.

To handle this, we’ve got this method in the User class:

app/models/user.rb
def new_to_site?
  created_at > 1.month.ago
end

We can use the same basic techniques we used for our validation examples to create a passing example of this feature:

spec/models/user_spec.rb
1 it "indicates a new user" do
2   user = User.new(created_at: Time.now)
3 
4   expect(user.new_to_site?).to be true
5 end

Cool, but what about a user who’s been here for awhile? We should test that, too:

spec/models/user_spec.rb
1 it "indicates an established user" do
2   user = User.new(created_at: 1.month.ago)
3 
4   expect(user.new_to_site?).to be false
5 end

Not too bad, but let’s tidy these tests up with some matcher magic. Remember earlier in this chapter that we used be_valid and be_invalid as matchers for testing a User object’s validity? We can use be_ on our own methods that return booleans, too!

spec/models/user_spec.rb
 1 it "indicates a new user" do
 2   user = User.new(created_at: Time.now)
 3 
 4   expect(user).to be_new_to_site
 5 end
 6 
 7 it "indicates an established user" do
 8   user = User.new(created_at: 1.month.ago)
 9 
10   expect(user).to_not be_new_to_site
11 end

Personally, I love this feature of RSpec. I think it makes tests read more like documentation and less like code. If you or your team disagree, there’s nothing wrong with the previous iterations of these tests.

Either way, we’re establishing a pattern for testing: Create test data, then tell RSpec how you expect it to behave. Let’s keep going.

Testing class methods and scopes

Our users can search recipe titles for a provided term. For the sake of demonstration, it’s currently implemented as a scope on the Recipe model:

app/models/recipe.rb
1 scope :by_word_in_name, ->(query) {
2   where("name LIKE ?", "%#{query}%") if query.present?
3 }

Let’s add another test to recipe_spec to cover this:

spec/models/recipe_spec.rb
 1 it "finds recipes that contain the search term in their name" do
 2   user = User.create(
 3     nickname: "test-user",
 4     email: "test-user@example.com",
 5     password: "password"
 6   )
 7 
 8   category = Category.create(name: "Test Category")
 9 
10   first_recipe = user.recipes.create(
11     name: "Pepperoni Pizza",
12     category: category
13   )
14 
15   second_recipe = user.recipes.create(
16     name: "Cheese Pizza",
17     category: category
18   )
19 
20   results = Recipe.by_word_in_name("pepperoni")
21 
22   expect(results).to include(first_recipe)
23   expect(results).to_not include(second_recipe)
24 end

The by_word_in_name scope should return a collection of recipes matching the search term, and that collection should only include those recipes–not ones that don’t contain the term.

This test gives us some other things to experiment with: What happens if we flip around the to and to_not variations on the tests? Or add more recipes containing the search term?

Testing all the cases

We’ve tested the happy path–a user searches a term for which we can return results–but what about occasions when the search returns no results? We’d better test that, too. The following spec should do it:

spec/models/recipe_spec.rb
 1 it "returns an empty collection when no recipes matching the search term are foun\
 2 d" do
 3   user = User.create(
 4     nickname: "test-user",
 5     email: "test-user@example.com",
 6     password: "password"
 7   )
 8 
 9   category = Category.create(name: "Test Category")
10 
11   first_recipe = user.recipes.create(
12     name: "Pepperoni Pizza",
13     category: category
14   )
15 
16   second_recipe = user.recipes.create(
17     name: "Cheese Pizza",
18     category: category
19   )
20 
21   results = Recipe.by_word_in_name("veggie")
22 
23   expect(results).to be_empty
24 end

This spec checks the value returned by Recipe.by_word_in_name("veggie"). Since the resulting collection is empty, the spec passes! We’re testing not just for the ideal results, but also for searches with no results.

More about matchers

We’ve already seen four matchers in action: be_valid, eq, include, and be_empty. First we used be_valid, which is provided by the rspec-rails gem to test a Rails model’s validity. eq and include come from rspec-expectations, installed alongside rspec-rails when we set up our app to use RSpec in the previous chapter.

A complete list of RSpec’s default matchers may be found in the README for the rspec-expectations repository on GitHub. We’ll look at several of these throughout this book. In chapter 8, we’ll take a look at creating custom matchers of our own.

Summary

This chapter focused on testing models, but we’ve covered a lot of other important techniques you’ll want to use in other types of specs moving forward:

  • Use active, explicit expectations: Use verbs to explain what an example’s results should be. Try to only check for one result per example. (We’ll talk about exceptions to this in a later chapter.)
  • Test for what you expect to happen, and for what you expect to not happen: Think about both paths when writing examples, and test accordingly.
  • Test for edge cases: If you have a validation that requires a password be between four and ten characters in length, don’t just test an eight-character password and call it good. A good set of tests would test at four and ten, as well as at three and eleven. (Of course, you might also take the opportunity to ask yourself why you’d allow such short passwords, or not allow longer ones. Testing is also a good opportunity to reflect on an application’s requirements and code.)

With a solid collection of model specs incorporated into your app, you’re well on your way to more trustworthy code. Great work!

Exercises

If you’re following along with your own, untested Rails code, take a look at its models now. What attributes map to their underlying database tables? How are data validated? What other business logic do they contain?

These are all great candidates for your first test coverage. Start by generating a model spec for a given model, then outline the scenarios you want to test. Think through how to build just enough test data for each scenario, and how it would prove that your code behaves as you want. Then write each spec and run. I recommend doing this one test at a time. Don’t worry if you’re repeating yourself from test to test. We’ll explore de-duplication options in the next chapter.

As you’re writing and running you new specs, do you notice anything unexpected about your application code? Like, a feature that doesn’t quite work as you’d assumed it did, or code that would benefit from refactoring? Congratulations! You’re already seeing the benefits of test-driven software design!