Minitest Guide: Rails Testing with Assertions and Fixtures
Minitest ships with Ruby's standard library and powers the default test suite in every new Rails application. It's fast, minimal, and surprisingly capable. Many teams that start with RSpec eventually migrate back to Minitest for its simplicity and performance.
This guide covers everything you need to write effective Rails tests with Minitest: the assertion library, fixtures, test organization, mocking, and common patterns.
Why Minitest
Rails chose Minitest as its default test framework for good reasons. It has no runtime dependencies beyond Ruby itself, starts up in milliseconds, and produces clear output. Unlike RSpec's DSL, Minitest uses plain Ruby classes and methods — there's nothing to learn beyond the assertion API.
Minitest also ships with two test styles. The default uses Minitest::Test with explicit assertion methods. The spec style mimics RSpec's describe/it DSL. Both styles produce the same output and have full access to every Minitest feature.
Basic Test Structure
A Minitest test class inherits from Minitest::Test:
require "test_helper"
class UserTest < Minitest::Test
def test_email_is_downcased_before_save
user = User.new(email: "ALICE@EXAMPLE.COM")
user.save!
assert_equal "alice@example.com", user.email
end
def test_invalid_without_email
user = User.new(email: nil)
refute user.valid?
assert_includes user.errors[:email], "can't be blank"
end
endEvery test method must begin with test_. Rails auto-discovers test files under test/ and runs them with bin/rails test.
In Rails applications, your test classes inherit from one of several base classes:
ActiveSupport::TestCase— for model and unit testsActionDispatch::IntegrationTest— for integration and system testsActionMailer::TestCase— for mailer testsActionView::TestCase— for view tests
Core Assertions
Minitest's assertion library is comprehensive. These are the assertions you'll use daily:
Equality and Truthiness
assert_equal expected, actual # ==
assert_nil value # value.nil?
assert value # truthy
refute value # falsy
refute_nil value # !value.nil?
assert_same expected, actual # object identity (equal?)Numeric Comparisons
assert_operator 5, :>, 3
assert_in_delta 3.14, Math::PI, 0.01 # floating point within delta
assert_in_epsilon 1000, 1001, 0.01 # within relative toleranceCollections
assert_includes collection, object
assert_empty collection
refute_empty collection
assert_equal [1, 2, 3], array.sortStrings and Patterns
assert_match /pattern/, string
refute_match /pattern/, stringExceptions
assert_raises(ArgumentError) do
User.create!(email: nil)
end
error = assert_raises(ActiveRecord::RecordInvalid) do
User.create!(email: "bad")
end
assert_match /Email is invalid/, error.messageDatabase State
Rails adds database-specific assertions:
assert_difference "User.count", 1 do
User.create!(email: "new@example.com", password: "secret")
end
assert_no_difference "User.count" do
User.create(email: nil) # invalid, should fail
end
assert_changes -> { user.reload.email }, from: "old@example.com", to: "new@example.com" do
user.update!(email: "new@example.com")
endFixtures
Fixtures are YAML files that define test data loaded into the test database before each test. They live in test/fixtures/.
Defining Fixtures
# test/fixtures/users.yml
alice:
email: alice@example.com
name: Alice Smith
role: admin
created_at: <%= Time.current %>
bob:
email: bob@example.com
name: Bob Jones
role: member
created_at: <%= Time.current %>Fixtures support ERB for dynamic values. Rails automatically assigns IDs based on fixture names using a hash function, so users(:alice) always returns the same record.
Using Fixtures in Tests
class UserTest < ActiveSupport::TestCase
test "admin can access admin panel" do
alice = users(:alice)
assert alice.admin?
end
test "member cannot access admin panel" do
bob = users(:bob)
refute bob.admin?
end
endYou access fixtures using the helper method named after the table (singular or plural). Rails loads all fixtures from the test/fixtures/ directory into the test database by default.
Fixture Associations
Fixtures handle associations through fixture names:
# test/fixtures/posts.yml
hello_world:
title: Hello World
body: My first post
user: alice # references users.yml "alice" fixture
published: trueRails resolves the association automatically when the fixture is loaded. The user key maps to the user_id column via the fixture name.
Dynamic Fixtures
For complex test data, use ERB:
# test/fixtures/subscriptions.yml
active:
user: alice
plan: pro
expires_at: <%= 1.month.from_now %>
status: active
expired:
user: bob
plan: free
expires_at: <%= 1.month.ago %>
status: expiredTest Organization
Rails generates test files that mirror your app structure:
test/
models/
user_test.rb
post_test.rb
controllers/
users_controller_test.rb
system/
user_flows_test.rb
integration/
api_test.rb
helpers/
application_helper_test.rb
mailers/
notification_mailer_test.rb
fixtures/
users.yml
posts.yml
test_helper.rbThe test_helper.rb file configures the test environment and is required by every test file. Customize it to add shared helpers, configure VCR, or set up factories.
Mocking and Stubbing
Minitest includes Minitest::Mock for lightweight mocking:
test "sends welcome email on user creation" do
mailer = Minitest::Mock.new
mailer.expect(:deliver_later, nil)
UserMailer.stub(:welcome, mailer) do
User.create!(email: "new@example.com", password: "secret123")
end
mailer.verify
endFor stubbing individual methods, Minitest provides Object#stub:
test "returns cached weather when API is down" do
WeatherApi.stub(:fetch, { temp: 72, condition: "sunny" }) do
result = WeatherService.current_conditions("NYC")
assert_equal 72, result[:temp]
end
endThe stub reverts automatically after the block exits, so test isolation is guaranteed.
Setup and Teardown
Use setup and teardown methods for shared test initialization:
class PostTest < ActiveSupport::TestCase
setup do
@user = users(:alice)
@post = Post.new(title: "Test Post", user: @user)
end
test "valid with all required fields" do
assert @post.valid?
end
test "invalid without title" do
@post.title = nil
refute @post.valid?
end
teardown do
# Cleanup if needed (usually not required with transactional fixtures)
end
endRails wraps each test in a database transaction that rolls back after the test completes. This means fixtures are always in a clean state — no explicit cleanup needed.
Running Tests
# Run all tests
bin/rails <span class="hljs-built_in">test
<span class="hljs-comment"># Run a specific file
bin/rails <span class="hljs-built_in">test <span class="hljs-built_in">test/models/user_test.rb
<span class="hljs-comment"># Run a specific test by name
bin/rails <span class="hljs-built_in">test <span class="hljs-built_in">test/models/user_test.rb -n test_email_is_downcased_before_save
<span class="hljs-comment"># Run tests matching a pattern
bin/rails <span class="hljs-built_in">test -n /email/
<span class="hljs-comment"># Run with verbose output
bin/rails <span class="hljs-built_in">test -v
<span class="hljs-comment"># Run in parallel (Rails 6+)
bin/rails <span class="hljs-built_in">test --parallelRails 6 introduced parallel test execution using forked processes or threads. Add to your test_helper.rb:
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
endCustom Assertions
Extract repeated assertion logic into reusable methods:
# test/test_helper.rb
module UserAssertions
def assert_user_valid(user)
assert user.valid?, "Expected user to be valid: #{user.errors.full_messages.join(', ')}"
end
def assert_redirected_to_login(response)
assert_redirected_to login_path
assert_equal "You must be logged in", flash[:alert]
end
end
class ActiveSupport::TestCase
include UserAssertions
endMinitest Spec Style
If you prefer RSpec-like syntax, Minitest supports a spec DSL:
require "test_helper"
describe User do
describe "#valid?" do
it "returns true with valid attributes" do
user = User.new(email: "alice@example.com", password: "secret")
_(user.valid?).must_equal true
end
it "returns false without email" do
user = User.new(email: nil)
_(user.valid?).must_equal false
end
end
endThe spec style uses _() wrapper for expectations (Minitest 5.19+ requirement). Both styles are equivalent — use whichever your team prefers.
Integration with HelpMeTest
While Minitest validates your business logic in isolation, production applications need continuous testing against live environments. HelpMeTest runs your end-to-end test scenarios 24/7 and alerts you the moment something breaks — catching issues that unit tests never see.
You can trigger HelpMeTest runs from your CI pipeline alongside your Minitest suite, giving you both fast unit test feedback and continuous production monitoring in a single workflow.
Common Patterns
Testing Callbacks
test "sets slug before save" do
post = Post.create!(title: "My Post Title", user: users(:alice))
assert_equal "my-post-title", post.slug
endTesting Scopes
test "published scope returns only published posts" do
published = posts(:published_post)
draft = posts(:draft_post)
result = Post.published
assert_includes result, published
refute_includes result, draft
endTesting Validations
test "validates email format" do
["not-an-email", "@nodomain", "no@"].each do |bad_email|
user = User.new(email: bad_email)
refute user.valid?, "Expected #{bad_email} to be invalid"
assert_includes user.errors[:email], "is invalid"
end
endSummary
Minitest provides everything Rails applications need for robust unit testing: a complete assertion library, fixture management, mocking primitives, and deep Rails integration. Its performance advantage over RSpec becomes significant in large test suites, and its simplicity makes it easy to onboard new developers.
Start with the built-in assertions and fixtures. Add custom helpers as patterns emerge. Keep tests fast by using fixtures over factories where possible, and let Rails' transaction rollback handle cleanup automatically.