Testing is an integral part of the software development lifecycle. And a testing framework is one of a key decision for the project and the team. One that's usually hard or expensive to reverse.
In Ruby the choice comes down to Minitest and RSpec. Each has its own strengths and use cases, making the choice between them not always straightforward. One usually pairs with fixtures and one with factory approach to create test data.
I used RSpec almost exclusively at work and for my freelance clients. And I used Minitest with fixtures on all my little Rails apps. So let's see the key differences, benefits, and examples of both frameworks.
Minitest is a lightweight, simple-to-use testing framework for Ruby. It provides a fast and straightforward way to write unit and integration tests. Minitest emphasizes simplicity and clarity in test code, making it easy to read and maintain.
Initially introduced as part of Ruby's standard library since Ruby 1.9 in 2007, Minitest was designed as a simplified successor and replacement for Ruby's older Test::Unit
framework. Ryan Davis developed Minitest to provide a minimalistic and efficient solution that aligns with Ruby's philosophy of simplicity and elegance.
Today, Minitest is widely adopted in Ruby gems and the test framework of choice of both DHH, author of Rails, and Steven R. Baker, the original author of RSpec. It's also now the default testing framework in Rails.
RSpec is a widely-used testing framework for Ruby and Ruby on Rails applications. It provides a domain-specific language (DSL) that makes writing tests more of behaviour specification as opposed to just asserting facts about the system.
The beauty in RSpec is in its DSL that let's you structure your test suite as a specification with as many nesting as you might need. It's also a very practical choice due to its popularity for testing Rails applications. They are many books, tutorials, and resources to learn RSpec way of testing.
One interesting thing about RSpec is that its original author Steven R. Baker says that he created RSpec for learning purposes rather than the practical utility as a test framework. Nevertheless, it's the number one test fromework for Rails in terms of wide adoption.
Minitest comes with the old school unit test feels. The idea here is that it's just Ruby and a tests are just assertions of something for the most part. This kind of code tends to look a little bit ordinary, but it's exteremly easy to write and maintainable over long period of time.
But why is that? You can simply take a bit of your Ruby interface from somewhere and call an assertion on it. A simple setup
method and copy-pastability makes writing tests fast and on top of that keep them simple:
require "test_helper"
class AccessTokenTest < ActiveSupport::TestCase
setup do
@user = users(:john)
end
test "token is automatically generated" do
token = AccessToken.new(name: "token", user: @user, valid_for_in_days: 2)
token.validate
assert token.token
end
test "#expired?" do
token = AccessToken.new(name: "token", user: @user, valid_until: 2.days.from_now)
assert_not token.expired?
token.valid_until = 2.days.ago
assert token.expired?
end
end
RSpec on the other hand is more sophisticated. You aren't just writing tests but a specification of your program (class, method). It comes with a beautiful DSL of subject
, let
, spec
and expect
blocks that looks tidy and makes it easy to write cascading specs:
require 'rails_helper'
RSpec.describe User, type: :model do
subject { described_class.new(name: "John Doe", email: "[email protected]", password: "password") }
# Using let to define reusable attributes
let(:valid_attributes) { { name: "Jane Doe", email: "[email protected]", password: "password" } }
let(:user) { described_class.new(valid_attributes) }
describe "validations" do
it "is valid with valid attributes" do
expect(user).to be_valid
end
it "is invalid without a name" do
user.name = nil
expect(user).not_to be_valid
expect(user.errors[:name]).to include("can't be blank")
end
it "is invalid without an email" do
user.email = nil
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
it "is invalid with a duplicate email" do
described_class.create!(valid_attributes) # Persist first user
duplicate_user = described_class.new(valid_attributes) # Attempt duplicate
expect(duplicate_user).not_to be_valid
expect(duplicate_user.errors[:email]).to include("has already been taken")
end
end
end
But the DSL and the advanced features like shared examples also take a little bit of that easiness. You might lose track of what subject
is, have harder time to compose back the interface out of expactations and get lost on shared example failures.
Rails offers two ways to create data. Active Record and fixtures. Fixtures are pre-loaded data sets while regular Active Record let's you create additional objects when needed. Fixtures fit perfectly well with the Minitest way of doing things.
RSpec practitioners on the other hand embraced the factory-pattern to create test data with a library called FactoryBot. You can think of this as using Active Record on steroids, making you create your test objects without too much work.
It's important to remember that we can still use factories with Minitest and mix and match all approaches, but most teams pick one of those two stacks.
Fixtures in Rails are predefined sample data one can use later in the test suite. You might have noticed that newly generated Rails applications feature YAML files in the test/fixtures
directory representing our models. They are the most common fixtures, but not the only ones.
The way I think about fixtures is that they are a small representation of your application world. They are a small snapshot of a running application at a specific time. We design them to cover 60-80% cases while keeping the amount of fixtures small.
Here's an example of defining a fixture that will be loaded in our tests by default:
# test/fixtures/users.yml
<% password = "test123456" %>
joe:
name: Joe
email: <%= Faker::Internet.unique.email %>
encrypted_password: <%= Devise::Encryptor.digest(User, password) %>
confirmed_at: <%= DateTime.now %>
time_zone: "Berlin"
And here is how we would use a fixture in a test:
class UserTest < ActiveSupport::TestCase
setup do
@user = users(:joe)
@user.update(my_attribute: "New value")
@other_user = User.create!(my_attribute: "Passed value")
end
test "#my_attribute" do
assert_equal "New value", @user.my_attribute
assert_equal "Passed value", @other_user.my_attribute
end
end
Factories too come with object definition and even variants:
factory :user do
first_name { "Joe" }
last_name { "Silver" }
end
However, a factory is just a mere default, a shortcut, to produce test data at test time:
it "downcases the location on save" do
user = create(:user, first_name: "Peter")
expect(user.first_name).to eq "Peter"
end
We usually try to create objects with attributes that are being tested, so this information is close to its expectation.
Rails comes with Minitest and fixtures by default. You don't need to configure anything to start testing. In a Rails app, the test directory typically looks like this:
test/
├── controllers/
├── fixtures/
├── helpers/
├── integration/
├── mailers/
├── models/
├── system/
└── test_helper.rb
Rails also provides built-in rake tasks to run tests:
rails test
rails test test/models/user_test.rb
rails test test/controllers/posts_controller_test.rb
rails test:system
Fixtures are loaded automatically, but you are also free to load just specific ones for the test at hand:
# test/models/user_test.rb
require "test_helper"
class UserTest < ActiveSupport::TestCase
fixtures :users
test "user fixture should be valid" do
alice = users(:alice)
assert alice.valid?
assert_equal "Alice Smith", alice.name
end
end
Rails will also generate your test files as part of its scaffold generator.
There is a bit more to say about the exact integration. Get a copy of Test Driving Rails to learn more.
Rails currently cannot generate new applications using RSpec as it's not part of Rails. However we can at least skip the generation of Minitest scaffolded files:
$ rails new myapp --skip-test
Then we can add rspec-rails
to Gemfile
alongside FactoryBot, Faker, and other test libraries we want to use:
# Gemfile
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
end
And continue with RSpec installation:
$ rails generate rspec:install
This command generates the necessary directory structure and configuration files including helper files.
To continue using RSpec with scaffolding, we can set config.generators
to rspec
:
config.generators do |g|
g.orm :data_mapper, migration: true
g.template_engine :haml
g.test_framework :rspec
end
RSpec tests are called specs and are typically placed under the spec/
directory, structured similarly to the Rails application:
spec/
models/
controllers/
factories/
features/
helpers/
requests/
views/
To run the test suite we would call rspec
directly instead of bin/rails test
:
$ bundle exec rspec
$ bundle exec rspec spec/models/user_spec.rb
Some of the Minitest strong points include:
assert_equal
, etc.) and spec-style syntax (describe
, it
, etc.).My personal favourite is likely how easy is to maintain a test suite long-term.
Some of the disadvantages include lack of adoption among Rails agencies and startups (for career purposes), limited nesting options (like multiple describe blocks), required assertion nesting (wrapping assertions), no bissecting out-of-the-box.
Some of the RSpec strong points include:
I like the easy nesting which can arguably be overused but makes things a little bit more organized at times. Some things that are cumbersome in Minitest write nicely in RSpec.
The main disadvantage is then slower execution speed, extra dependencies, and the effort keeping factories fast.
RSpec is a powerful, expressive testing framework for Ruby and Rails. Together with FactoryBot and Faker is the most common way of testing Rails applications today. On the other hand Minitest with fixtures is a simple capable, easy to learn default and part of Rails Omakase menu. I encourage you to give it a go on your next project.