← 返回
未分类

Rails TDD Standards

RSpec testing standards and best practices for Rails applications. Use when writing new tests, reviewing test quality, debugging factory errors, setting up F...
RSpec 测试标准与 Rails应用最佳实践。用于编写新测试、审查测试质量、调试 factory 错误以及配置...
djc00p djc00p 来源
未分类 clawhub v1.1.0 2 版本 99809.5 Key: 无需
★ 0
Stars
📥 524
下载
💾 2
安装
2
版本
#latest

概述

Rails TDD Standards

Best practices for writing clean, reliable RSpec tests in Rails applications.

Core Principle: Single Expectation

One assertion per test. Tests should read like specifications — each it block verifies exactly one thing.

# ✅ Correct
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to belong_to(:user) }

# ❌ Wrong — too many expectations in one test
it "validates the user" do
  expect(user).to validate_presence_of(:email)
  expect(user).to validate_presence_of(:name)
  expect(user).to be_valid
end

FactoryBot Patterns

Always use role traits

# ✅ Correct
create(:user, :admin)
create(:user, :member)
create(:user, :guest)

# ❌ Wrong — missing role context
create(:user)

Association keys matter

Check your factory definitions carefully. Wrong keys cause silent failures.

# ✅ Example — if your factory uses owner:
create(:profile, owner: user)

# ❌ Wrong key
create(:profile, user: user)  # fails if factory expects owner:

Always set required associations

When a model requires a specific association to be valid, always set it explicitly — don't rely on factory defaults when they might be nil or wrong.

# ✅ Explicit — clear intent, no surprises
let(:record) do
  create(:model, required_association: other_record)
end

# ❌ Implicit — may break if factory default changes
let(:record) { create(:model) }

Use described_class not hardcoded class names

# ✅
subject { described_class.new(params) }

# ❌
subject { MyService.new(params) }

Common FactoryBot Gotchas

Join tables without primary key

Tables with id: false can't use .last or .first.

# ✅ Use a scoped query
record = JoinModel.find_by(field_a: a, field_b: b)

# ❌ Will raise ActiveRecord::MissingRequiredOrderError
record = JoinModel.last

RecordInvalid from missing role/trait

If you see Validation failed: X must have Y role — you're missing a trait on the user factory.

# ✅
user = create(:user, :editor)

# ❌ causes "must be an editor" validation error
user = create(:user)

Spec Structure

RSpec.describe MyClass do
  # Subject
  subject(:instance) { described_class.new(params) }

  # Shared setup
  let(:user) { create(:user, :admin) }

  # Group by behavior
  describe "#method_name" do
    context "when condition is true" do
      it "does the expected thing" do
        expect(instance.method_name).to eq(expected)
      end
    end

    context "when condition is false" do
      it "does something else" do
        expect(instance.method_name).to be_nil
      end
    end
  end
end

Mocking & Stubbing

# Stub a method
allow(object).to receive(:method_name).and_return(value)

# Stub and verify it was called
expect(object).to receive(:method_name).once

# Stub HTTP calls (WebMock)
stub_request(:post, "https://api.example.com/endpoint")
  .to_return(status: 200, body: { result: "ok" }.to_json)

# Allow localhost for system tests (if using WebMock)
WebMock.disable_net_connect!(allow_localhost: true)

Service Object Testing

RSpec.describe MyService do
  describe "#call" do
    context "with valid params" do
      it "returns the expected result" do
        result = described_class.new(valid_params).call
        expect(result).to be_a(ExpectedClass)
      end

      it "creates the expected record" do
        expect { described_class.new(valid_params).call }
          .to change(Record, :count).by(1)
      end
    end

    context "with invalid params" do
      it "returns false" do
        expect(described_class.new(invalid_params).call).to be(false)
      end
    end
  end
end

Rails 8 Gotchas

Status codes changed

:unprocessable_entity is deprecated in Rails 8.0.2+. Use :unprocessable_content in response assertions.

# ✅ Rails 8
expect(response).to have_http_status(:unprocessable_content)

# ❌ Deprecated (will warn/fail)
expect(response).to have_http_status(:unprocessable_entity)

params.expect vs require/permit

Rails 8 introduces params.expect as the preferred strong params pattern. But watch out: params.expect strips nested hash-keyed arrays (like items_attributes: { "0" => { ... } }).

# ✅ params.expect — works for flat + simple nested
params.expect(post: [:title, :body, tag_ids: []])

# ✅ Use require/permit for nested attributes with "0"-keyed hashes
params.require(:post).permit(:title, items_attributes: [:id, :name, :_destroy])

# ❌ params.expect breaks "0"-keyed nested attributes
# params.expect(post: [items_attributes: [...]])  ← strips the hash keys

skip_forgery_protection

# ✅ Rails 8
skip_forgery_protection

# ❌ Deprecated
skip_before_action :verify_authenticity_token

CI Database Setup

Critical: Never use db:prepare in CI. It runs seeds, which pollutes the test database and causes scoped queries to return unexpected results.

# ✅ CI config (GitHub Actions / etc.)
- run: bundle exec rails db:schema:load RAILS_ENV=test

# ❌ Runs seeds → pollutes test DB → scope specs fail
- run: bundle exec rails db:prepare RAILS_ENV=test

If you see scope specs randomly returning too many records, check your CI DB setup first. Seeds belong in development, not the test environment.


Callback Testing: before_create vs before_validation

