📘 Testing Rails Models with Vanilla RSpec
📌 Scope
We will rigorously cover three essential model testing dimensions:
1. Validations — Presence, Uniqueness, Length, Format, Inclusion/Exclusion, Numericality,
Custom validations, Conditional validations.
2. Associations — belongs_to , has_many , has_one , has_and_belongs_to_many , inverse
relationships, dependent options.
3. Database Columns — Presence of columns, data types, null constraints, default values.
All examples assume you are using RSpec 3.x or later with rspec-rails .
We aim to write pure vanilla RSpec tests, asserting model behavior without shortcuts or
declarative matchers like shoulda-matchers .
1️⃣ VALIDATIONS
✅ Overview
You must write examples that explicitly trigger validations and assert on errors .
🔍 Test Strategy
For each attribute:
Set up an invalid value
Run valid? on the model
Assert errors[:attribute] contains the expected message
🔢 Common Validation Types
1. Presence
# Model
validates :name, presence: true
# Spec
RSpec.describe User, type: :model do
describe 'validations' do
it 'is invalid without a name' do
user = User.new(name: nil)
expect(user).not_to be_valid
expect(user.errors[:name]).to include("can't be blank")
end
end
end
2. Uniqueness
Uniqueness is not guaranteed at the app layer—you should have a unique index in the DB too.
# Model
validates :email, uniqueness: true
# Spec
RSpec.describe User, type: :model do
describe 'validations' do
it 'is invalid with a duplicate email' do
User.create!(email: '[email protected]')
dup = User.new(email: '[email protected]')
expect(dup).not_to be_valid
expect(dup.errors[:email]).to include("has already been taken")
end
end
end
Optionally, test case-insensitivity:
# Model
validates :email, uniqueness: { case_sensitive: false }
# Spec
it 'is invalid if email is not unique case-insensitively' do
User.create!(email: '[email protected]')
user = User.new(email: '[email protected]')
expect(user).not_to be_valid
expect(user.errors[:email]).to include("has already been taken")
end
3. Length
# Model
validates :username, length: { minimum: 3, maximum: 10 }
# Spec
describe 'length validations' do
it 'is invalid if username is too short' do
user = User.new(username: 'ab')
expect(user).not_to be_valid
expect(user.errors[:username]).to include("is too short (minimum is 3
characters)")
end
it 'is invalid if username is too long' do
user = User.new(username: 'a' * 11)
expect(user).not_to be_valid
expect(user.errors[:username]).to include("is too long (maximum is 10
characters)")
end
end
4. Format
# Model
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
# Spec
it 'is invalid with a malformed email' do
user = User.new(email: 'not-an-email')
expect(user).not_to be_valid
expect(user.errors[:email]).to include("is invalid")
end
5. Inclusion / Exclusion
# Model
validates :status, inclusion: { in: %w[pending active archived] }
# Spec
it 'is invalid with status outside allowed list' do
user = User.new(status: 'deleted')
expect(user).not_to be_valid
expect(user.errors[:status]).to include("is not included in the list")
end
6. Numericality
# Model
validates :age, numericality: { only_integer: true, greater_than: 0 }
# Spec
describe 'numericality validations' do
it 'is invalid with a non-integer age' do
user = User.new(age: 20.5)
expect(user).not_to be_valid
expect(user.errors[:age]).to include("must be an integer")
end
it 'is invalid with a negative age' do
user = User.new(age: -3)
expect(user).not_to be_valid
expect(user.errors[:age]).to include("must be greater than 0")
end
end
7. Custom Validations
# Model
validate :birth_date_cannot_be_in_the_future
def birth_date_cannot_be_in_the_future
if birth_date.present? && birth_date > Date.today
errors.add(:birth_date, "can't be in the future")
end
end
# Spec
it 'is invalid if birth_date is in the future' do
user = User.new(birth_date: Date.today + 1.day)
expect(user).not_to be_valid
expect(user.errors[:birth_date]).to include("can't be in the future")
end
8. Conditional Validations
# Model
validates :phone_number, presence: true, if: :requires_phone?
def requires_phone?
requires_contact_info
end
# Spec
it 'requires phone number if requires_contact_info is true' do
user = User.new(requires_contact_info: true, phone_number: nil)
expect(user).not_to be_valid
expect(user.errors[:phone_number]).to include("can't be blank")
end
🧠 Summary: Validation Testing Checklist
Validation Type Key Test Points
Presence nil or '' values
Uniqueness Duplicate values, case sensitivity
Length Too short, too long
Format Invalid regex match
Inclusion/Exclusion Value in/out of a set
Numericality Integer only, min/max, negative values
Custom Boundary conditions, logic triggers
Conditional Ensure validation is only active under correct context
2️⃣ ASSOCIATIONS
✅ Overview
In vanilla RSpec, you verify associations by exercising behavior, not introspection.
You test:
Whether foreign keys work
Whether dependent records are saved/destroyed appropriately
Whether children can be added or removed
Whether the inverse association is wired correctly
1. belongs_to
# Book model
belongs_to :author
# Spec
describe 'associations' do
it 'can be assigned an author' do
author = Author.create!(name: "Alice")
book = Book.new(author: author)
expect(book.author).to eq(author)
end
it 'is invalid without an author' do
book = Book.new(author: nil)
book.valid?
expect(book.errors[:author]).to include("must exist")
end
end
2. has_many
# Author model
has_many :books
# Spec
it 'can have many books' do
author = Author.create!(name: "Alice")
book1 = Book.create!(title: "Book 1", author: author)
book2 = Book.create!(title: "Book 2", author: author)
expect(author.books).to include(book1, book2)
end
3. has_one
# User model
has_one :profile
# Spec
it 'can have one profile' do
user = User.create!(email: "[email protected]")
profile = Profile.create!(user: user, bio: "Hello")
expect(user.profile).to eq(profile)
end
4. has_and_belongs_to_many
# Students <-> Courses
# Spec
it 'can be enrolled in many courses' do
student = Student.create!(name: "Jane")
course1 = Course.create!(title: "Math")
course2 = Course.create!(title: "Physics")
student.courses << [course1, course2]
student.save!
expect(student.courses).to match_array([course1, course2])
expect(course1.students).to include(student)
end
5. dependent: :destroy
# Author model
has_many :books, dependent: :destroy
# Spec
it 'destroys dependent books when author is destroyed' do
author = Author.create!(name: "Alice")
Book.create!(title: "Book", author: author)
expect { author.destroy }.to change { Book.count }.by(-1)
end
6. Inverse Relationships
# Book model
belongs_to :author, inverse_of: :books
# Spec
it 'maintains inverse associations in memory' do
author = Author.new(name: "Alice")
book = author.books.build(title: "Test")
expect(book.author).to equal(author) # Same in-memory object
end
🧠 Summary: Associations Testing Checklist
Association Type What to Test
belongs_to Foreign key assignment, presence, validation
has_many Adding/removing children
has_one Correct one-to-one association
has_and_belongs_to_many Join-table behavior
dependent: :destroy Children are deleted when parent is destroyed
inverse_of Parent and child refer to each other in memory
3️⃣ COLUMNS AND TYPES
✅ Overview
Since Rails models are typically built on top of schema-defined ActiveRecord objects, you verify:
That expected columns exist
That their data types are correct
That constraints like nullability and defaults are enforced
There are two approaches:
Database-level test: raw SQL to test the actual schema
Behavioral test: test impact of constraints via models
1. Column Existence and Type
# Example: `users` table has `email:string`, `age:integer`
RSpec.describe User, type: :model do
describe 'database schema' do
it 'has an email column of type string' do
column = described_class.columns_hash['email']
expect(column.type).to eq(:string)
end
it 'has an age column of type integer' do
column = described_class.columns_hash['age']
expect(column.type).to eq(:integer)
end
end
end
2. Null Constraint
# migration: `t.string :email, null: false`
it 'raises error when saving null to non-nullable column' do
expect {
User.create!(email: nil)
}.to raise_error(ActiveRecord::NotNullViolation)
end
3. Default Values
# migration: `t.boolean :admin, default: false`
it 'assigns default value to admin' do
user = User.create!(email: "
[email protected]")
expect(user.admin).to eq(false)
end
🧠 Summary: Schema Testing Checklist
Column Property What to Test
Presence Column exists in schema
Type .columns_hash['attr'].type == :expected_type
Nullability Save model with nil , expect DB error
Default Value Create without assigning, check default
Here is an exhaustively comprehensive and rigorously detailed exposition on testing Rails
request specs using vanilla RSpec (without rspec_api_documentation , airborne , json_spec , or
rails-controller-testing gems). We will cover all common dimensions of request testing at
the HTTP and application logic level.
📘 Exhaustive Guide to Request Specs in Rails
with Vanilla RSpec
📌 Scope
Rails request specs test the full integration stack, from routing to controller to database and
response serialization. These are black-box tests exercising HTTP behavior.
We’ll rigorously cover these common and critical request testing dimensions:
1. HTTP Verbs and RESTful Routing
2. Status Codes
3. JSON Response Body Structure and Values
4. Database Side Effects
5. Headers and Content Types
6. Authentication / Authorization
7. Invalid Input / Validation Errors
8. Edge Case Handling
9. Query Parameters and Filtering
0️⃣ 📦 Setup Assumptions
# spec/rails_helper.rb
require 'rails_helper'
# RSpec configuration should have:
# config.infer_spec_type_from_file_location!
# config.include Rails.application.routes.url_helpers
# config.include ActionDispatch::TestProcess for file uploads
# All request specs should live in spec/requests/
1️⃣ HTTP Verbs and RESTful Routing
✅ Test Strategy
Ensure each endpoint responds to the correct HTTP verb ( GET , POST , PUT/PATCH , DELETE ).
Example: /articles
RSpec.describe "Articles API", type: :request do
describe 'GET /articles' do
it 'returns a list of articles' do
Article.create!(title: "A", body: "B")
get '/articles'
expect(response).to have_http_status(:ok)
expect(response.content_type).to include("application/json")
end
end
describe 'POST /articles' do
it 'creates an article' do
post '/articles', params: { article: { title: 'T', body: 'B' } }
expect(response).to have_http_status(:created)
end
end
describe 'PATCH /articles/:id' do
it 'updates the article' do
article = Article.create!(title: "Old", body: "B")
patch "/articles/#{article.id}", params: { article: { title: 'New' } }
expect(response).to have_http_status(:ok)
expect(article.reload.title).to eq('New')
end
end
describe 'DELETE /articles/:id' do
it 'deletes the article' do
article = Article.create!(title: "T", body: "B")
expect {
delete "/articles/#{article.id}"
}.to change { Article.count }.by(-1)
expect(response).to have_http_status(:no_content)
end
end
end
2️⃣ HTTP Status Codes
✅ Test Strategy
Explicitly assert correct status code for every scenario:
200 OK
201 Created
204 No Content
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
422 Unprocessable Entity
500 Internal Server Error
expect(response).to have_http_status(:unprocessable_entity)
3️⃣ JSON Response Body Structure and Values
✅ Test Strategy
Parse response.body using JSON.parse
Use match , include , eq , or hash deep-matching
Don't assume order unless sorted
it 'returns article fields' do
article = Article.create!(title: "T", body: "B")
get "/articles/#{article.id}"
json = JSON.parse(response.body)
expect(json["id"]).to eq(article.id)
expect(json["title"]).to eq("T")
expect(json).to include("created_at", "updated_at")
end
4️⃣ Database Side Effects
✅ Test Strategy
Use change matchers, reload , or ActiveRecord assertions.
it 'creates a record' do
expect {
post '/articles', params: { article: { title: 'T', body: 'B' } }
}.to change(Article, :count).by(1)
end
it 'modifies the record' do
article = Article.create!(title: "Old", body: "B")
patch "/articles/#{article.id}", params: { article: { title: 'New' } }
expect(article.reload.title).to eq("New")
end
5️⃣ Headers and Content Types
✅ Test Strategy
Use request.headers and expect(response.content_type) .
it 'returns JSON content type' do
get '/articles'
expect(response.content_type).to include("application/json")
end
it 'accepts JSON headers' do
get '/articles', headers: { "ACCEPT" => "application/json" }
expect(response).to be_successful
end
6️⃣ Authentication / Authorization
Assuming custom token-based auth or Devise.
let(:user) { User.create!(email: "
[email protected]", password: "secret") }
it 'rejects unauthenticated access' do
get '/articles'
expect(response).to have_http_status(:unauthorized)
end
it 'allows access with valid auth token' do
token = user.generate_auth_token # hypothetical method
get '/articles', headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
end
7️⃣ Invalid Input / Validation Errors
✅ Test Strategy
Trigger model validation errors
Expect 422 Unprocessable Entity
Assert error messages in response JSON
it 'returns validation errors' do
post '/articles', params: { article: { title: nil } }
expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json["errors"]["title"]).to include("can't be blank")
end
8️⃣ Edge Case Handling
Test for:
Nonexistent IDs
Empty input
Extremely large/small values
it 'returns 404 for nonexistent ID' do
get '/articles/99999'
expect(response).to have_http_status(:not_found)
end
9️⃣ Query Parameters and Filtering
Assuming filtering on published boolean.
it 'filters by published status' do
Article.create!(title: "A", published: true)
Article.create!(title: "B", published: false)
get '/articles', params: { published: true }
json = JSON.parse(response.body)
expect(json.length).to eq(1)
end