Model Testing

Learning Goals

  • Understand why Model Testing is important
  • Setup an RSpec test suite in Rails
  • Write model tests for instance methods

Setup

This lesson builds off of the Task Manager Tutorial. You can find the completed code from this tutorial here.

Why Model Test?

As Backend developers, our job is to handle data. We need to add data, retrieve data, manipulate data, validate data, analyze data, etc. and we cannot afford to have any errors when handling data. Since our Models are the thing in our Rails apps that interact with the data, we write tests specifically for the models to ensure that we are handling data properly. These tests should fully cover all of the data logic in our application.

Now that we know why it is important to model test, let’s add some model testing to Task Manager.

Setup RSpec and SimpleCov

Gems

We are going to use RSpec as our testing framework, so first thing is to install and set up RSpec.

In your Gemfile Inside the existing group :development, :test block, add

  • gem "rspec-rails"
  • gem "pry"
  • gem "simplecov"

Your Gemfile should now have this:

Gemfile

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri mingw x64_mingw ]
  gem "pry"
  gem "rspec-rails"
  gem "simplecov"
end

Always run bundle install whenever you update your Gemfile. Now from the command line run:

$ rails g rspec:install

What new files did this generate?

  • ./.rspec file
  • a whole ./spec/ directory
  • ./spec/rails_helper.rb is the new spec_helper, holds Rails-specific configurations
  • ./spec/spec_helper.rb - where we keep all specs that don’t depend on rails

At the top of your rails_helper.rb, add these lines to configure SimpleCov:

spec/rails_helper.rb

require "simplecov"
SimpleCov.start

We’ll also add a line for coverage to the .gitignore file so that our SimpleCov reports aren’t pushed to GitHub.

Testing the Task Model

Let’s start by creating a test file for the Task class. From the command line run:

$ mkdir spec/models
$ touch spec/models/task_spec.rb

It is important that these folders are named spec and models, respectively.

In our new test file, add the following:

spec/models/task_spec.rb

require "rails_helper"

RSpec.describe Task, type: :model do

end

This is the basic set up for any model test. RSpec.describe Task tells our test that we are testing the Task class. type: :model tells our test that it is a model test. You can optionally leave this out since our test will recognize that it is a model test because it is defined inside spec/models. Yes, the name of the folders and files will affect how your tests run.

What to Test?

We’ve seen request specs handle the end-to-end functionality of our API endpoints, so what should we be testing in the model?

The answer: Data logic and integrity!

Model tests are where we can isolate data manipulations and make sure they’re working correctly. Our request specs are integration tests, while our model tests are more like unit tests. We’ve seen unit tests before, but now they’re just used in the context of a Rails application.

Here are some common tests you’ll find in the model:

  • Testing relationships
  • Testing validations
  • Testing instance and class methods

In our ActiveRecord Associations class we discuss testing relationships, and we’ll get to testing validations in Data Validations, so the focus of this lesson plan will be testing instance methods. The good news: we’ve seen this type of test before in Module 1!

Testing Instance Methods in the Model

Inside our model test, let’s add a section for instance method tests:

spec/models/task_spec.rb

require "rails_helper"

describe Task, type: :model do
  describe "instance methods" do

  end
end

And inside that section, let’s add another section for a test for a specific method:

spec/models/task_spec.rb

require "rails_helper"

describe Task, type: :model do
  describe "instance methods" do
    describe "#laundry?" do

    end
  end
end

The idea of this laundry? method is that it will return a boolean if the Task’s title or description contains the word “laundry”. Notice that we are using the # symbol to indicate that this is an instance method test in addition to our describe "instance methods" block.

We’re now ready to write our first test! Let’s start with a simple test case:

spec/models/task_spec.rb

require "rails_helper"

describe Task, type: :model do
  describe "instance methods" do
    describe "#laundry?" do
      it "returns true when the title is laundry" do
        task = Task.create!(title: "laundry", description: "clean clothes")

        expect(task.laundry?).to be(true)
      end
    end
  end
end

A couple of things to note here:

  1. describe blocks are used to organize tests. it blocks are the actual tests.
  2. We are using the create! method as opposed to the create method. The bang ! is very useful for testing because it will throw an error if the instance of the model was not created successfully. In our tests, we would want to see this error to indicate that something went wrong so we can make debugging easier.

Now let’s run this test and TDD our way to a passing test. From the command line run bundle exec rspec and you should see an error:

NoMethodError:
       undefined method "laundry?" for #<Task id: 1, title: "laundry", description: "clean clothes">

Let’s go make the method in our Task model:

app/models/task.rb

class Task < ApplicationRecord
  def laundry?
  end
end

Running the test again gives us expected true, got nil, which makes sense since an empty method returns nil. Let’s fill in the method body:

app/models/task.rb

class Task < ApplicationRecord
  def laundry?
    if title == "laundry"
      return true
    else
      return false
    end
  end
end

Now we should have a passing test!

Let’s add some more test cases:

*spec/models/task_spec.rb

require "rails_helper"

describe Task, type: :model do
  describe "instance methods" do
    describe "#laundry?" do
      it "returns true when the title is laundry" do
        task = Task.create!(title: "laundry", description: "clean clothes")

        expect(task.laundry?).to be(true)
      end

      it "returns true when the description is laundry" do
        task = Task.create!(title: "Clean my clothes", description: "laundry")
    
        expect(task.laundry?).to be(true)
      end
    end
  end
end

Run this test and you should get a failure expected true, got false. Let’s update our method:

app/models/task.rb

class Task < ApplicationRecord
  def laundry?
    if title == "laundry"
      return true
    elsif description == "laundry"
      return true
    else
      return false
    end
  end
end

And now we should have two passing tests.

Test Coverage

Let’s check in on our test coverage. Open the SimpleCov report with:

$ open coverage/index.html 

This should open a new page in your default web browser with the report and you should see coverage less than 100%. If you click on the app/models/task.rb file in the report you should see the line of code that is not covered: return false.

We tested when the method returns true, but not the other path when it returns false. Remember earlier when we said that our model tests should fully cover our models? This is an example of when the model is not fully covered. Additionally, SimpleCov is a great tool to get quick feedback on our test coverage, but SimpleCov is exactly that… simple! This means that even if SimpleCov says that your file is fully covered, there could still be some edge cases that we need to consider.

Finally, because we are particularly concerned with model test coverage, we should run bundle exec rspec spec/models to point rspec to the model tests folder. We are going to add another type of test called features later, and we won’t want our Feature Testing to affect our Model Test coverage.

Practice

Let’s create some pending tests for practice:

spec/models/task_spec.rb

describe "#laundry?" do
  it "returns true when the title is laundry" do
    task = Task.create!(title: "laundry", description: "clean clothes")

    expect(task.laundry?).to be(true)
  end

  it "returns true when the description is laundry" do
    task = Task.create!(title: "Clean my clothes", description: "laundry")

    expect(task.laundry?).to be(true)
  end

  it "returns false when neither the description nor title is laundry"

  it "returns true when the title contains the word laundry"

  it "is case insensitive when checking if the title contains the word laundry"

  it "returns true when the description contains the word laundry"

  it "is case insensitive when checking if the description contains the word laundry"
end

See if you can fill in the test bodies and update our laundry? method to handle each additional case.

If you finish, try to come up with another test case on your own.

You can find a completed version of this practice exercise on the model_testing-complete branch of Task Manager here.

Checks for Understanding

  • What are model tests?
  • Why is model testing important?
  • What does a describe block do in an RSpec test?
  • What does an it block do in an RSpec test?

Lesson Search Results

Showing top 10 results