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:
-
Rails Guides - Testing Rails Applications
-
Rails API - ActiveRecord::FixtureSet
-
Minitest Assertions
Gems & Libraries:
-
Minitest - Ruby testing framework
-
WebMock - HTTP request stubbing