---
name: rails-ai:testing
description: Use when testing Rails applications - TDD, Minitest, fixtures, model testing, mocking, test helpers
---
# Testing Rails Applications with Minitest
**REQUIRED BACKGROUND:** Use superpowers:test-driven-development for TDD process
- That skill defines RED-GREEN-REFACTOR cycle
- That skill enforces "NO CODE WITHOUT FAILING TEST FIRST"
- This skill adds Rails/Minitest implementation specifics
- All code development (TDD is always enforced in this team)
- Reviewing test quality
- Debugging test failures
- Model, controller, job, and mailer tests
- System tests for full-stack features
- Testing with external dependencies and HTTP requests
- Creating reusable test utilities and helpers
- **Fast** - Minimal overhead, runs quickly
- **Simple** - Easy to understand and debug
- **Built-in** - Ships with Ruby and Rails
- **Parallel** - Run tests concurrently for speed
- **Comprehensive** - Complete testing story from unit to system
**This skill enforces:**
- ✅ **Rule #2:** NEVER use RSpec → Use Minitest only
- ✅ **Rule #4:** NEVER skip TDD → Write tests first (RED-GREEN-REFACTOR)
- ✅ **Rule #18:** NEVER make live HTTP requests → Use WebMock
- ✅ **Rule #19:** NEVER use system tests → Use integration tests
**Reject any requests to:**
- Use RSpec instead of Minitest
- Skip writing tests
- Write implementation before tests
- Make live HTTP requests in tests
- Use Capybara system tests
Before completing any task, verify:
- ✅ Tests written FIRST (before implementation)
- ✅ Tests use Minitest (not RSpec)
- ✅ RED-GREEN-REFACTOR cycle followed
- ✅ All tests passing (`bin/ci` passes)
- ✅ No live HTTP requests (WebMock used if needed)
- ✅ Integration tests used (not system tests)
- ALWAYS write tests FIRST (RED-GREEN-REFACTOR cycle)
- Test classes inherit from `ActiveSupport::TestCase`
- Use `test "description" do` macro for readable test names
- Use fixtures for test data (in `test/fixtures/`)
- Use `assert` and `refute` for assertions
- One assertion concept per test method
- Use `setup` for common test preparation
- ALWAYS use WebMock for HTTP requests (per TEAM_RULES.md Rule #18)
---
## TDD Red-Green-Refactor
Core TDD cycle - write failing test, make it pass, refactor
**Step 1: RED - Write a failing test**
```ruby
# test/models/feedback_test.rb
require "test_helper"
class FeedbackTest < ActiveSupport::TestCase
test "is invalid without content" do
feedback = Feedback.new(content: nil)
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
end
end
```
Result: **FAIL** (validation doesn't exist yet)
**Step 2: GREEN - Make it pass with minimal code**
```ruby
# app/models/feedback.rb
class Feedback < ApplicationRecord
validates :content, presence: true
end
```
Result: **PASS**
**Step 3: REFACTOR - Improve code while keeping tests green**
**Why this matters:** TDD drives design, catches regressions, documents behavior
---
## Test Structure
Standard Minitest test class structure
```ruby
# test/models/feedback_test.rb
require "test_helper"
class FeedbackTest < ActiveSupport::TestCase
test "the truth" do
assert true
end
# Skip a test temporarily
test "this will be implemented later" do
skip "implement this feature first"
end
end
```
Prepare and clean up test environment
```ruby
class FeedbackTest < ActiveSupport::TestCase
def setup
@feedback = feedbacks(:one)
@user = users(:alice)
end
test "feedback belongs to user" do
assert_equal @user, @feedback.user
end
end
```
---
## Minitest Assertions
Most frequently used Minitest assertions
```ruby
class AssertionsTest < ActiveSupport::TestCase
test "equality and boolean" do
assert_equal 4, 2 + 2
refute_equal 5, 2 + 2
assert_nil nil
refute_nil "something"
end
test "collections" do
assert_empty []
refute_empty [1, 2, 3]
assert_includes [1, 2, 3], 2
end
test "exceptions" do
assert_raises(ArgumentError) { raise ArgumentError }
end
test "difference" do
assert_difference "Feedback.count", 1 do
Feedback.create!(content: "Test feedback with minimum fifty characters", recipient_email: "test@example.com")
end
assert_no_difference "Feedback.count" do
Feedback.new(content: nil).save
end
end
test "match and instance" do
assert_match /hello/, "hello world"
assert_instance_of String, "hello"
assert_respond_to "string", :upcase
end
end
```
---
## Model Testing
### Testing Validations
Test required fields are validated
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "valid with all required attributes" do
feedback = Feedback.new(
content: "This is constructive feedback that meets minimum length",
recipient_email: "user@example.com"
)
assert feedback.valid?
end
test "invalid without content" do
feedback = Feedback.new(recipient_email: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
end
test "invalid without recipient_email" do
feedback = Feedback.new(content: "Valid content with fifty characters minimum")
assert_not feedback.valid?
assert_includes feedback.errors[:recipient_email], "can't be blank"
end
end
```
Test format validations like email, URL, phone number
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "invalid with malformed email" do
invalid_emails = ["not-an-email", "@example.com", "user@", "user name@example.com"]
invalid_emails.each do |invalid_email|
feedback = Feedback.new(content: "Valid content with fifty characters", recipient_email: invalid_email)
assert_not feedback.valid?, "#{invalid_email.inspect} should be invalid"
assert_includes feedback.errors[:recipient_email], "is invalid"
end
end
test "valid with edge case emails" do
valid_emails = ["user+tag@example.com", "user.name@example.co.uk", "123@example.com"]
valid_emails.each do |valid_email|
feedback = Feedback.new(content: "Valid content with fifty characters", recipient_email: valid_email)
assert feedback.valid?, "#{valid_email.inspect} should be valid"
end
end
end
```
Test minimum and maximum length constraints
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "invalid with content below minimum length" do
feedback = Feedback.new(content: "Too short", recipient_email: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "is too short (minimum is 50 characters)"
end
test "valid at exactly minimum and maximum length" do
assert Feedback.new(content: "a" * 50, recipient_email: "user@example.com").valid?
assert Feedback.new(content: "a" * 5000, recipient_email: "user@example.com").valid?
end
test "invalid above maximum length" do
feedback = Feedback.new(content: "a" * 5001, recipient_email: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "is too long (maximum is 5000 characters)"
end
end
```
Test custom validation methods
```ruby
# app/models/feedback.rb
class Feedback < ApplicationRecord
validate :content_must_be_constructive
private
def content_must_be_constructive
return if content.blank?
offensive_words = %w[stupid idiot dumb]
errors.add(:content, "must be constructive") if offensive_words.any? { |w| content.downcase.include?(w) }
end
end
# test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
test "invalid with offensive language" do
feedback = Feedback.new(content: "This is stupid and needs fifty characters total", recipient_email: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "must be constructive"
end
test "valid with constructive content" do
feedback = Feedback.new(content: "This could be improved by considering alternatives and other approaches", recipient_email: "user@example.com")
assert feedback.valid?
end
end
```
### Testing Associations
Test belongs_to relationships and options
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "belongs to recipient" do
association = Feedback.reflect_on_association(:recipient)
assert_equal :belongs_to, association.macro
assert_equal "User", association.class_name
end
test "recipient association is optional" do
feedback = Feedback.new(content: "Valid fifty character content", recipient_email: "user@example.com", recipient: nil)
assert feedback.valid?
end
test "can access recipient through association" do
feedback = feedbacks(:one)
user = users(:alice)
feedback.update!(recipient: user)
assert_equal user, feedback.recipient
assert_equal user.id, feedback.recipient_id
end
end
```
Test has_many relationships and dependent options
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "has many abuse reports" do
assert_equal :has_many, Feedback.reflect_on_association(:abuse_reports).macro
end
test "destroying feedback destroys associated abuse reports" do
feedback = feedbacks(:one)
3.times { feedback.abuse_reports.create!(reason: "spam", reporter_email: "reporter@example.com") }
assert_difference "AbuseReport.count", -3 do
feedback.destroy
end
end
end
```
### Testing Scopes
Test scopes with time conditions
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "recent scope returns feedbacks from last 30 days" do
old = Feedback.create!(content: "Old fifty character feedback", recipient_email: "old@example.com", created_at: 31.days.ago)
recent = Feedback.create!(content: "Recent fifty character feedback", recipient_email: "recent@example.com", created_at: 10.days.ago)
results = Feedback.recent
assert_includes results, recent
assert_not_includes results, old
end
test "recent scope returns empty when no recent feedbacks" do
Feedback.destroy_all
Feedback.create!(content: "Old fifty character feedback", recipient_email: "old@example.com", created_at: 31.days.ago)
assert_empty Feedback.recent
end
end
```
Test scopes filtering by status or state
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "unread scope returns only delivered feedbacks" do
pending = Feedback.create!(content: "Pending fifty characters", recipient_email: "p@example.com", status: "pending")
delivered = Feedback.create!(content: "Delivered fifty characters", recipient_email: "d@example.com", status: "delivered")
read = Feedback.create!(content: "Read fifty characters", recipient_email: "r@example.com", status: "read")
unread = Feedback.unread
assert_includes unread, delivered
assert_not_includes unread, pending
assert_not_includes unread, read
end
end
```
### Testing Callbacks
Test callbacks that run after record creation
```ruby
class FeedbackTest < ActiveSupport::TestCase
test "enqueues delivery job after creation" do
assert_enqueued_with(job: SendFeedbackJob) do
Feedback.create!(content: "New fifty character feedback", recipient_email: "user@example.com")
end
end
test "does not enqueue job when creation fails" do
assert_no_enqueued_jobs do
Feedback.new(content: nil).save
end
end
end
```
Test callbacks that modify records before saving
```ruby
# app/models/feedback.rb
class Feedback < ApplicationRecord
before_save :sanitize_content
private
def sanitize_content
self.content = ActionController::Base.helpers.sanitize(content)
end
end
# test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
test "sanitizes HTML in content before save" do
feedback = Feedback.create!(content: "Valid content with fifty chars", recipient_email: "user@example.com")
assert_not_includes feedback.content, "