- Merge dhh-ruby-style into dhh-rails-style for comprehensive Rails conventions - Add testing.md reference covering Rails testing patterns - Add /feature-video command for recording PR demo videos - Update docs and component counts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
339 lines
6.9 KiB
Markdown
339 lines
6.9 KiB
Markdown
# Testing - DHH Rails Style
|
|
|
|
## Core Philosophy
|
|
|
|
"Minitest with fixtures - simple, fast, deterministic." The approach prioritizes pragmatism over convention.
|
|
|
|
## Why Minitest Over RSpec
|
|
|
|
- **Simpler**: Less DSL magic, plain Ruby assertions
|
|
- **Ships with Rails**: No additional dependencies
|
|
- **Faster boot times**: Less overhead
|
|
- **Plain Ruby**: No specialized syntax to learn
|
|
|
|
## Fixtures as Test Data
|
|
|
|
Rather than factories, fixtures provide preloaded data:
|
|
- Loaded once, reused across tests
|
|
- No runtime object creation overhead
|
|
- Explicit relationship visibility
|
|
- Deterministic IDs for easier debugging
|
|
|
|
### Fixture Structure
|
|
```yaml
|
|
# test/fixtures/users.yml
|
|
david:
|
|
identity: david
|
|
account: basecamp
|
|
role: admin
|
|
|
|
jason:
|
|
identity: jason
|
|
account: basecamp
|
|
role: member
|
|
|
|
# test/fixtures/rooms.yml
|
|
watercooler:
|
|
name: Water Cooler
|
|
creator: david
|
|
direct: false
|
|
|
|
# test/fixtures/messages.yml
|
|
greeting:
|
|
body: Hello everyone!
|
|
room: watercooler
|
|
creator: david
|
|
```
|
|
|
|
### Using Fixtures in Tests
|
|
```ruby
|
|
test "sending a message" do
|
|
user = users(:david)
|
|
room = rooms(:watercooler)
|
|
|
|
# Test with fixture data
|
|
end
|
|
```
|
|
|
|
### Dynamic Fixture Values
|
|
ERB enables time-sensitive data:
|
|
```yaml
|
|
recent_card:
|
|
title: Recent Card
|
|
created_at: <%= 1.hour.ago %>
|
|
|
|
old_card:
|
|
title: Old Card
|
|
created_at: <%= 1.month.ago %>
|
|
```
|
|
|
|
## Test Organization
|
|
|
|
### Unit Tests
|
|
Verify business logic using setup blocks and standard assertions:
|
|
|
|
```ruby
|
|
class CardTest < ActiveSupport::TestCase
|
|
setup do
|
|
@card = cards(:one)
|
|
@user = users(:david)
|
|
end
|
|
|
|
test "closing a card creates a closure" do
|
|
assert_difference -> { Card::Closure.count } do
|
|
@card.close(creator: @user)
|
|
end
|
|
|
|
assert @card.closed?
|
|
assert_equal @user, @card.closure.creator
|
|
end
|
|
|
|
test "reopening a card destroys the closure" do
|
|
@card.close(creator: @user)
|
|
|
|
assert_difference -> { Card::Closure.count }, -1 do
|
|
@card.reopen
|
|
end
|
|
|
|
refute @card.closed?
|
|
end
|
|
end
|
|
```
|
|
|
|
### Integration Tests
|
|
Test full request/response cycles:
|
|
|
|
```ruby
|
|
class CardsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
@user = users(:david)
|
|
sign_in @user
|
|
end
|
|
|
|
test "closing a card" do
|
|
card = cards(:one)
|
|
|
|
post card_closure_path(card)
|
|
|
|
assert_response :success
|
|
assert card.reload.closed?
|
|
end
|
|
|
|
test "unauthorized user cannot close card" do
|
|
sign_in users(:guest)
|
|
card = cards(:one)
|
|
|
|
post card_closure_path(card)
|
|
|
|
assert_response :forbidden
|
|
refute card.reload.closed?
|
|
end
|
|
end
|
|
```
|
|
|
|
### System Tests
|
|
Browser-based tests using Capybara:
|
|
|
|
```ruby
|
|
class MessagesTest < ApplicationSystemTestCase
|
|
test "sending a message" do
|
|
sign_in users(:david)
|
|
visit room_path(rooms(:watercooler))
|
|
|
|
fill_in "Message", with: "Hello, world!"
|
|
click_button "Send"
|
|
|
|
assert_text "Hello, world!"
|
|
end
|
|
|
|
test "editing own message" do
|
|
sign_in users(:david)
|
|
visit room_path(rooms(:watercooler))
|
|
|
|
within "#message_#{messages(:greeting).id}" do
|
|
click_on "Edit"
|
|
end
|
|
|
|
fill_in "Message", with: "Updated message"
|
|
click_button "Save"
|
|
|
|
assert_text "Updated message"
|
|
end
|
|
|
|
test "drag and drop card to new column" do
|
|
sign_in users(:david)
|
|
visit board_path(boards(:main))
|
|
|
|
card = find("#card_#{cards(:one).id}")
|
|
target = find("#column_#{columns(:done).id}")
|
|
|
|
card.drag_to target
|
|
|
|
assert_selector "#column_#{columns(:done).id} #card_#{cards(:one).id}"
|
|
end
|
|
end
|
|
```
|
|
|
|
## Advanced Patterns
|
|
|
|
### Time Testing
|
|
Use `travel_to` for deterministic time-dependent assertions:
|
|
|
|
```ruby
|
|
test "card expires after 30 days" do
|
|
card = cards(:one)
|
|
|
|
travel_to 31.days.from_now do
|
|
assert card.expired?
|
|
end
|
|
end
|
|
```
|
|
|
|
### External API Testing with VCR
|
|
Record and replay HTTP interactions:
|
|
|
|
```ruby
|
|
test "fetches user data from API" do
|
|
VCR.use_cassette("user_api") do
|
|
user_data = ExternalApi.fetch_user(123)
|
|
|
|
assert_equal "John", user_data[:name]
|
|
end
|
|
end
|
|
```
|
|
|
|
### Background Job Testing
|
|
Assert job enqueueing and email delivery:
|
|
|
|
```ruby
|
|
test "closing card enqueues notification job" do
|
|
card = cards(:one)
|
|
|
|
assert_enqueued_with(job: NotifyWatchersJob, args: [card]) do
|
|
card.close
|
|
end
|
|
end
|
|
|
|
test "welcome email is sent on signup" do
|
|
assert_emails 1 do
|
|
Identity.create!(email: "new@example.com")
|
|
end
|
|
end
|
|
```
|
|
|
|
### Testing Turbo Streams
|
|
```ruby
|
|
test "message creation broadcasts to room" do
|
|
room = rooms(:watercooler)
|
|
|
|
assert_turbo_stream_broadcasts [room, :messages] do
|
|
room.messages.create!(body: "Test", creator: users(:david))
|
|
end
|
|
end
|
|
```
|
|
|
|
## Testing Principles
|
|
|
|
### 1. Test Observable Behavior
|
|
Focus on what the code does, not how it does it:
|
|
|
|
```ruby
|
|
# ❌ Testing implementation
|
|
test "calls notify method on each watcher" do
|
|
card.expects(:notify).times(3)
|
|
card.close
|
|
end
|
|
|
|
# ✅ Testing behavior
|
|
test "watchers receive notifications when card closes" do
|
|
assert_difference -> { Notification.count }, 3 do
|
|
card.close
|
|
end
|
|
end
|
|
```
|
|
|
|
### 2. Don't Mock Everything
|
|
|
|
```ruby
|
|
# ❌ Over-mocked test
|
|
test "sending message" do
|
|
room = mock("room")
|
|
user = mock("user")
|
|
message = mock("message")
|
|
|
|
room.expects(:messages).returns(stub(create!: message))
|
|
message.expects(:broadcast_create)
|
|
|
|
MessagesController.new.create
|
|
end
|
|
|
|
# ✅ Test the real thing
|
|
test "sending message" do
|
|
sign_in users(:david)
|
|
post room_messages_url(rooms(:watercooler)),
|
|
params: { message: { body: "Hello" } }
|
|
|
|
assert_response :success
|
|
assert Message.exists?(body: "Hello")
|
|
end
|
|
```
|
|
|
|
### 3. Tests Ship with Features
|
|
Same commit, not TDD-first but together. Neither before (strict TDD) nor after (deferred testing).
|
|
|
|
### 4. Security Fixes Always Include Regression Tests
|
|
Every security fix must include a test that would have caught the vulnerability.
|
|
|
|
### 5. Integration Tests Validate Complete Workflows
|
|
Don't just test individual pieces - test that they work together.
|
|
|
|
## File Organization
|
|
|
|
```
|
|
test/
|
|
├── controllers/ # Integration tests for controllers
|
|
├── fixtures/ # YAML fixtures for all models
|
|
├── helpers/ # Helper method tests
|
|
├── integration/ # API integration tests
|
|
├── jobs/ # Background job tests
|
|
├── mailers/ # Mailer tests
|
|
├── models/ # Unit tests for models
|
|
├── system/ # Browser-based system tests
|
|
└── test_helper.rb # Test configuration
|
|
```
|
|
|
|
## Test Helper Setup
|
|
|
|
```ruby
|
|
# test/test_helper.rb
|
|
ENV["RAILS_ENV"] ||= "test"
|
|
require_relative "../config/environment"
|
|
require "rails/test_help"
|
|
|
|
class ActiveSupport::TestCase
|
|
fixtures :all
|
|
|
|
parallelize(workers: :number_of_processors)
|
|
end
|
|
|
|
class ActionDispatch::IntegrationTest
|
|
include SignInHelper
|
|
end
|
|
|
|
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
|
driven_by :selenium, using: :headless_chrome
|
|
end
|
|
```
|
|
|
|
## Sign In Helper
|
|
|
|
```ruby
|
|
# test/support/sign_in_helper.rb
|
|
module SignInHelper
|
|
def sign_in(user)
|
|
session = user.identity.sessions.create!
|
|
cookies.signed[:session_id] = session.id
|
|
end
|
|
end
|
|
```
|