Introduction
Validations are the first line of defense for data integrity in a Rails application. Every validates declaration in a model should have corresponding tests that prove it accepts good data and rejects bad data. This lesson shows you how to write thorough, maintainable validation tests that catch regressions before they reach production.
Key Concepts
- Presence Validation: Ensures a field is not nil or blank. Test by setting the field to nil and asserting the record is invalid.
- Uniqueness Validation: Ensures no two records share the same value for a field. Test by creating a duplicate and checking for the error message.
- Format Validation: Ensures a field matches a regular expression. Test with multiple valid and invalid values.
- Numericality Validation: Ensures a field is a number with optional constraints (greater than, integer only, etc.). Test boundary values.
Real World Context
Imagine a team ships a User model where the email uniqueness validation accidentally gets removed during a refactor. Without tests, duplicate emails silently enter the database, causing login failures and data corruption. A simple test like assert_not duplicate.valid? would catch this regression immediately in CI. Validation tests are cheap to write and prevent expensive production bugs.
Deep Dive
Testing Presence Validations
Presence validations are the most common. Test both the positive case (all fields present) and the negative case (required field missing):
rubyclass UserTest < ActiveSupport::TestCase test 'valid with all required fields' do user = User.new( name: 'Alice', email: 'alice@example.com' ) assert user.valid? end test 'invalid without email' do user = User.new(name: 'Alice') assert_not user.valid? assert_includes user.errors[:email], "can't be blank" end end
Always check the specific error message with assert_includes user.errors[:field]. This ensures the right validation triggered, not just any validation.
Testing Uniqueness Validations
Use fixtures to provide an existing record, then create a duplicate:
rubytest 'email must be unique' do existing = users(:david) duplicate = User.new( name: 'Other', email: existing.email ) assert_not duplicate.valid? assert_includes duplicate.errors[:email], 'has already been taken' end
Testing Format Validations
Test multiple valid and invalid inputs. Use custom failure messages to identify which value caused a failure:
rubytest 'rejects invalid email formats' do user = User.new(name: 'Test') %w[invalid@ @invalid plaintext].each do |bad_email| user.email = bad_email assert_not user.valid?, "#{bad_email} should be invalid" end end test 'accepts valid email formats' do user = User.new(name: 'Test') %w[user@example.com USER@foo.COM a+b@baz.cn].each do |good_email| user.email = good_email assert user.valid?, "#{good_email} should be valid" end end
Testing Numericality and Boundary Values
Boundary testing catches off-by-one errors in numericality constraints:
rubytest 'price must be positive' do product = products(:widget) product.price = -1 assert_not product.valid? product.price = 0 assert_not product.valid? product.price = 0.01 assert product.valid? end
Common Pitfalls
- Only testing the negative case — If you only test that invalid data fails, you will not catch a bug where the validation is too strict and rejects valid data. Always test both valid and invalid inputs.
- Not checking specific error messages — Using
assert_not user.valid?alone does not tell you which validation failed. A different validation might be triggering. Always checkerrors[:field]for the expected message.
Best Practices
- Test each validation independently — Change only one attribute at a time so you know exactly which validation is being exercised. Start from a valid baseline (all attributes set) and remove or modify one.
- Test boundary values for numericality — If the validation says
greater_than: 0, test with -1, 0, and 0.01. If it sayslength: { minimum: 5 }, test with 4 characters, 5 characters, and 6 characters.
Summary
- Every validation in the model should have a corresponding test with both positive and negative cases.
- Use
assert_includes record.errors[:field]to verify the exact error message, not just that the record is invalid. - Test boundary values for numericality and length validations to catch off-by-one errors.
Code Examples
require 'test_helper'
class UserTest < ActiveSupport::TestCase
test 'valid with all required fields' do
user = User.new(name: 'Alice', email: 'alice@example.com')
assert user.valid?
end
test 'invalid without email' do
user = User.new(name: 'Alice')
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
test 'email must be unique' do
existing = users(:david)
duplicate = User.new(name: 'Copy', email: existing.email)
assert_not duplicate.valid?
assert_includes duplicate.errors[:email], 'has already been taken'
end
end