minitest

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Rails Minitest Expert

Rails Minitest 测试专家

Write performant, maintainable, and non-brittle tests for Rails applications using Minitest and fixtures.
使用Minitest和fixtures为Rails应用编写高性能、可维护且稳定的测试。

Philosophy

理念

Core Principles:
  1. Test behavior, not implementation - Focus on WHAT code does, not HOW
  2. Fast feedback loops - Prefer unit tests over integration tests, fixtures over factories
  3. Tests as documentation - Test names should describe expected behavior
  4. Minimal test data - Create only what's necessary; 2 records == many records
  5. 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)
核心原则:
  1. 测试行为而非实现 - 聚焦代码做什么,而非怎么做
  2. 快速反馈循环 - 优先选择单元测试而非集成测试,优先使用fixtures而非工厂模式
  3. 测试即文档 - 测试名称应描述预期行为
  4. 最小化测试数据 - 仅创建必要的数据;2条记录即可代表多条记录
  5. 稳定的断言 - 测试结果,而非可能变化的精确值
测试金字塔:
    /\     系统测试(少量 - 仅覆盖关键路径)
   /  \    
  /____\   请求/集成测试(适量)
 /      \  
/________\ 单元测试(大量 - 模型、策略、服务)

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
  • 为Rails模型、策略、控制器或请求编写新的Minitest测试
  • 将RSpec测试迁移至Minitest
  • 调试缓慢或不稳定的测试
  • 提升测试套件性能
  • 遵循Rails测试规范
  • 编写基于fixtures的测试数据
  • 实现TDD工作流

Instructions

操作步骤

Step 1: Identify Test Type

步骤1:确定测试类型

Before writing, determine the appropriate test type:
Test TypeLocationUse 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
编写测试前,先确定合适的测试类型:
测试类型存放位置适用场景
模型
test/models/
验证规则、关联关系、业务逻辑方法
策略
test/policies/
Pundit授权策略
请求
test/requests/
完整HTTP请求/响应周期
控制器
test/controllers/
控制器动作(优先使用请求测试)
系统
test/system/
带真实浏览器模拟的关键用户流程
服务
test/services/
服务对象与复杂操作
任务
test/jobs/
后台任务行为
邮件
test/mailers/
邮件内容与发送逻辑

Step 2: Check Existing Patterns

步骤2:参考现有模式

ALWAYS search for existing tests first:
bash
undefined
务必先搜索现有测试:
bash
undefined

Find similar test files

查找类似测试文件

rg "class.*Test < " test/
rg "class.*Test < " test/

Find existing fixtures

查找现有fixtures

