Getting Familiar with RSpec in Rails

I spent some time over the weekend getting familiar with RSpec. Gonna brain dump (with just a little bit of structure) the process and what I did and learned. To start I set up in a new rails project and kinda tweaked it into a place where I can be productive.

What is RSpec though? It is a testing framwork. But it's a little different than the Minitest testing framework that ships with Rails. RSpec is a "Behavior Driven Development" tool. You may have heard of TDD, or "Test Driven Development". BDD is a close relative. BDD informs TDD and encourages collaboration in the projets development by defining and formalizing a system's behavior in a common language. With RSpec you are writing the specification for how your application actually works. As a proponent of TDD this is super exciting!

Installation

I started with a clean install of Rails. I passed in the -T flag to skip generation of Minitest unit test files. Since I'm gonna RSpec I do not need them.

$ rails new rspec-example-app -T

Then I installed RSpec. I added rspec-rails to both the :test and :development sections of the projects Gemfile. The reason to add it to both the :development and :test sections is so that the rails generator will correctly generate spec files. Otherwise you have to use RAILS_ENV=test when using generators.

group :development, :test do
  gem 'rspec-rails', '~> 6.0.0'
end

Then, in your project directory install the gem:

$ bundle install

Next, generate boilerplate configuration files:

$ rails generate rspec:install
    create  .rspec
    create  spec
    create  spec/spec_helper.rb
    create  spec/rails_helper.rb

Configuring RSpec

Out of the box RSpec works just fine; As described in spec/spec_helper.rb:

(the defaults) provide a good initial experience with RSpec, but feel free to customize to your heart's content

But let's take a look anyways!

.rspec

You can store command line options in this file. Running the rspec command will read them as though you typed them on the command line. By default, when we generated boilerplate configuration, the file will only contain a single line, --require spec_helper. See available options with rspec --help. I did not add any additional flags to this file just yet.

spec/spec_helper.rb

This file is automatically loaded when we run rspec (because of the flag in .rspec).

In this file you configure RSpec. By default RSpec uses rspec-expectations as the assertion/expectation library, and rspec-mocks as the test double library. You can specify alternate libraries in this file, such as wrong or stdlib/minitest for expectations, or Mocha or Bogus as a double library. I did not change any of those defaults though. There are a lot of configuration choices you can make here. I would recommend not changing much until you need to.

For reference here are configuration options for the rspec-core, rspec-mocks, and rspec-expectations gems that are installed when you installed rspec-rails.

spec/rails_helper.rb

Define how rails run RSpec in this file.

The default configuration is minimal. It defines a path to fixtures, enables transactional fixtures so that the database is rolled back after each test, and toggles on a flag to have RSPec infer the type of test it is by the location of the test file. What that means is, for example, if you are testing a model you can tell RSpec it is a model by specifying it like RSpec.describe User, type: :model do when creating a spec, OR you can put your test in the file specs/models/user_spec.rb and RSPec will automatically apply the type :model because it is in the models directory.

By default RSpec is also configured to filter lines from Rails gems in backtraces.

Writing Specs

Rails will create spec files when you use a generator. For example, I created a new User model to start testing and Rails generated spec/models/user_spec.rb for me. Run migrations, then run rspec to see that it works. I haven't written a test yet but it should still show that things are working.

$ bin/rails g model User email:string password:string
  Running via Spring preloader in process 41305
      invoke  active_record
      create    db/migrate/20221107061213_create_users.rb
      create    app/models/user.rb
      invoke    rspec
      create      spec/models/user_spec.rb

$ bin/rails db:migrate
$ rspec

*

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

  1) User add some examples to (or delete) /Users/roylindauer/Development/rspec-example-app/spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4


Finished in 0.00074 seconds (files took 0.38603 seconds to load)
1 example, 0 failures, 1 pending

I used a feature of RSpec called contexts to start organizing tests. A context in RSpec groups related checks together. Context seems good for when or if conditions, such as when a required parameter is missing

Add validation for the email field of the User model

class User < ApplicationRecord
  validates :email, presence: true
end

The first spec for the User model to ensure that email is required

require 'rails_helper'

RSpec.describe User, type: :model do
  context "when email address is missing" do
    it "should return a validation error" do
      user = User.new.save
      expect(user).to eq(false)
    end
  end
end

Result:

$ rspec
.

Finished in 0.0165 seconds (files took 0.45219 seconds to load)
1 example, 0 failures

RSpec can give us a nicer output though. I learned about the command line flag --format. You can control the formatting of test output. I've updated .rspec to set the formatter to documentation

.rspec

--require spec_helper
--format documentation
$ rspec

User
  when email address is missing
    should return a validation error

Finished in 0.00796 seconds (files took 0.48131 seconds to load)
1 example, 0 failures

That's pretty good looking!

You can also organize tests with describe. Describe seems good for who or what, such as describe "method name" or describe "scope :scope_name"

I've added a field to my model so I could test a scope.

$ bin/rails g migration AddActiveFieldToUser active:boolean
$ bin/rails db:migrate

The updated model and the new test added:

class User < ApplicationRecord
  validates :email, presence: true

  scope :active_users, -> { where(active: true) }
end
describe ":active_users scope" do
  it "returns active users" do
    User.new(email: 'hello@example.org', active: true).save!
    User.new(email: 'test@example.org', active: false).save!
    expect(User.active_users.length).to eq(1)
  end
end

Rspec output:

$ rspec

User
  when email address is missing
    should return a validation error
  :active_users scope
    returns active users

Finished in 0.01023 sec

Couple of things, I am not using fixtures yet and can already tell that is going to be a nightmare to maintain. Fixtures are necessary. RSpec feels pretty good to write. It almost feels like the tests write themselves, and I think that may be why Behavior Drive Development exists. It also almost feels like taking user acceptance criteria and codifying it in specs, which may also have been the point. Regardless, so far it has a nice overall vibe.

Wrapping up

This is getting long winded. I hope it was helpful in some way. Next time I am going to tackle a few things:

  • Testing ActiveJob
  • Setting up a Fixture library with factory_bot
  • Dig into database cleaning with database_cleaner

Some Resources: