minitest
Rails Minitest Expert
Write performant, maintainable, and non-brittle tests for Rails applications using Minitest and fixtures.
Philosophy
Core Principles:
- Test behavior, not implementation - Focus on WHAT code does, not HOW
- Fast feedback loops - Prefer unit tests over integration tests, fixtures over factories
- Tests as documentation - Test names should describe expected behavior
- Minimal test data - Create only what's necessary; 2 records == many records
- Non-brittle assertions - Test outcomes, not exact values that may change
Testing Pyramid:
/\ System Tests (Few - critical paths only)
/ \
/____\ Request/Integration Tests (Some)
/ \
/________\ Unit Tests (Many - models, policies, services)
When To Use This Skill
- Writing new Minitest tests for Rails models, policies, controllers, or requests
- Converting RSpec tests to Minitest
- Debugging slow or flaky tests
- Improving test suite performance
- Following Rails testing conventions
- Writing fixture-based test data
- Implementing TDD workflows
Instructions
Step 1: Identify Test Type
Before writing, determine the appropriate test type:
| Test Type | Location | Use For |
|---|---|---|
| Model | test/models/ |
Validations, associations, business logic methods |
| Policy | test/policies/ |
Pundit authorization policies |
| Request | test/requests/ |
Full HTTP request/response cycle |
| Controller | test/controllers/ |
Controller actions (prefer request tests) |
| System | test/system/ |
Critical user flows with real browser |
| Service | test/services/ |
Service objects and complex operations |
| Job | test/jobs/ |
Background job behavior |
| Mailer | test/mailers/ |
Email content and delivery |
Step 2: Check Existing Patterns
ALWAYS search for existing tests first:
# Find similar test files
rg "class.*Test < " test/
# Find existing fixtures
ls test/fixtures/
# Check for test helpers
cat test/test_helper.rb
cat test/support/*.rb
Match existing project conventions - consistency is more important than "best" patterns.
Step 3: Use Fixtures (Not Factories)
Fixtures are 10-100x faster than Factory Bot.
# AVOID - Factory Bot (slow, implicit)
let(:user) { create(:user) }
let(:project) { create(:project, workspace: workspace) }
# PREFER - Fixtures (fast, explicit)
setup do
@workspace = workspaces(:main_workspace)
@user = users(:admin_user)
@project = projects(:active_project)
end
Fixture Best Practices:
- Create purpose-specific fixtures with descriptive names
- Use
<%= %>for dynamic values and UUIDs - Reference associations by fixture name, not ID
- Keep fixtures minimal - only include required attributes
# test/fixtures/users.yml
admin_user:
id: <%= Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "admin_user") %>
email: "admin@example.com"
name: "Admin User"
created_at: <%= 1.week.ago %>
member_user:
id: <%= Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, "member_user") %>
email: "member@example.com"
name: "Member User"
workspace: main_workspace # Reference by fixture name
Step 4: Write Test Structure
Standard Test Structure:
require "test_helper"
class ModelTest < ActiveSupport::TestCase
setup do
# Load fixtures - MINIMAL setup only
@record = models(:fixture_name)
end
# Group related tests with comments or test naming
# Validation tests
test "requires name" do
@record.name = nil
refute @record.valid?
assert_includes @record.errors[:name], "can't be blank"
end
# Method tests
test "#full_name returns formatted name" do
@record.first_name = "John"
@record.last_name = "Doe"
assert_equal "John Doe", @record.full_name
end
end
Step 5: Follow Performance Guidelines
Avoid Database When Possible:
# SLOW - Creates database records
test "validates email format" do
user = User.create!(email: "invalid", name: "Test")
refute user.valid?
end
# FAST - Uses in-memory object
test "validates email format" do
user = User.new(email: "invalid", name: "Test")
refute user.valid?
end
Minimize Records Created:
# SLOW - Creates 25 records
test "paginates results" do
create_list(:post, 25)
# ...
end
# FAST - Configure pagination threshold for tests
# config/environments/test.rb: Pagy::DEFAULT[:limit] = 2
test "paginates results" do
# Only need 3 records to test pagination with limit of 2
assert_operator posts.count, :>=, 3
# ...
end
Avoid Browser Tests When Possible:
# SLOW - Full browser simulation
class PostsSystemTest < ApplicationSystemTestCase
test "creates a post" do
visit new_post_path
fill_in "Title", with: "Test"
click_on "Create"
assert_text "Post created"
end
end
# FAST - Request test (no browser)
class PostsRequestTest < ActionDispatch::IntegrationTest
test "creates a post" do
post posts_path, params: { post: { title: "Test" } }
assert_response :redirect
follow_redirect!
assert_response :success
end
end
Step 6: Write Non-Brittle Assertions
Test Behavior, Not Exact Values:
# BRITTLE - Exact timestamp match
assert_equal "2025-01-15T10:00:00Z", response["created_at"]
# ROBUST - Just verify presence
assert response["created_at"].present?
# BRITTLE - Exact error message
assert_equal "Name can't be blank", record.errors.full_messages.first
# ROBUST - Check for key content
assert_includes record.errors[:name], "can't be blank"
Use Inclusive Assertions:
# BRITTLE - Exact match
assert_equal({ id: 1, name: "Test", email: "test@example.com" }, response)
# ROBUST - Check key attributes only
assert_equal 1, response[:id]
assert_equal "Test", response[:name]
# OR
assert response.slice(:id, :name) == { id: 1, name: "Test" }
Step 7: Handle Multi-Tenancy
For acts_as_tenant projects, always wrap in tenant context:
# WRONG - Missing tenant context
test "admin can view project" do
assert policy(@admin, @project).show?
end
# CORRECT - Proper tenant scoping
test "admin can view project" do
with_workspace(@workspace) do
assert policy(@admin, @project).show?
end
end
Step 8: Test Permission Flows Correctly
Always test denial BEFORE granting, then allow AFTER:
test "member requires permission to create" do
with_workspace(@workspace) do
# 1. Test denial WITHOUT permission
refute policy(@member, Project).create?
# 2. Grant permission
set_workspace_permissions(@member, @workspace, :allowed_to_create_projects)
# 3. Test allow WITH permission
assert policy(@member, Project).create?
end
end
Quick Reference
Assertion Mapping (RSpec to Minitest)
| RSpec | Minitest |
|---|---|
expect(x).to eq(y) |
assert_equal y, x |
expect(x).to be_truthy |
assert x |
expect(x).to be_falsey |
refute x |
expect(x).to be_nil |
assert_nil x |
expect(arr).to include(x) |
assert_includes arr, x |
expect(arr).not_to include(x) |
refute_includes arr, x |
expect { }.to change { X.count }.by(1) |
assert_difference "X.count", 1 do ... end |
expect { }.to raise_error(E) |
assert_raises(E) { ... } |
expect(x).to be_valid |
assert x.valid? |
expect(x).not_to be_valid |
refute x.valid? |
expect(x).to match(/pattern/) |
assert_match /pattern/, x |
Rails-Specific Assertions
# Record changes
assert_difference "Post.count", 1 do
Post.create!(title: "Test")
end
assert_no_difference "Post.count" do
Post.new.save # Invalid, doesn't save
end
# Value changes
assert_changes -> { post.reload.title }, from: "Old", to: "New" do
post.update!(title: "New")
end
# Response assertions
assert_response :success
assert_response :redirect
assert_redirected_to post_path(post)
# DOM assertions
assert_select "h1", "Expected Title"
assert_select ".post", count: 3
# Query assertions
assert_queries_count(2) { User.find(1); User.find(2) }
assert_no_queries { cached_value }
Test File Templates
Model Test:
require "test_helper"
class UserTest < ActiveSupport::TestCase
setup do
@user = users(:active_user)
end
test "valid fixture" do
assert @user.valid?
end
test "requires email" do
@user.email = nil
refute @user.valid?
assert_includes @user.errors[:email], "can't be blank"
end
test "#display_name returns formatted name" do
@user.name = "John Doe"
assert_equal "John Doe", @user.display_name
end
end
Request Test:
require "test_helper"
class PostsRequestTest < ActionDispatch::IntegrationTest
setup do
@user = users(:active_user)
@post = posts(:published_post)
sign_in @user
end
test "GET /posts returns success" do
get posts_path
assert_response :success
end
test "POST /posts creates record" do
assert_difference "Post.count", 1 do
post posts_path, params: { post: { title: "New Post", body: "Content" } }
end
assert_redirected_to post_path(Post.last)
end
test "POST /posts with invalid data returns error" do
assert_no_difference "Post.count" do
post posts_path, params: { post: { title: "" } }
end
assert_response :unprocessable_entity
end
end
Policy Test:
require "test_helper"
class PostPolicyTest < ActiveSupport::TestCase
include PolicyTestHelpers
setup do
@workspace = workspaces(:main_workspace)
@admin = users(:admin_user)
@member = users(:member_user)
@post = posts(:workspace_post)
Current.user = nil
end
test "admin can always edit" do
with_workspace(@workspace) do
assert policy(@admin, @post).edit?
end
end
test "member requires permission to edit" do
with_workspace(@workspace) do
refute policy(@member, @post).edit?
set_workspace_permissions(@member, @workspace, :allowed_to_edit_posts)
assert policy(@member, @post).edit?
end
end
test "scope excludes other workspace posts" do
with_workspace(@other_workspace) do
scope = PostPolicy::Scope.new(@admin, Post.all).resolve
refute_includes scope, @post
end
end
end
Performance Optimization Checklist
Before submitting tests, verify:
- Using fixtures instead of factories
- Using
User.newinstead ofUser.createwhen DB not needed - Testing validation errors on in-memory objects
- Minimal fixture data (only what's needed)
- Request tests instead of system tests where possible
- Pagination thresholds configured low for tests
- No unnecessary associations in fixtures
- BCrypt cost set to minimum in test environment
- Logging disabled in test environment
- Using
build_stubbedpattern where applicable
Anti-Patterns to Avoid
- Testing implementation details - Test outcomes, not internal method calls
- Overly complex setup - If setup is > 10 lines, refactor to fixtures
- Shared state between tests - Each test should be independent
- Testing private methods - Only test public interface
- Brittle assertions - Don't assert on timestamps, exact errors, or order
- Too many system tests - Reserve for critical user paths only
- Missing negative tests - Always test what should fail/be denied
- Factory cascades - Avoid factories that create many associated records
Running Tests
# Run all tests
bin/rails test
# Run specific file
bin/rails test test/models/user_test.rb
# Run specific test by line
bin/rails test test/models/user_test.rb:42
# Run specific test by name
bin/rails test -n "test_requires_email"
# Run directory
bin/rails test test/policies/
# Run with verbose output
bin/rails test -v
# Run in parallel
bin/rails test --parallel
# Run with coverage
COVERAGE=true bin/rails test
# Run and fail fast
bin/rails test --fail-fast
Debugging Tips
# Print response body in request tests
puts response.body
# Print validation errors
pp @record.errors.full_messages
# Use breakpoint (Rails 7+)
debugger
# Check SQL queries
ActiveRecord::Base.logger = Logger.new(STDOUT)
# Inspect fixture data
pp users(:admin_user).attributes
More from thinkoodle/rails-skills
caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4