ls test/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.
cat test/test_helper.rb cat test/support/*.rb

**匹配项目现有规范** - 一致性比“最优”模式更重要。

Step 3: Use Fixtures (Not Factories)

步骤3:使用Fixtures(而非工厂模式)

Fixtures are 10-100x faster than Factory Bot.
ruby
undefined
Fixtures比Factory Bot快10-100倍。
ruby
undefined

AVOID - Factory Bot (slow, implicit)

避免使用 - Factory Bot(缓慢、隐式)

let(:user) { create(:user) } let(:project) { create(:project, workspace: workspace) }
let(:user) { create(:user) } let(:project) { create(:project, workspace: workspace) }

PREFER - Fixtures (fast, explicit)

推荐使用 - Fixtures(快速、显式)

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

```yaml
setup do @workspace = workspaces(:main_workspace) @user = users(:admin_user) @project = projects(:active_project) end

**Fixtures 最佳实践:**
- 创建用途明确、命名清晰的fixtures
- 使用`<%= %>`生成动态值和UUID
- 通过fixture名称关联关联关系,而非ID
- 保持fixtures精简 - 仅包含必要属性

```yaml

test/fixtures/users.yml

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
undefined
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 # 通过fixture名称关联
undefined

Step 4: Write Test Structure

步骤4:编写测试结构

Standard Test Structure:
ruby
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
标准测试结构:
ruby
require "test_helper"

class ModelTest < ActiveSupport::TestCase
  setup do
    # 加载fixtures - 仅做最小化准备
    @record = models(:fixture_name)
  end

  # 用注释或测试名称分组相关测试
  
  # 验证规则测试
  test "requires name" do
    @record.name = nil
    refute @record.valid?
    assert_includes @record.errors[:name], "can't be blank"
  end

  # 方法测试
  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

步骤5:遵循性能优化指南

Avoid Database When Possible:
ruby
undefined
尽可能避免操作数据库:
ruby
undefined

SLOW - Creates database records

缓慢 - 创建数据库记录

test "validates email format" do user = User.create!(email: "invalid", name: "Test") refute user.valid? end
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:**

```ruby
test "validates email format" do user = User.new(email: "invalid", name: "Test") refute user.valid? end

**最小化创建的记录数量:**

```ruby

SLOW - Creates 25 records

缓慢 - 创建25条记录

test "paginates results" do create_list(:post, 25)

...

end
test "paginates results" do create_list(:post, 25)

...

end

FAST - Configure pagination threshold for tests

快速 - 为测试配置分页阈值

config/environments/test.rb: Pagy::DEFAULT[:limit] = 2

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:**

```ruby
test "paginates results" do

仅需3条记录即可测试限制为2的分页逻辑

assert_operator posts.count, :>=, 3

...

end

**尽可能避免浏览器测试:**

```ruby

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
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
undefined
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
undefined

Step 6: Write Non-Brittle Assertions

步骤6:编写稳定的断言

Test Behavior, Not Exact Values:
ruby
undefined
测试行为而非精确值:
ruby
undefined

BRITTLE - Exact timestamp match

不稳定 - 精确时间戳匹配

assert_equal "2025-01-15T10:00:00Z", response["created_at"]
assert_equal "2025-01-15T10:00:00Z", response["created_at"]

ROBUST - Just verify presence

健壮 - 仅验证存在性

assert response["created_at"].present?
assert response["created_at"].present?

BRITTLE - Exact error message

不稳定 - 精确错误信息

assert_equal "Name can't be blank", record.errors.full_messages.first
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:**

```ruby
assert_includes record.errors[:name], "can't be blank"

**使用包容性断言:**

```ruby

BRITTLE - Exact match

不稳定 - 精确匹配

assert_equal({ id: 1, name: "Test", email: "test@example.com" }, response)
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]
assert_equal 1, response[:id] assert_equal "Test", response[:name]

OR

assert response.slice(:id, :name) == { id: 1, name: "Test" }
undefined
assert response.slice(:id, :name) == { id: 1, name: "Test" }
undefined

Step 7: Handle Multi-Tenancy

步骤7:处理多租户场景

For acts_as_tenant projects, always wrap in tenant context:
ruby
undefined
对于使用acts_as_tenant的项目,务必包裹在租户上下文中:
ruby
undefined

WRONG - Missing tenant context

错误 - 缺少租户上下文

test "admin can view project" do assert policy(@admin, @project).show? end
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
undefined
test "admin can view project" do with_workspace(@workspace) do assert policy(@admin, @project).show? end end
undefined

Step 8: Test Permission Flows Correctly

步骤8:正确测试权限流程

Always test denial BEFORE granting, then allow AFTER:
ruby
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
务必先测试拒绝场景,再授予权限后测试允许场景:
ruby
test "member requires permission to create" do
  with_workspace(@workspace) do
    # 1. 测试无权限时的拒绝场景
    refute policy(@member, Project).create?
    
    # 2. 授予权限
    set_workspace_permissions(@member, @workspace, :allowed_to_create_projects)
    
    # 3. 测试有权限时的允许场景
    assert policy(@member, Project).create?
  end
end

Quick Reference

快速参考

Assertion Mapping (RSpec to Minitest)

断言映射(RSpec 转 Minitest)

RSpecMinitest
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
RSpecMinitest
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

Rails 专属断言

ruby
undefined
ruby
undefined

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
assert_difference "Post.count", 1 do Post.create!(title: "Test") end
assert_no_difference "Post.count" do Post.new.save # 无效,不会保存 end

Value changes

值变化

assert_changes -> { post.reload.title }, from: "Old", to: "New" do post.update!(title: "New") end
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)
assert_response :success assert_response :redirect assert_redirected_to post_path(post)

DOM assertions

DOM断言

assert_select "h1", "Expected Title" assert_select ".post", count: 3
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 }
undefined
assert_queries_count(2) { User.find(1); User.find(2) } assert_no_queries { cached_value }
undefined

Test File Templates

测试文件模板

Model Test:
ruby
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:
ruby
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:
ruby
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
模型测试:
ruby
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
请求测试:
ruby
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
策略测试:
ruby
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.new
    instead of
    User.create
    when 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_stubbed
    pattern where applicable
提交测试前,请确认:
  • 使用fixtures而非工厂模式
  • 无需数据库时使用
    User.new
    而非
    User.create
  • 在内存对象上测试验证错误
  • Fixtures数据精简(仅保留必要内容)
  • 尽可能用请求测试替代系统测试
  • 为测试配置较低的分页阈值
  • Fixtures中无不必要的关联关系
  • 测试环境中BCrypt成本设置为最小值
  • 测试环境中禁用日志
  • 适用时使用
    build_stubbed
    模式

Anti-Patterns to Avoid

需避免的反模式

  1. Testing implementation details - Test outcomes, not internal method calls
  2. Overly complex setup - If setup is > 10 lines, refactor to fixtures
  3. Shared state between tests - Each test should be independent
  4. Testing private methods - Only test public interface
  5. Brittle assertions - Don't assert on timestamps, exact errors, or order
  6. Too many system tests - Reserve for critical user paths only
  7. Missing negative tests - Always test what should fail/be denied
  8. Factory cascades - Avoid factories that create many associated records
  1. 测试实现细节 - 测试结果,而非内部方法调用
  2. 过于复杂的准备逻辑 - 若准备代码超过10行,重构为fixtures
  3. 测试间共享状态 - 每个测试应独立运行
  4. 测试私有方法 - 仅测试公共接口
  5. 不稳定的断言 - 不要断言时间戳、精确错误信息或顺序
  6. 过多系统测试 - 仅用于关键用户路径
  7. 缺失负面测试 - 务必测试应失败/被拒绝的场景
  8. 工厂级联 - 避免会创建大量关联记录的工厂模式

Running Tests

运行测试

bash
undefined
bash
undefined

Run all tests

运行所有测试

bin/rails test
bin/rails test

Run specific file

运行指定文件

bin/rails test test/models/user_test.rb
bin/rails test test/models/user_test.rb

Run specific test by line

按行号运行指定测试

bin/rails test test/models/user_test.rb:42
bin/rails test test/models/user_test.rb:42

Run specific test by name

按名称运行指定测试

bin/rails test -n "test_requires_email"
bin/rails test -n "test_requires_email"

Run directory

运行指定目录下的测试

bin/rails test test/policies/
bin/rails test test/policies/

Run with verbose output

带详细输出运行测试

bin/rails test -v
bin/rails test -v

Run in parallel

并行运行测试

bin/rails test --parallel
bin/rails test --parallel

Run with coverage

带覆盖率报告运行测试

COVERAGE=true bin/rails test
COVERAGE=true bin/rails test

Run and fail fast

快速失败模式运行测试

bin/rails test --fail-fast
undefined
bin/rails test --fail-fast
undefined

Debugging Tips

调试技巧

ruby
undefined
ruby
undefined

Print response body in request tests

在请求测试中打印响应体

puts response.body
puts response.body

Print validation errors

打印验证错误信息

pp @record.errors.full_messages
pp @record.errors.full_messages

Use breakpoint (Rails 7+)

使用断点(Rails 7+)

debugger
debugger

Check SQL queries

查看SQL查询

ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Base.logger = Logger.new(STDOUT)

Inspect fixture data

检查fixtures数据

pp users(:admin_user).attributes
undefined
pp users(:admin_user).attributes
undefined