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:
bashrg "describe.*ClassName" spec/
- •
Search for existing factories before creating new ones:
bashrg "factory.*:model_name" spec/factories/
- •
If factory exists, read it to understand available traits:
ruby# 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):rubyuser = 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:rubyuser = build_stubbed(:user) # Has stubbed id and timestamps
Use for: when id needed (URLs, associations), read operations, maximum performance
Use
createonly when DB persistence is required:rubyuser = create(:user) # Persisted to DB
Use for: DB queries, counting records, uniqueness validations, actual persistence testing
- •
Use traits when available (
:published,:with_comments, etc.) - •
Use only relevant attributes for factories:
ruby# 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:
rubyallow(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_calledinstead - •
allow_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