🎁 Get the FREE AI Skills Starter Guide β€” Subscribe β†’
BytesAgainBytesAgain
πŸ¦€ ClawHub

Rails TDD Standards

by @djc00p

RSpec testing standards and best practices for Rails applications. Use when writing new tests, reviewing test quality, debugging factory errors, setting up F...

Versionv1.1.0
Downloads829
TERMINAL
clawhub install rails-tdd-standards

πŸ“– About This Skill


name: rails-tdd-standards description: RSpec testing standards and best practices for Rails applications. Use when writing new tests, reviewing test quality, debugging factory errors, setting up FactoryBot, or enforcing single-expectation patterns. Also use when a test fails due to factory misconfiguration, wrong association keys, or missing role traits. Triggers on phrases like "write a test", "add specs", "factory error", "test is failing", "how should I test this", or when reviewing test code in a Rails project. metadata: {"clawdbot":{"emoji":"πŸ§ͺ","requires":{"bins":["bundle"]},"os":["linux","darwin","win32"]}}

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