| name | rails-ai:testing |
| description | Use when testing Rails applications - TDD, Minitest, fixtures, model testing, mocking, test helpers |
Testing Rails Applications with Minitest
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
TDD Red-Green-Refactor
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 Structure
# 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
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
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
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
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
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
# 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
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
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
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
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
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
# 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: "user@example.com")
assert_not_includes feedback.content, "<script>"
assert_includes feedback.content, "Valid"
end
end
Testing Instance Methods
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
Testing Enums
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: "user@example.com", 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: "u@example.com", status: "pending")
delivered = Feedback.create!(content: "Delivered fifty chars", recipient_email: "u@example.com", status: "delivered")
results = Feedback.status_pending
assert_includes results, pending
assert_not_includes results, delivered
end
end
Testing Class Methods
# 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: "user@example.com", status: "delivered", delivered_at: 10.days.ago)
has_resp = Feedback.create!(content: "Has fifty chars", recipient_email: "user@example.com", status: "delivered", delivered_at: 10.days.ago)
has_resp.create_response!(content: "Thank you")
too_recent = Feedback.create!(content: "Recent fifty chars", recipient_email: "user@example.com", 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
# 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: "u@example.com", created_at: 5.days.ago)
f1.create_response!(content: "R1", created_at: 4.days.ago)
f2 = Feedback.create!(content: "Second fifty chars", recipient_email: "u@example.com", 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: "u@example.com")
assert_nil Feedback.average_response_time
end
end
Testing Edge Cases
class FeedbackTest < ActiveSupport::TestCase
test "handles empty collections gracefully" do
feedback = Feedback.create!(content: "Feedback fifty chars", recipient_email: "user@example.com")
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: "user@example.com", 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: "user@example.com")
assert_equal unicode, feedback.reload.content
end
end
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
Controller and Integration Testing
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: "test@example.com" } }
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
System Testing
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: "user@example.com"
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
Fixtures Design
Fixture File:
# test/fixtures/users.yml
alice:
name: Alice Johnson
email: alice@example.com
active: true
created_at: <%= 1.week.ago %>
bob:
name: Bob Smith
email: bob@example.com
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
Fixture Files:
# test/fixtures/users.yml
alice:
name: Alice Johnson
email: alice@example.com
bob:
name: Bob Smith
email: bob@example.com
# test/fixtures/feedbacks.yml
one:
content: This is great feedback with minimum fifty characters!
recipient_email: alice@example.com
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: bob@example.com
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 "alice@example.com", 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
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
Testing Jobs and Mailers
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
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 ["noreply@example.com"], 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
Advanced Fixtures
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
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: david@example.com
avatar_url: <%= default_avatar_url %>
registered_on: <%= formatted_date(1.month.ago) %>
password_digest: <%= default_password_digest %>
admin:
name: Admin User
email: admin@example.com
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 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: "manual@example.com")
end
test "uses manually created data" do
assert @user.persisted?
end
end
Mocking and Stubbing
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:
- Stub is scoped to the block
- Original method restored automatically
- Use lambda for dynamic return values
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
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
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
Custom Test Helpers
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
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: "test@example.com", 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
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
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 "user@example.com", subject: "New Feedback"
end
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: "user@example.com", 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.
Performance and Database Testing
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: "test@example.com", 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: "test@example.com" },
{ content: "Bulk 2 with fifty characters", recipient_email: "test@example.com" }
])
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
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 Isolation
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
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.
Anti-Patterns
# ❌ BAD - Code written first, then tests
# ✅ GOOD - RED-GREEN-REFACTOR cycle
# 1. Write failing test
# 2. Write minimal code to pass
# 3. Refactor
# ❌ 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: "user@example.com")
assert_not feedback.valid?
assert_includes feedback.errors[:content], "can't be blank"
end
# ❌ BAD - Creating records in every test
test "feedback belongs to user" do
user = User.create!(email: "test@example.com")
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: alice@example.com }
# test/fixtures/feedbacks.yml: one: { content: "Great!", user: alice }
test "feedback belongs to user" do
assert_equal users(:alice), feedbacks(:one).user
end
# ❌ 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
# ❌ 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
# ❌ 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
# ❌ 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
Running Tests
# 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: