Minitest vs RSpec for testing Rails applications

Table of contents

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.

About Minitest

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.

About RSpec

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.

Looks and feels

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.

Creating test data

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

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

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 integration

Minitest with fixtures

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.

RSpec and factories

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

Pros and cons

Minitest

Some of the Minitest strong points include:

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.

RSpec

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.

Summary

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.

Author
Josef Strzibny
Hello, I am Josef and I am on Rails since its 2.0 version. I always liked strong conventions and the Rails Omakase docrine. I am author of Kamal Handbook and Test Driving Rails. I think testing should be fun, not a chore.

© Test Driving Rails Blog