Introduction
Callbacks contain business logic that runs automatically at specific points in a model's lifecycle — normalizing data before save, sending emails after create, or updating counters after destroy. Custom methods and scopes also encode important behavior. Testing these ensures your models do what they promise without manual intervention.
Key Concepts
- before_save / before_validation: Callbacks that transform data before it reaches the database. Test by saving a record and inspecting the resulting attributes.
- after_create: Callbacks that trigger side effects when a new record is created. Test with
assert_enqueued_emailsorassert_differencefor counter caches. - Scopes: Named queries defined on the model. Test by verifying that the returned collection includes expected records and excludes others.
- Custom Methods: Instance or class methods that encapsulate business logic. Test like any other Ruby method with assertions on return values and side effects.
Real World Context
A before_save callback that normalizes email addresses to lowercase is invisible to developers calling User.create!. Without a test, someone could refactor the callback and accidentally break it, letting mixed-case emails into the database and causing login failures. Callback tests make implicit behavior explicit and prevent silent regressions.
Deep Dive
Testing before_save Callbacks
Test the outcome of the callback by saving a record and inspecting the persisted value:
rubyclass UserTest < ActiveSupport::TestCase test 'normalizes email to lowercase before save' do user = User.create!( name: 'Alice', email: ' ALICE@EXAMPLE.COM ' ) assert_equal 'alice@example.com', user.email end end
Notice that the test provides a mixed-case email with extra whitespace and asserts the saved result is clean. This tests the full callback chain without coupling to implementation details.
Testing after_create Callbacks
When a callback sends an email or enqueues a job, use Rails' built-in test helpers:
rubyclass OrderTest < ActiveSupport::TestCase test 'sends confirmation email after creation' do assert_enqueued_emails 1 do Order.create!( customer: 'Alice', total: 99.99 ) end end end
For counter caches or other database side effects:
rubytest 'increments author article count after create' do author = authors(:david) assert_difference 'author.reload.articles_count', 1 do author.articles.create!( title: 'New Article', body: 'Content' ) end end
Testing Scopes
Scopes are testable queries. Verify inclusion and exclusion:
rubyclass ArticleTest < ActiveSupport::TestCase test 'published scope returns only published articles' do published = articles(:published_one) draft = articles(:draft) results = Article.published assert_includes results, published assert_not_includes results, draft end test 'recent scope returns articles from the last week' do recent = articles(:today) old = articles(:last_month) results = Article.recent assert_includes results, recent assert_not_includes results, old end end
Testing Custom Instance Methods
Test custom methods like any Ruby method — call them and assert on the return value:
rubyclass UserTest < ActiveSupport::TestCase test 'full_name joins first and last name' do user = User.new(first_name: 'John', last_name: 'Doe') assert_equal 'John Doe', user.full_name end test 'full_name handles missing last name' do user = User.new(first_name: 'John', last_name: nil) assert_equal 'John', user.full_name end test 'adult? returns true for age 18 and above' do assert User.new(age: 18).adult? assert User.new(age: 25).adult? end test 'adult? returns false for under 18 or nil' do assert_not User.new(age: 17).adult? assert_not User.new(age: nil).adult? end end
Common Pitfalls
- Testing the callback implementation instead of its effect — Do not test that
normalize_emailis called. Test that the saved email is lowercase. This lets you refactor the implementation freely. - Forgetting time-dependent tests — Scopes like
recentdepend on the current time. Usetravel_toto freeze time and make tests deterministic:travel_to Time.zone.parse('2025-01-15') do ... end.
Best Practices
- Use travel_to for time-sensitive tests — Rails provides
travel_toto freeze or travel through time. Always use it when testing scopes or callbacks that depend on timestamps. - Test edge cases for custom methods — If
full_nameconcatenates first and last name, test what happens when one or both are nil, empty, or contain special characters.
Summary
- Test callbacks by verifying their effect on persisted data, not by checking that the callback method was called.
- Use
assert_enqueued_emailsandassert_differenceto test after_create side effects. - Test scopes by verifying both inclusion of matching records and exclusion of non-matching records.
- Use
travel_tofor deterministic time-dependent tests.
Code Examples
class UserTest < ActiveSupport::TestCase
test 'normalizes email before save' do
user = User.create!(
name: 'Alice',
email: ' ALICE@EXAMPLE.COM '
)
# The before_save callback strips and downcases
assert_equal 'alice@example.com', user.email
end
end
class ArticleTest < ActiveSupport::TestCase
test 'published scope excludes drafts' do
results = Article.published
assert_includes results, articles(:published_one)
assert_not_includes results, articles(:draft)
end
end