Introduction
Models are the ideal starting point for TDD in Rails. They contain your business logic, validations, and data transformations — all of which are pure functions with clear inputs and outputs. By writing model tests first, you define your domain rules before implementing them, producing models that are focused, well-validated, and free of speculative features.
Key Concepts
- Validation Tests: Tests that assert a model rejects invalid data. Write these first to define your data integrity rules.
- Behavior Tests: Tests that verify model methods return the correct results. These define your business logic contract.
- Edge Case Tests: Tests that cover boundary conditions like nil values, empty strings, and extreme dates.
- Fixtures: YAML files in
test/fixtures/that provide sample data for your tests. Rails loads them into the test database before each test.
Real World Context
In a SaaS application, the Subscription model might need to know if it is active, calculate remaining days, handle renewal, and prorate upgrades. Without TDD, developers often implement these methods and then write tests that merely confirm the implementation. With TDD, you write the test first — "a subscription with an expiry in the past should not be active" — and the implementation follows naturally from the requirement.
Deep Dive
Let us build a Subscription model using TDD, starting with validations and progressing to business logic.
Step 1: Define Validation Rules as Tests
Before writing any model code, describe what valid and invalid data looks like:
ruby# test/models/subscription_test.rb class SubscriptionTest < ActiveSupport::TestCase test 'requires user' do sub = Subscription.new(plan: 'basic') assert_not sub.valid? assert_includes sub.errors[:user], 'must exist' end test 'requires plan' do sub = Subscription.new(user: users(:david)) assert_not sub.valid? assert_includes sub.errors[:plan], "can't be blank" end test 'plan must be a valid option' do sub = Subscription.new(user: users(:david), plan: 'invalid') assert_not sub.valid? assert_includes sub.errors[:plan], 'is not included in the list' end test 'valid with user and valid plan' do sub = Subscription.new(user: users(:david), plan: 'basic') assert sub.valid? end end
Run the tests — all fail (Red). Now implement the validations:
ruby# app/models/subscription.rb class Subscription < ApplicationRecord belongs_to :user validates :plan, presence: true, inclusion: { in: %w[basic pro enterprise] } end
Run the tests again — all pass (Green).
Step 2: Add Behavior Tests
With validations in place, test the methods your model needs:
rubytest 'active? returns true for non-expired subscription' do sub = Subscription.new(expires_at: 1.day.from_now) assert sub.active? end test 'active? returns false for expired subscription' do sub = Subscription.new(expires_at: 1.day.ago) assert_not sub.active? end test 'active? returns false when expires_at is nil' do sub = Subscription.new(expires_at: nil) assert_not sub.active? end test 'days_remaining calculates correctly' do travel_to Time.zone.local(2026, 3, 1) do sub = Subscription.new(expires_at: Time.zone.local(2026, 3, 11)) assert_equal 10, sub.days_remaining end end test 'days_remaining returns 0 for expired subscription' do sub = Subscription.new(expires_at: 1.day.ago) assert_equal 0, sub.days_remaining end
All fail (Red). Implement the methods:
rubyclass Subscription < ApplicationRecord belongs_to :user validates :plan, presence: true, inclusion: { in: %w[basic pro enterprise] } def active? expires_at.present? && expires_at > Time.current end def days_remaining return 0 unless active? ((expires_at - Time.current) / 1.day).ceil end end
All pass (Green).
Step 3: Test Complex Business Logic
For operations that change state, test both the outcome and side effects:
rubytest 'renew! extends expiration by 30 days for basic plan' do travel_to Time.zone.local(2026, 1, 1) do sub = subscriptions(:basic_active) original_expiry = sub.expires_at sub.renew! assert_equal original_expiry + 30.days, sub.expires_at end end test 'upgrade_to changes plan and calculates prorated credit' do sub = subscriptions(:basic_active) sub.upgrade_to('pro') assert_equal 'pro', sub.plan assert sub.prorated_credit.positive? end
These tests drive the implementation of renew! and upgrade_to — you write exactly the code needed to satisfy the test assertions.
Common Pitfalls
- Testing implementation instead of behavior — Do not test that a method calls another method internally. Test the observable outcome: "after calling
renew!,expires_atis 30 days later." - Forgetting edge cases in the Red phase — Write tests for nil values, empty strings, and boundary dates before implementing. It is much harder to add edge case handling retroactively.
- Using
travel_towithout freezing time — Time-dependent tests must usetravel_toto produce deterministic results. Without it, tests that pass at noon may fail at midnight.
Best Practices
- Start with validation tests — Validations define your data contract. Writing them first ensures your model rejects bad data before you add any behavior.
- Test one behavior per test — Each test should assert a single outcome. "Subscription is active and has 10 days remaining" should be two separate tests.
- Use
travel_tofor all time-dependent logic — Wrap time-sensitive assertions intravel_toblocks to freeze time and eliminate flakiness.
Summary
- Start model TDD with validation tests — they define what valid data looks like.
- Progress to behavior tests that verify method return values and state changes.
- Use
travel_toto freeze time in tests that depend on dates or durations. - Test observable outcomes, not internal implementation details.
- Each test should assert a single behavior, keeping failures easy to diagnose.
Code Examples
# TDD workflow for a Subscription model
# 1. Write validation tests first
test 'requires plan' do
sub = Subscription.new(user: users(:david))
assert_not sub.valid?
end
# 2. Write behavior tests
test 'active? returns false for expired subscription' do
sub = Subscription.new(expires_at: 1.day.ago)
assert_not sub.active?
end
# 3. Write complex logic tests with time freezing
test 'renew! extends expiration by 30 days' do
travel_to Time.zone.local(2026, 1, 1) do
sub = subscriptions(:basic_active)
sub.renew!
assert_equal sub.expires_at, Time.zone.local(2026, 1, 1) + 30.days
end
end