🎁 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
Downloads507
Installs1
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