before_create callbacks are skipped by FactoryBot's build(). If your callback needs to fire during build (e.g., for token generation, slug assignment), use before_validation on: :create instead.

# ✅ Works with both build() and create()
before_validation :generate_token, on: :create

# ❌ Skipped by build() — token will be nil in specs using build
before_create :generate_token

This is especially important for:

  • Token generation (API keys, one-time tokens)
  • Slug generation
  • Setting default values that specs need to assert on
  • Any callback you expect to fire when using build(:model)

Token Authentication Testing

Never test for raw token values — tokens should be hashed on save. Test that:

  1. A digest was stored (not nil)
  2. The authenticate / find_by_token method works correctly
RSpec.describe ApiToken do
  describe "token generation" do
    it "stores a hashed digest, not the raw token" do
      token = build(:api_token)
      token.save!
      expect(token.token_digest).to be_present
    end

    it "authenticates with the raw token" do
      token = ApiToken.new(name: "test")
      token.save!
      raw = token.raw_token  # capture from one-time return before it's gone
      expect(ApiToken.authenticate(raw)).to eq(token)
    end
  end
end

For request specs, generate the token in a let block and inject it as a header:

let(:user) { create(:user) }
let(:raw_token) do
  t = user.api_tokens.build(name: "test")
  t.save!
  t.raw_token
end

before { get "/api/v1/resources", headers: { "Authorization" => "Bearer #{raw_token}" } }

> The raw_token method name is implementation-specific — adjust to match whatever your model exposes after save.


ActionCable Channel Testing

Use stub_connection to inject the authenticated user into the channel connection.

RSpec.describe NotificationsChannel, type: :channel do
  let(:user) { create(:user) }

  before { stub_connection current_user: user }

  describe "#subscribed" do
    it "subscribes to the user stream" do
      subscribe
      expect(subscription).to be_confirmed
      expect(streams).to include("notifications:user:#{user.id}")
    end
  end

  describe "#unsubscribed" do
    it "stops all streams" do
      subscribe
      unsubscribe
      expect(streams).to be_empty
    end
  end
end

Test broadcast behavior with have_broadcasted_to:

it "broadcasts a notification to the user stream" do
  subscribe
  expect {
    NotificationsChannel.broadcast_to(user, { type: "alert", message: "You have a new message" })
  }.to have_broadcasted_to(user).with(hash_including(type: "alert"))
end

External Service Stubs

Always stub external services in unit and integration specs. Never make real API calls in tests.

Geocoder

# In spec_helper or a shared context
before do
  allow(Geocoder).to receive(:search).and_return(
    [double(coordinates: [40.7128, -74.0060], city: "New York", state: "NY")]
  )
end

Stripe

# Stub a PaymentIntent creation
before do
  allow(Stripe::PaymentIntent).to receive(:create).and_return(
    double(id: "pi_test_123", status: "requires_capture", client_secret: "secret_abc")
  )
end

# For HTTP-level stubs (WebMock)
stub_request(:post, "https://api.stripe.com/v1/payment_intents")
  .to_return(status: 200, body: { id: "pi_test_123", status: "requires_capture" }.to_json)

General pattern

Any service that hits the network should be stubbed. If you find yourself relying on VCR cassettes for unit tests, consider switching to explicit doubles — they're faster and don't require recorded responses.


Serializer Testing

Test serializers directly — don't test through controllers. Assert on the JSON output structure.

RSpec.describe ArticleSerializer do
  let(:article) { create(:article, :published) }

  subject(:json) { described_class.new(article).as_json }

  it "includes expected public keys" do
    expect(json.keys).to include(:id, :title, :body, :published_at)
  end

  it "excludes sensitive internal fields" do
    expect(json.keys).not_to include(:internal_cost_cents, :vendor_id)
  end

  context "with an admin scope" do
    subject(:json) { described_class.new(article, scope: { role: :admin }).as_json }

    it "includes admin-only fields" do
      expect(json.keys).to include(:cost_breakdown, :vendor_id)
    end
  end
end

Running Tests

# Run full suite
bundle exec rspec

# Run specific file
bundle exec rspec spec/models/user_spec.rb

# Run specific line
bundle exec rspec spec/models/user_spec.rb:42

# Run only failures from last run
bundle exec rspec --only-failures

# Run with documentation format
bundle exec rspec --format documentation

See Also

  • references/factory-patterns.md — advanced FactoryBot patterns
  • references/system-specs.md — Capybara / browser testing setup

版本历史

共 2 个版本

  • v1.1.0 当前
    2026-05-03 05:06 安全 安全
  • v1.0.1
    2026-03-30 15:21 安全 安全

安全检测

腾讯云安全 (Keen)

安全,无风险
查看报告

腾讯云安全 (Sanbu)

安全,无风险
查看报告

🔗 相关推荐

dev-programming

CodeConductor.ai

larsonreever
AI驱动平台,提供快速全栈开发、智能体、工作流自动化及低代码AI集成的可扩展产品创建。
★ 72 📥 181,840
ai-agent

Agent Cost Strategy

djc00p
分层模型选择与成本优化,适用于多智能体 AI 工作流。在为任务选择模型或启动子代理时使用此技能。
★ 0 📥 770
dev-programming

Mcporter

steipete
使用 mcporter CLI 直接列出、配置、认证及调用 MCP 服务器/工具(支持 HTTP 或 stdio),涵盖临时服务器、配置编辑及 CLI/类型生成功能。
★ 195 📥 67,646