0% found this document useful (0 votes)
21 views17 pages

Testing Models & Controllers

The document provides a comprehensive guide on testing Rails models and request specs using vanilla RSpec, covering essential dimensions such as validations, associations, and database columns. It outlines strategies for testing model behavior, including validation types, association integrity, and schema properties, as well as detailed request testing techniques for HTTP verbs, status codes, and JSON responses. The guide emphasizes writing pure RSpec tests without shortcuts, ensuring thorough validation and association checks, and validating request responses in a structured manner.

Uploaded by

buro
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
21 views17 pages

Testing Models & Controllers

The document provides a comprehensive guide on testing Rails models and request specs using vanilla RSpec, covering essential dimensions such as validations, associations, and database columns. It outlines strategies for testing model behavior, including validation types, association integrity, and schema properties, as well as detailed request testing techniques for HTTP verbs, status codes, and JSON responses. The guide emphasizes writing pure RSpec tests without shortcuts, ensuring thorough validation and association checks, and validating request responses in a structured manner.

Uploaded by

buro
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 17

📘 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

You might also like