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