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: