| name | ruby-testing |
| description | Ruby/RSpec testing guidelines and workflow following project conventions. Use when writing or improving Ruby tests. |
| allowed-tools | Grep, Glob, Read, Write, Edit, Bash(bundle exec rspec:*), Bash(bin/rspec:*), Bash(rg:*) |
Ruby Testing
Overview
Provides workflow for writing RSpec tests following project conventions: test behavior not implementation, use FactoryBot with traits, separate test phases clearly, and avoid common anti-patterns.
Core Principles
Test behavior, not code:
- Focus on WHAT the code does, not HOW it does it
- Test public interfaces and observable outcomes
- NEVER test private methods directly
Test structure:
- Setup → Exercise → Verify → Teardown
- Separate phases with blank lines (no phase comments)
- Self-contained tests
Workflow
Step 1: Search for Existing Test Files and Factories
Before writing anything:
Use Grep to find existing test files:
rg "describe.*ClassName" spec/Search for existing factories before creating new ones:
rg "factory.*:model_name" spec/factories/If factory exists, read it to understand available traits:
# Check for traits like :with_user, :published, etc.
Step 2: Structure the Test File
For new specs (APPLIES ONLY TO NEW SPECS):
RSpec.describe ClassName do
# Define helper methods at top if needed, avoid single line helpers
def prepare_tester_with_preferences
user = build(:user, name: "Test")
build(:preferences, user: )
# Other repeating operations
end
describe "#method_name" do
context "when condition" do
it "describes expected behavior" do
# Test phases here
end
end
context "when different condition" do
# More tests
end
end
end
Conventions for new specs:
- NO
letorlet!- define variables directly:user = build(:user) - NO
beforeorafterhooks - use named methods instead - context naming: Start with "when" or "with", nested with "and", max 2 levels deep
- it blocks: Describe expected behavior clearly
- For classes with only
#callor.call, omit the method describe block
For existing specs:
- Follow the existing file's patterns (
let,before, etc. are okay if already used) - Maintain consistency within the file
Step 3: Write Test - Setup Phase
Create test data using FactoryBot with appropriate method:
Prefer
build(default, no DB):user = build(:user) post = build(:post, :published, author: user)Use for: validations, testing unsaved state, before_save callbacks
Use
build_stubbedwhen you need id/timestamps without DB:user = build_stubbed(:user) # Has stubbed id and timestampsUse for: when id needed (URLs, associations), read operations, maximum performance
Use
createonly when DB persistence is required:user = create(:user) # Persisted to DBUse for: DB queries, counting records, uniqueness validations, actual persistence testing
Use traits when available (
:published,:with_comments, etc.)Use only relevant attributes for factories:
# Good user = build(:user, email: "test@example.com") # Bad - unnecessary attributes user = build(:user, email: "test@example.com", name: "John", age: 30)Stub external dependencies:
allow(UserCreator).to receive(:create).and_return(user)
Blank line after setup phase
Step 4: Write Test - Exercise Phase
Execute the code under test:
result = MyService.call(user)
For tests that change data stores (database, cache), wrap in lambda:
action = -> { MyService.call(user) }
Blank line after exercise phase
Step 5: Write Test - Verify Phase
For regular tests:
expect(result).to be_successful
expect(result.value).to eq(expected_value)
For tests with data store changes:
expect(&action).to change(User, :count).by(1)
expect(&action).to change { user.reload.status }.from("pending").to("active")
For verifying method calls - use have_called:
# First stub the method
allow(UserCreator).to receive(:create)
# Then call the code
MyService.call
# Then verify with have_called
expect(UserCreator).to have_called(:create).with(email: "test@example.com")
NEVER use:
expect(...).to receive(...)- useallowthenhave_calledinsteadallow_any_instance_of- stub specific instances instead
Blank line after verify phase (if teardown exists)
Step 6: Review Against Anti-Patterns
Before finishing, check:
- Not testing private methods
- Not using
allow_any_instance_of - Not using
expect().to receive(usehave_calledinstead) - Phases separated with blank lines
- No phase comments (setup, exercise, verify should be obvious from structure)
- Using existing factories (searched before creating new ones)
- Using factory traits appropriately
- For new specs: no
let/let!, nobefore/afterhooks - context naming follows "when"/"with"/"and" pattern
- Max 2 context nesting levels