RoR Model Tests
Model tests verify that your data layer works correctly. They test validations, associations, scopes, and custom methods. Model tests run fast because they talk directly to the database without going through routes, controllers, or views. A thorough model test suite catches data bugs before they ever reach users.
What to Test in a Model
Model areas to test: 1. Validations ← does invalid data get rejected? 2. Associations ← do relationships behave correctly? 3. Scopes ← do named queries return the right records? 4. Custom methods ← do business logic methods return correct values? 5. Callbacks ← does before_save / after_create run correctly?
Testing Validations
spec/models/product_spec.rb
require "rails_helper"
RSpec.describe Product, type: :model do
describe "validations" do
let(:product) { build(:product) }
it "is valid with valid attributes" do
expect(product).to be_valid
end
it "requires a name" do
product.name = nil
expect(product).not_to be_valid
expect(product.errors[:name]).to include("can't be blank")
end
it "requires a positive price" do
product.price = -5
expect(product).not_to be_valid
expect(product.errors[:price]).to include("must be greater than 0")
end
it "requires price to be a number" do
product.price = "free"
expect(product).not_to be_valid
end
it "requires stock to be zero or more" do
product.stock = -1
expect(product).not_to be_valid
end
it "requires a unique SKU" do
create(:product, sku: "ABC123")
duplicate = build(:product, sku: "ABC123")
expect(duplicate).not_to be_valid
end
end
end
Testing Associations
RSpec.describe User, type: :model do
describe "associations" do
let(:user) { create(:user) }
it "has many articles" do
article1 = create(:article, user: user)
article2 = create(:article, user: user)
expect(user.articles).to include(article1, article2)
end
it "destroys associated articles when deleted" do
create(:article, user: user)
expect { user.destroy }.to change(Article, :count).by(-1)
end
it "has one profile" do
profile = create(:profile, user: user)
expect(user.profile).to eq(profile)
end
end
end
Testing Scopes
RSpec.describe Article, type: :model do
describe "scopes" do
before do
create(:article, published: true, created_at: 3.days.ago)
create(:article, published: true, created_at: 1.day.ago)
create(:article, published: false, created_at: 2.days.ago)
end
describe ".published" do
it "returns only published articles" do
expect(Article.published.count).to eq(2)
end
it "excludes unpublished articles" do
unpublished = Article.where(published: false).first
expect(Article.published).not_to include(unpublished)
end
end
describe ".recent" do
it "returns articles ordered newest first" do
articles = Article.published.recent
expect(articles.first.created_at).to be > articles.last.created_at
end
end
end
end
Testing Custom Methods
RSpec.describe User, type: :model do
describe "#full_name" do
it "returns first and last name joined" do
user = build(:user, first_name: "Alice", last_name: "Smith")
expect(user.full_name).to eq("Alice Smith")
end
end
describe "#admin?" do
it "returns true for admin role" do
user = build(:user, role: :admin)
expect(user.admin?).to be true
end
it "returns false for viewer role" do
user = build(:user, role: :viewer)
expect(user.admin?).to be false
end
end
describe "#initials" do
it "returns uppercase initials from full name" do
user = build(:user, first_name: "Alice", last_name: "Smith")
expect(user.initials).to eq("AS")
end
end
end
Testing Callbacks
RSpec.describe Article, type: :model do
describe "callbacks" do
describe "before_save" do
it "generates a slug from the title before saving" do
article = create(:article, title: "My First Post")
expect(article.slug).to eq("my-first-post")
end
it "updates the slug when the title changes" do
article = create(:article, title: "Old Title")
article.update(title: "New Title")
expect(article.slug).to eq("new-title")
end
end
describe "after_create" do
it "sends a welcome email after user creation" do
expect {
create(:user, email: "new@example.com")
}.to have_enqueued_mail(UserMailer, :welcome_email)
end
end
end
end
Testing Database Interactions
RSpec.describe Order, type: :model do
describe ".total_revenue" do
it "sums the amount of all completed orders" do
create(:order, amount: 100, status: "completed")
create(:order, amount: 250, status: "completed")
create(:order, amount: 50, status: "pending") ← excluded
expect(Order.total_revenue).to eq(350)
end
end
describe "#apply_discount" do
it "reduces the order amount by the given percentage" do
order = create(:order, amount: 200)
order.apply_discount(10) ← 10% off
expect(order.amount).to eq(180)
end
end
end
Database Cleaner — Fresh Database Per Test
Each test should start with a clean database. Add DatabaseCleaner to prevent test data from leaking between tests:
gem "database_cleaner-active_record" bundle install
spec/rails_helper.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning { example.run }
end
end
Shoulda Matchers — Concise Validation Tests
Shoulda Matchers provide one-line validation and association tests:
gem "shoulda-matchers"
RSpec.describe User, type: :model do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email) }
it { should validate_length_of(:password).is_at_least(8) }
it { should have_many(:articles).dependent(:destroy) }
it { should have_one(:profile) }
it { should belong_to(:organization) }
end
Each line replaces four to six lines of standard RSpec. Use them for simple validation checks and write full descriptive tests for complex business logic.
Comprehensive model tests are your first line of defence. They run in seconds, require no browser, and tell you immediately when business logic breaks.
