From rails-ai
Enforces TDD with Minitest for Rails: fixtures, model/controller tests, WebMock HTTP mocking, integration tests; rejects RSpec/system tests.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rails-ai:skills/testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
<superpowers-integration>
Reject any requests to:
Step 1: RED - Write a failing test
# 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
# 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/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
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
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: "[email protected]")
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
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: "[email protected]"
)
assert feedback.valid?
end
test "invalid without content" do
feedback = Feedback.new(recipient_email: "[email protected]")
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
class FeedbackTest < ActiveSupport::TestCase
test "invalid with malformed email" do
invalid_emails = ["not-an-email", "@example.com", "user@", "user [email protected]"]
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 = ["[email protected]", "[email protected]", "[email protected]"]
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
class FeedbackTest < ActiveSupport::TestCase
test "invalid with content below minimum length" do
feedback = Feedback.new(content: "Too short", recipient_email: "[email protected]")
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: "[email protected]").valid?
assert Feedback.new(content: "a" * 5000, recipient_email: "[email protected]").valid?
end
test "invalid above maximum length" do
feedback = Feedback.new(content: "a" * 5001, recipient_email: "[email protected]")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "is too long (maximum is 5000 characters)"
end
end
Test custom validation methods
# 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: "[email protected]")
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: "[email protected]")
assert feedback.valid?
end
end
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: "[email protected]", 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
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: "[email protected]") }
assert_difference "AbuseReport.count", -3 do
feedback.destroy
end
end
end
class FeedbackTest < ActiveSupport::TestCase
test "recent scope returns feedbacks from last 30 days" do
old = Feedback.create!(content: "Old fifty character feedback", recipient_email: "[email protected]", created_at: 31.days.ago)
recent = Feedback.create!(content: "Recent fifty character feedback", recipient_email: "[email protected]", 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: "[email protected]", created_at: 31.days.ago)
assert_empty Feedback.recent
end
end
Test scopes filtering by status or state
class FeedbackTest < ActiveSupport::TestCase
test "unread scope returns only delivered feedbacks" do
pending = Feedback.create!(content: "Pending fifty characters", recipient_email: "[email protected]", status: "pending")
delivered = Feedback.create!(content: "Delivered fifty characters", recipient_email: "[email protected]", status: "delivered")
read = Feedback.create!(content: "Read fifty characters", recipient_email: "[email protected]", status: "read")
unread = Feedback.unread
assert_includes unread, delivered
assert_not_includes unread, pending
assert_not_includes unread, read
end
end
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: "[email protected]")
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
# 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: "<script>alert('xss')</script>Valid content with fifty chars", recipient_email: "[email protected]")
assert_not_includes feedback.content, "<script>"
assert_includes feedback.content, "Valid"
end
end
class FeedbackTest < ActiveSupport::TestCase
test "mark_as_delivered! updates status and timestamp" do
feedback = feedbacks(:pending)
assert_equal "pending", feedback.status
assert_nil feedback.delivered_at
feedback.mark_as_delivered!
assert_equal "delivered", feedback.status
assert_not_nil feedback.delivered_at
assert_in_delta Time.current, feedback.delivered_at, 1.second
end
end
class FeedbackTest < ActiveSupport::TestCase
test "defines status enum with correct values" do
assert_equal "pending", Feedback.statuses[:status_pending]
assert_equal "delivered", Feedback.statuses[:status_delivered]
assert_equal "read", Feedback.statuses[:status_read]
assert_equal "responded", Feedback.statuses[:status_responded]
end
test "enum provides predicate methods with prefix" do
feedback = Feedback.create!(content: "Test feedback with fifty characters minimum", recipient_email: "[email protected]", status: "pending")
assert feedback.status_pending?
assert_not feedback.status_delivered?
end
test "enum provides bang methods to change state" do
feedback = feedbacks(:pending)
feedback.status_delivered!
assert feedback.status_delivered?
assert_equal "delivered", feedback.status
end
test "can query by enum state" do
pending = Feedback.create!(content: "Pending fifty chars", recipient_email: "[email protected]", status: "pending")
delivered = Feedback.create!(content: "Delivered fifty chars", recipient_email: "[email protected]", status: "delivered")
results = Feedback.status_pending
assert_includes results, pending
assert_not_includes results, delivered
end
end
# app/models/feedback.rb
class Feedback < ApplicationRecord
def self.needs_followup
where(status: "delivered").where("delivered_at < ?", 7.days.ago).where.missing(:response)
end
end
# test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
test "needs_followup returns delivered feedbacks without response" do
needs = Feedback.create!(content: "Needs fifty chars", recipient_email: "[email protected]", status: "delivered", delivered_at: 10.days.ago)
has_resp = Feedback.create!(content: "Has fifty chars", recipient_email: "[email protected]", status: "delivered", delivered_at: 10.days.ago)
has_resp.create_response!(content: "Thank you")
too_recent = Feedback.create!(content: "Recent fifty chars", recipient_email: "[email protected]", status: "delivered", delivered_at: 3.days.ago)
results = Feedback.needs_followup
assert_includes results, needs
assert_not_includes results, has_resp
assert_not_includes results, too_recent
end
end
Test class methods that perform calculations
# app/models/feedback.rb
class Feedback < ApplicationRecord
def self.average_response_time
joins(:response).average("EXTRACT(EPOCH FROM (feedback_responses.created_at - feedbacks.created_at))").to_i
end
end
# test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
test "average_response_time calculates correct average" do
f1 = Feedback.create!(content: "First fifty chars", recipient_email: "[email protected]", created_at: 5.days.ago)
f1.create_response!(content: "R1", created_at: 4.days.ago)
f2 = Feedback.create!(content: "Second fifty chars", recipient_email: "[email protected]", created_at: 5.days.ago)
f2.create_response!(content: "R2", created_at: 3.days.ago)
assert_in_delta 129600, Feedback.average_response_time, 60
end
test "average_response_time returns nil when no responses" do
Feedback.destroy_all
Feedback.create!(content: "No response fifty chars", recipient_email: "[email protected]")
assert_nil Feedback.average_response_time
end
end
class FeedbackTest < ActiveSupport::TestCase
test "handles empty collections gracefully" do
feedback = Feedback.create!(content: "Feedback fifty chars", recipient_email: "[email protected]")
assert_empty feedback.abuse_reports
assert_equal 0, feedback.abuse_reports.count
end
test "handles nil associations gracefully" do
feedback = Feedback.create!(content: "Feedback fifty chars", recipient_email: "[email protected]", recipient: nil)
assert_nil feedback.recipient
assert_nothing_raised { feedback.recipient&.name }
end
test "handles unicode content correctly" do
unicode = "Emoji feedback 😀 with unicode 日本語 and fifty+ characters"
feedback = Feedback.create!(content: unicode, recipient_email: "[email protected]")
assert_equal unicode, feedback.reload.content
end
end
Test proper error handling and exception cases
class FeedbackTest < ActiveSupport::TestCase
test "handles nil arguments in query methods" do
feedback = feedbacks(:one)
assert_nothing_raised do
result = feedback.readable_by?(nil)
assert_not result
end
end
test "raises appropriate error for invalid state transition" do
feedback = feedbacks(:one)
def feedback.invalid_transition!
raise ActiveRecord::RecordInvalid.new(self)
end
assert_raises(ActiveRecord::RecordInvalid) do
feedback.invalid_transition!
end
end
end
class FeedbacksControllerTest < ActionDispatch::IntegrationTest
test "GET index returns success" do
get feedbacks_url
assert_response :success
end
test "GET show displays feedback" do
get feedback_url(feedbacks(:one))
assert_response :success
end
test "POST create with valid params creates feedback" do
assert_difference("Feedback.count", 1) do
post feedbacks_url, params: { feedback: { content: "New feedback with fifty characters minimum", recipient_email: "[email protected]" } }
end
assert_redirected_to feedback_url(Feedback.last)
end
test "POST create with invalid params does not create feedback" do
assert_no_difference("Feedback.count") do
post feedbacks_url, params: { feedback: { content: nil } }
end
assert_response :unprocessable_entity
end
test "DELETE destroy removes feedback" do
assert_difference("Feedback.count", -1) do
delete feedback_url(feedbacks(:one))
end
assert_redirected_to feedbacks_url
end
end
require "application_system_test_case"
class FeedbacksTest < ApplicationSystemTestCase
test "creating a feedback" do
visit feedbacks_url
click_on "New Feedback"
fill_in "Content", with: "This is great feedback with enough characters"
fill_in "Recipient email", with: "[email protected]"
click_on "Create Feedback"
assert_text "Feedback was successfully created"
end
test "editing a feedback" do
visit feedback_url(feedbacks(:one))
click_on "Edit"
fill_in "Content", with: "Updated content with minimum fifty characters required"
click_on "Update Feedback"
assert_text "Feedback was successfully updated"
end
end
Fixture File:
# test/fixtures/users.yml
alice:
name: Alice Johnson
email: [email protected]
active: true
created_at: <%= 1.week.ago %>
bob:
name: Bob Smith
email: [email protected]
active: true
created_at: <%= 2.weeks.ago %>
Accessing Fixtures:
class UserTest < ActiveSupport::TestCase
test "accessing fixtures by name" do
alice = users(:alice)
assert_equal "Alice Johnson", alice.name
assert alice.persisted?
end
test "accessing multiple fixtures at once" do
alice, bob = users(:alice, :bob)
assert_equal "Alice Johnson", alice.name
end
end
Define associations between fixtures using names
Fixture Files:
# test/fixtures/users.yml
alice:
name: Alice Johnson
email: [email protected]
bob:
name: Bob Smith
email: [email protected]
# test/fixtures/feedbacks.yml
one:
content: This is great feedback with minimum fifty characters!
recipient_email: [email protected]
sender: alice # ✅ References users fixture by name
status: pending
created_at: <%= 1.day.ago %>
two:
content: Could be improved with additional context and details
recipient_email: [email protected]
sender: bob
status: responded
created_at: <%= 3.days.ago %>
Testing Associations:
class AssociationFixturesTest < ActiveSupport::TestCase
test "fixtures handle associations automatically" do
feedback = feedbacks(:one)
assert_equal users(:alice), feedback.sender
assert_equal "[email protected]", feedback.sender.email
end
test "has_many associations work through fixtures" do
alice = users(:alice)
assert alice.feedbacks.exists?
assert_includes alice.feedbacks, feedbacks(:one)
end
end
Use ERB for dynamic values and calculations
Fixture with ERB:
# test/fixtures/products.yml
tshirt:
name: T-Shirt
price: <%= 19.99 %>
inventory_count: 15
sku: <%= "TSH-#{SecureRandom.hex(4)}" %>
created_at: <%= Time.current %>
shoes:
name: Running Shoes
price: <%= 89.99 %>
inventory_count: 0
on_sale: <%= true %>
sale_price: <%= 89.99 * 0.8 %> # 20% off
created_at: <%= 3.months.ago %>
Testing Dynamic Values:
class ERBFixturesTest < ActiveSupport::TestCase
test "ERB is evaluated in fixtures" do
tshirt = products(:tshirt)
assert_equal 19.99, tshirt.price
assert tshirt.created_at
assert tshirt.sku.present?
end
test "dynamic calculations work" do
shoes = products(:shoes)
assert shoes.on_sale?
assert_in_delta 71.99, shoes.sale_price, 0.01
end
end
class SendFeedbackJobTest < ActiveJob::TestCase
test "enqueues job with correct arguments" do
feedback = feedbacks(:one)
assert_enqueued_with(job: SendFeedbackJob, args: [feedback]) do
SendFeedbackJob.perform_later(feedback)
end
end
test "performs job successfully" do
feedback = feedbacks(:one)
assert_difference "ActionMailer::Base.deliveries.size", 1 do
SendFeedbackJob.perform_now(feedback)
end
assert_equal "delivered", feedback.reload.status
end
test "handles job failures gracefully" do
feedback = feedbacks(:one)
# Simulate external service failure
EmailService.stub :send_feedback, -> (*) { raise StandardError.new("Service down") } do
assert_raises(StandardError) do
SendFeedbackJob.perform_now(feedback)
end
end
# Status should not change on failure
assert_equal "pending", feedback.reload.status
end
end
Test email delivery and content
class FeedbackMailerTest < ActionMailer::TestCase
test "notification email has correct content" do
feedback = feedbacks(:one)
email = FeedbackMailer.notification(feedback)
assert_emails 1 do
email.deliver_now
end
assert_equal ["[email protected]"], email.from
assert_equal [feedback.recipient_email], email.to
assert_equal "New Feedback Received", email.subject
assert_match feedback.content, email.body.encoded
end
test "includes unsubscribe link" do
feedback = feedbacks(:one)
email = FeedbackMailer.notification(feedback)
assert_match /unsubscribe/, email.body.encoded
end
test "uses correct email template" do
feedback = feedbacks(:one)
email = FeedbackMailer.notification(feedback)
assert_match "feedback/notification", email.body.encoded
end
end
Fixtures:
# test/fixtures/comments.yml
feedback_comment:
content: Great feedback!
commentable: one (Feedback) # Polymorphic association
user: alice
created_at: <%= 1.day.ago %>
article_comment:
content: Interesting article
commentable: first_article (Article) # Different type
user: bob
created_at: <%= 2.days.ago %>
Testing:
class PolymorphicFixturesTest < ActiveSupport::TestCase
test "polymorphic associations in fixtures" do
feedback_comment = comments(:feedback_comment)
article_comment = comments(:article_comment)
assert_instance_of Feedback, feedback_comment.commentable
assert_instance_of Article, article_comment.commentable
assert_equal "Feedback", feedback_comment.commentable_type
end
end
Share reusable logic across fixtures with helper methods
Define Helpers:
# test/test_helper.rb
module FixtureFileHelpers
def default_avatar_url
"https://example.com/default-avatar.png"
end
def formatted_date(date)
date.strftime("%Y-%m-%d")
end
def default_password_digest
BCrypt::Password.create("password123", cost: 4)
end
def admin_permissions
%w[read write delete admin].to_json
end
end
# Make helpers available to fixtures
ActiveRecord::FixtureSet.context_class.include FixtureFileHelpers
Use in Fixtures:
# test/fixtures/users.yml
david:
name: David
email: [email protected]
avatar_url: <%= default_avatar_url %>
registered_on: <%= formatted_date(1.month.ago) %>
password_digest: <%= default_password_digest %>
admin:
name: Admin User
email: [email protected]
permissions: <%= admin_permissions %>
Testing:
class FixtureHelpersTest < ActiveSupport::TestCase
test "uses fixture helper methods" do
david = users(:david)
assert_equal "https://example.com/default-avatar.png", david.avatar_url
assert BCrypt::Password.new(david.password_digest).is_password?("password123")
end
end
Load only specific fixtures for test classes
Load All (Default):
# test/test_helper.rb
class ActiveSupport::TestCase
fixtures :all # Load all fixtures
self.use_transactional_tests = true
end
Load Specific:
# test/models/feedback_test.rb
class FeedbackTest < ActiveSupport::TestCase
fixtures :users, :feedbacks # Only specific fixtures
test "only users and feedbacks are loaded" do
assert users(:alice)
assert feedbacks(:one)
end
end
Disable Fixtures:
# test/models/manual_test.rb
class ManualTest < ActiveSupport::TestCase
self.use_instantiated_fixtures = false
def setup
@user = User.create!(name: "Manual User", email: "[email protected]")
end
test "uses manually created data" do
assert @user.persisted?
end
end
class FeedbackTest < ActiveSupport::TestCase
test "stubs instance method" do
user = users(:alice)
user.stub :name, "Stubbed Name" do
assert_equal "Stubbed Name", user.name
end
assert_equal "Alice Johnson", user.name # Restored after block
end
test "stubs with lambda for dynamic return" do
feedback = feedbacks(:one)
feedback.stub :content, -> { "Dynamic: #{Time.current}" } do
assert_match /^Dynamic:/, feedback.content
end
end
end
Key Points:
class MinitestMockTest < ActiveSupport::TestCase
test "creates mock object" do
mock = Minitest::Mock.new
mock.expect :call, "mocked result", ["arg1", "arg2"]
result = mock.call("arg1", "arg2")
assert_equal "mocked result", result
mock.verify # REQUIRED
end
test "uses assert_mock for auto-verification" do
mock = Minitest::Mock.new
mock.expect :call, "result"
assert_mock mock do
mock.call
end # Automatically calls verify
end
end
Important: Always call mock.verify or use assert_mock to ensure expectations were met.
Setup:
# Gemfile
gem "webmock", group: :test
# test/test_helper.rb
require "webmock/minitest"
Basic HTTP Stubs:
class WebMockTest < ActiveSupport::TestCase
test "stubs HTTP GET request" do
stub_request(:get, "https://api.example.com/feedback")
.to_return(status: 200, body: '{"status":"success"}')
response = Net::HTTP.get(URI("https://api.example.com/feedback"))
assert_equal '{"status":"success"}', response
end
test "stubs POST with body matching" do
stub_request(:post, "https://api.example.com/ai/improve")
.with(body: hash_including(content: "Test feedback"))
.to_return(status: 200, body: '{"improved":"Enhanced"}')
end
test "simulates timeout" do
stub_request(:get, "https://api.example.com/slow").to_timeout
assert_raises(Net::OpenTimeout) do
Net::HTTP.get(URI("https://api.example.com/slow"))
end
end
test "verifies HTTP request was made" do
stub_request(:get, "https://api.example.com/check").to_return(status: 200)
Net::HTTP.get(URI("https://api.example.com/check"))
assert_requested :get, "https://api.example.com/check", times: 1
end
end
Stub external API clients and third-party services
class ExternalDependenciesTest < ActiveSupport::TestCase
test "stubs external API client" do
AIService.stub :improve_content, "Improved content" do
result = AIService.improve_content(feedbacks(:one).content)
assert_equal "Improved content", result
end
end
test "simulates external service error" do
AIService.stub :improve_content, -> (*) { raise StandardError.new("API Error") } do
assert_raises(StandardError) { AIService.improve_content("test") }
end
end
end
Design for testability with dependency injection
Bad - Hard to test:
# ❌ BAD
class FeedbackProcessorBad
def process(feedback)
improved = AIService.improve_content(feedback.content)
feedback.update!(content: improved)
end
end
Good - Dependency injection:
# ✅ GOOD
class FeedbackProcessorGood
def initialize(ai_service: AIService)
@ai_service = ai_service
end
def process(feedback)
improved = @ai_service.improve_content(feedback.content)
feedback.update!(content: improved)
end
end
Test:
class DependencyInjectionTest < ActiveSupport::TestCase
test "uses dependency injection instead of mocking" do
fake_ai_service = Object.new
def fake_ai_service.improve_content(content)
"Improved: #{content}"
end
processor = FeedbackProcessorGood.new(ai_service: fake_ai_service)
processor.process(feedbacks(:one))
assert_match /^Improved:/, feedbacks(:one).content
end
end
test/test_helper.rb:
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"
module ActiveSupport
class TestCase
parallelize(workers: :number_of_processors)
fixtures :all
# Include custom test helpers globally
include TestHelpers::Authentication
include TestHelpers::ApiHelpers
include TestHelpers::AssertionHelpers
end
end
Rails.logger.level = Logger::WARN
Simplify user authentication in controller and integration tests
test/test_helpers/authentication.rb:
module TestHelpers
module Authentication
def sign_in_as(user)
post sign_in_url, params: { email: user.email, password: "password" }
end
def sign_out
delete sign_out_url
end
def signed_in?
session[:user_id].present?
end
def create_and_sign_in_user(**attrs)
user = User.create!({ name: "Test", email: "[email protected]", password: "password" }.merge(attrs))
sign_in_as(user)
user
end
end
end
Usage:
class ProfileControllerTest < ActionDispatch::IntegrationTest
test "shows profile when signed in" do
sign_in_as users(:alice)
get profile_url
assert_response :success
end
end
Streamline API testing with JSON parsing and authenticated requests
test/test_helpers/api_helpers.rb:
module TestHelpers
module ApiHelpers
def json_response
JSON.parse(response.body)
end
def api_get(url, user: nil, **options)
headers = options[:headers] || {}
headers["Authorization"] = "Bearer #{user.api_token}" if user
get url, headers: headers, **options
end
def api_post(url, params: {}, user: nil)
headers = { "Content-Type" => "application/json" }
headers["Authorization"] = "Bearer #{user.api_token}" if user
post url, params: params.to_json, headers: headers
end
def assert_json_response(expected_keys)
actual = json_response.keys.map(&:to_sym)
expected_keys.each { |key| assert_includes actual, key.to_sym }
end
end
end
Usage:
test "returns JSON feedback list" do
api_get api_feedbacks_url, user: users(:alice)
assert_response :success
assert_json_response [:feedbacks, :total, :page]
end
Domain-specific assertions for clearer test intent
test/test_helpers/assertion_helpers.rb:
module TestHelpers
module AssertionHelpers
def assert_visible(selector, text: nil)
text ? assert_selector(selector, text: text, visible: true) : assert_selector(selector, visible: true)
end
def assert_hidden(selector)
assert_no_selector selector, visible: true
end
def assert_flash(type, message)
assert_equal message, flash[type]
end
def assert_validation_error(model, attribute, fragment)
refute model.valid?
assert_match /#{fragment}/i, model.errors[attribute].join(", ")
end
def assert_email_sent_to(email, subject: nil)
emails = ActionMailer::Base.deliveries.select { |e| e.to.include?(email) }
assert emails.any?, "No email sent to #{email}"
assert emails.any? { |e| e.subject == subject }, "No email with subject '#{subject}'" if subject
end
end
end
Usage:
test "shows error for invalid feedback" do
assert_validation_error Feedback.new(content: nil), :content, "can't be blank"
end
test "sends notification email" do
FeedbackMailer.notification(feedbacks(:one)).deliver_now
assert_email_sent_to "[email protected]", subject: "New Feedback"
end
Lightweight factory methods for creating test data
test/test_helpers/factory_helpers.rb:
module TestHelpers
module FactoryHelpers
def create_user(**attrs)
User.create!({ name: "User #{SecureRandom.hex(4)}", email: "#{SecureRandom.hex(4)}@example.com" }.merge(attrs))
end
def create_feedback(**attrs)
Feedback.create!({ content: "Test content with minimum fifty characters required", recipient_email: "[email protected]", status: "pending" }.merge(attrs))
end
def create_admin_user(**attrs)
create_user(attrs.merge(admin: true))
end
end
end
Usage:
test "admin can delete feedback" do
sign_in_as create_admin_user
delete feedback_url(create_feedback)
assert_response :redirect
end
Note: Prefer fixtures for most tests. Use factories for unique attributes.
class FeedbackPerformanceTest < ActiveSupport::TestCase
test "avoids N+1 queries when loading feedbacks with users" do
10.times do |i|
user = User.create!(name: "User #{i}", email: "user#{i}@example.com")
Feedback.create!(content: "Feedback #{i} with minimum fifty characters required", recipient_email: "[email protected]", sender: user)
end
# Without includes - N+1 problem
assert_queries(11) do # 1 for feedbacks + 10 for users
Feedback.limit(10).each { |f| f.sender.name }
end
# With includes - optimized
assert_queries(2) do # 1 for feedbacks + 1 for users
Feedback.includes(:sender).limit(10).each { |f| f.sender.name }
end
end
test "bulk operations are efficient" do
# Efficient bulk insert
assert_queries(1) do
Feedback.insert_all([
{ content: "Bulk 1 with fifty characters", recipient_email: "[email protected]" },
{ content: "Bulk 2 with fifty characters", recipient_email: "[email protected]" }
])
end
end
end
Note: assert_queries is not built-in. Add to test_helper.rb:
def assert_queries(num = nil, &block)
queries = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
queries << payload[:sql] unless payload[:name] == "SCHEMA"
end
yield
assert_equal num, queries.size if num
ensure
ActiveSupport::Notifications.unsubscribe(subscriber)
end
Validate all fixtures are valid records
class FixtureValidationTest < ActiveSupport::TestCase
test "all user fixtures are valid" do
User.find_each do |user|
assert user.valid?, "#{user.name} invalid: #{user.errors.full_messages.join(', ')}"
end
end
test "all feedback fixtures are valid" do
Feedback.find_each do |feedback|
assert feedback.valid?, "Feedback #{feedback.id} invalid: #{feedback.errors.full_messages.join(', ')}"
end
end
test "feedback fixtures have required associations" do
Feedback.find_each do |feedback|
assert feedback.sender.present?, "Feedback #{feedback.id} missing sender"
end
end
test "fixture associations are set correctly" do
feedback = feedbacks(:one)
assert_equal users(:alice), feedback.sender
assert_equal users(:alice).id, feedback.sender_id
end
end
test/test_helper.rb:
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
parallelize_setup do |worker|
# Rails handles database setup automatically
end
parallelize_teardown do |worker|
FileUtils.rm_rf(Rails.root.join("tmp", "test_worker_#{worker}"))
end
end
Disable for specific tests:
class FeedbackTest < ActiveSupport::TestCase
parallelize(workers: 1)
test "requires exclusive database access" do
# ...
end
end
Stub time-dependent code (prefer travel_to when possible)
class TimeStubbingTest < ActiveSupport::TestCase
# ✅ PREFERRED: Use travel_to
test "uses travel_to for time manipulation" do
frozen_time = Time.zone.local(2024, 10, 29, 12, 0, 0)
travel_to frozen_time do
assert_equal frozen_time, Time.current
assert_equal frozen_time.to_date, Date.today
end
end
# Alternative: Stub when travel_to insufficient
test "stubs Time.current" do
Time.stub :current, Time.zone.local(2024, 10, 29, 12, 0, 0) do
assert_equal Time.zone.local(2024, 10, 29, 12, 0, 0), Time.current
end
end
end
Recommendation: Always prefer travel_to over stubbing time. It's more comprehensive and handles edge cases better.
# ❌ BAD - Code written first, then tests
# ✅ GOOD - RED-GREEN-REFACTOR cycle
# 1. Write failing test
# 2. Write minimal code to pass
# 3. Refactor
Testing multiple concerns in one test
Makes tests harder to debug when they fail
# ❌ BAD - Multiple validations in one test
test "feedback validations" do
feedback = Feedback.new
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
assert_includes feedback.errors[:email], "can't be blank"
end
# ✅ GOOD - One concern per test
test "invalid without content" do
feedback = Feedback.new(recipient_email: "[email protected]")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
end
Not using fixtures for test data
Makes tests slower and harder to maintain
# ❌ BAD - Creating records in every test
test "feedback belongs to user" do
user = User.create!(email: "[email protected]")
feedback = Feedback.create!(content: "Test feedback with fifty characters", user: user)
assert_equal user, feedback.user
end
# ✅ GOOD - Use fixtures
# test/fixtures/users.yml: alice: { email: [email protected] }
# test/fixtures/feedbacks.yml: one: { content: "Great!", user: alice }
test "feedback belongs to user" do
assert_equal users(:alice), feedbacks(:one).user
end
Forgetting to call mock.verify
Mock expectations are not validated, test may pass incorrectly
# ❌ BAD - Expectations not verified
test "forgets to verify mock" do
mock = Minitest::Mock.new
mock.expect :call, "result"
# NO mock.verify called
end
# ✅ GOOD - Always verify
test "verifies mock expectations" do
mock = Minitest::Mock.new
mock.expect :call, "result"
mock.call
mock.verify
end
# ✅ BETTER - Use assert_mock
test "uses assert_mock" do
mock = Minitest::Mock.new
mock.expect :call, "result"
assert_mock mock do
mock.call
end
end
Not using WebMock for HTTP requests
Violates TEAM_RULES.md Rule #18, makes tests slow and brittle
# ❌ BAD - Real HTTP request in test
test "makes real HTTP request" do
response = Net::HTTP.get(URI("https://api.example.com/feedback"))
assert_includes response, "success"
end
# ✅ GOOD - Use WebMock (REQUIRED)
test "stubs HTTP request with WebMock" do
stub_request(:get, "https://api.example.com/feedback")
.to_return(status: 200, body: '{"status":"success"}')
response = Net::HTTP.get(URI("https://api.example.com/feedback"))
assert_includes response, "success"
end
Hardcoding IDs in fixtures
Brittle, causes test failures, defeats auto-generation
# ❌ BAD - Hardcoded IDs
alice:
id: 1
name: Alice Johnson
one:
id: 100
sender_id: 1 # ❌ Hardcoded FK
# ✅ GOOD - Let Rails generate IDs
alice:
name: Alice Johnson
one:
sender: alice # ✅ Reference by name
Testing implementation details in helpers
Couples tests to internal implementation
# ❌ BAD - Directly manipulates session
def sign_in_as(user)
session[:user_id] = user.id
session[:authenticated_at] = Time.current
cookies.signed[:remember_token] = user.remember_token
end
# ✅ GOOD - Uses public interface
def sign_in_as(user)
post sign_in_url, params: { email: user.email, password: "password" }
end
# Run all tests
rails test
# Run specific test file
rails test test/models/feedback_test.rb
# Run specific test by line number
rails test test/models/feedback_test.rb:12
# Run tests matching pattern
rails test -n /validation/
# Run in parallel (faster)
rails test --parallel
# Run all model tests
rails test test/models/
# Run system tests
rails test:system
Official Documentation:
Gems & Libraries:
npx claudepluginhub zerobearing2/rails-ai --plugin rails-aiWrites Minitest tests for Ruby and Rails apps using traditional/spec styles, fixtures, mocking, and integration patterns. Use when creating test files or testing features.
Assists writing, reviewing, and improving RSpec tests for Ruby on Rails apps including model, controller, system, and integration specs using Better Specs and thoughtbot best practices.
Strict red-green-refactor TDD workflow for Rails: write a failing test, then minimal production code. Drop down layers as failures demand, one change per run.