Ruby Testing with RSpec — Factories Mocks System Tests and TDD Explained
In this tutorial, you will learn about Ruby Testing with RSpec. We cover key concepts, practical examples, and best practices to help you master this topic.
Ruby Testing with RSpec provides behavior-driven development for Rails applications using describe/it blocks, factory_bot for test data, mocks/stubs for isolation, and system tests for full browser integration testing.
What You'll Learn
- Setting up RSpec for Rails
- Writing model, controller, and request specs
- Using factories for test data
- Mocking and stubbing dependencies
Why It Matters
Testing prevents regressions. Doda Browser runs thousands of tests before each release. Durga Antivirus Pro tests threat detection patterns, scan engines, and API endpoints. Without tests, you can't refactor with confidence.
Real-World Use
CI/CD pipelines run tests on every push. GitHub Actions, CircleCI, and Jenkins execute test suites. Deployment is blocked if tests fail. TDD (Test-Driven Development) writes tests before code.
flowchart LR
A["Testing"] --> B["RSpec"]
B --> C["Factories"]
C --> D["Mocks"]
D --> E["System Tests"]
E --> F["CI/CD"]
A:::current --> B
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#dbeafe,stroke:#2563eb,color:#1e40af
style F fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Setup
# Gemfile
group :development, :test do
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
gem "shoulda-matchers"
end
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "database_cleaner-active_record"
end
bin/rails generate rspec:install
# Creates spec/spec_helper.rb and spec/rails_helper.rb
Model Specs
# spec/models/article_spec.rb
require "rails_helper"
RSpec.describe Article, type: :model do
describe "validations" do
it { should validate_presence_of(:title) }
it { should validate_presence_of(:body) }
it { should validate_length_of(:title).is_at_least(5) }
end
describe "associations" do
it { should belong_to(:user) }
it { should have_many(:comments).dependent(:destroy) }
end
describe "scopes" do
let!(:published_article) { create(:article, :published) }
let!(:draft_article) { create(:article, :draft) }
it "returns only published articles" do
expect(Article.published).to include(published_article)
expect(Article.published).not_to include(draft_article)
end
end
describe "#publish!" do
let(:article) { create(:article, :draft) }
it "sets published_at timestamp" do
expect { article.publish! }
.to change { article.published_at }
.from(nil)
.to(kind_of(Time))
end
end
end
Factory Definitions
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
password { "password123" }
trait :admin do
role { :admin }
end
trait :with_articles do
after(:create) { |user| create_list(:article, 3, user: user) }
end
end
end
# spec/factories/articles.rb
FactoryBot.define do
factory :article do
title { Faker::Book.title }
body { Faker::Lorem.paragraphs(number: 3).join("\n\n") }
user
trait :published do
published_at { 1.day.ago }
end
trait :draft do
published_at { nil }
end
end
end
Request Specs
# spec/requests/articles_spec.rb
require "rails_helper"
RSpec.describe "Articles", type: :request do
let(:user) { create(:user) }
let(:article) { create(:article, user: user) }
describe "GET /articles" do
it "returns a successful response" do
get articles_path
expect(response).to have_http_status(:ok)
end
it "lists published articles" do
published = create(:article, :published, user: user)
draft = create(:article, :draft, user: user)
get articles_path
expect(response.body).to include(published.title)
expect(response.body).not_to include(draft.title)
end
end
describe "POST /articles" do
let(:valid_params) do
{ article: { title: "Test", body: "Content here" } }
end
context "when authenticated" do
before { sign_in user }
it "creates a new article" do
expect {
post articles_path, params: valid_params
}.to change(Article, :count).by(1)
end
it "redirects to the article" do
post articles_path, params: valid_params
expect(response).to redirect_to(article_path(Article.last))
end
end
context "when not authenticated" do
it "redirects to login" do
post articles_path, params: valid_params
expect(response).to redirect_to(login_path)
end
end
end
end
System Specs
# spec/system/articles_spec.rb
require "rails_helper"
RSpec.describe "Articles", type: :system do
let(:user) { create(:user) }
before do
driven_by(:selenium_chrome_headless)
sign_in user
end
it "creates a new article" do
visit new_article_path
fill_in "Title", with: "My New Article"
fill_in "Body", with: "This is the content of my article."
click_button "Create Article"
expect(page).to have_content("Article created!")
expect(page).to have_content("My New Article")
end
it "displays validation errors" do
visit new_article_path
click_button "Create Article"
expect(page).to have_content("Title can't be blank")
end
end
Mocks and Stubs
describe "External service calls" do
# Stub — replaces method with fixed return
before do
allow(PaymentGateway).to receive(:charge)
.and_return(double(success?: true, transaction_id: "abc123"))
end
it "processes payment successfully" do
order = create(:order)
result = PaymentProcessor.charge(order)
expect(result).to be_success
end
end
describe "Email sending" do
# Mock — expects method to be called
it "sends welcome email" do
expect(WelcomeMailer).to receive(:welcome_email).with(kind_of(User))
.and_return(double(deliver_later: true))
User.create!(name: "Alice", email: "alice@test.com")
end
# Message expectations
it "sends exactly one email" do
expect {
post signup_path, params: { user: valid_attributes }
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
end
Testing with Different Data
RSpec.describe Article, type: :model do
# let — lazy-loaded, memoized
let(:user) { create(:user) }
let(:article) { create(:article, user: user) }
# let! — eager-loaded
let!(:existing_article) { create(:article, :published) }
# subject — the object under test
subject { build(:article) }
# before — setup
before do
# Runs before each test
create(:category, name: "Ruby")
end
# context — grouping by condition
context "when published" do
subject { build(:article, :published) }
it { should be_published }
end
context "when draft" do
subject { build(:article, :draft) }
it { should_not be_published }
end
end
Matchers
# Equality
expect(actual).to eq(expected)
expect(actual).to_not eq(expected)
expect(actual).to be_nil
# Truthiness
expect(actual).to be_truthy
expect(actual).to be_falsy
# Change
expect { obj.save }.to change(Model, :count).by(1)
expect { obj.touch }.to change { obj.updated_at }
# Error
expect { obj.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect { 1 / 0 }.to raise_error(ZeroDivisionError)
# Content
expect(response.body).to include("Welcome")
expect(page).to have_content("Article created!")
expect(page).to have_current_path(articles_path)
# Collection
expect(array).to include(element)
expect(array).to match_array(expected_array)
expect(array).to all(be > 0)
Common Mistakes
1. Testing Implementation Instead of Behavior
# Bad — tests how, not what
it "calls find_each" do
expect(User).to receive(:find_each)
Report.generate
end
# Good — tests the result
it "generates a report" do
create_list(:user, 3, :active)
report = Report.generate
expect(report.user_count).to eq(3)
end
2. Not Using let Efficiently
# Bad — creates records for every test
let(:user) { create(:user) }
# Good — use let! for records that must exist
let!(:users) { create_list(:user, 5) }
3. Testing Too Much in One Example
# Bad — tests multiple things
it "creates and updates and deletes" do
# ...
end
# Good — one assertion per example
it "creates a record"
it "updates attributes"
it "deletes the record"
4. Over-Mocking
# Bad — mocks everything, tests nothing
allow(User).to receive(:find).and_return(user)
allow(Article).to receive(:new).and_return(article)
# Good — use real objects when practical
user = create(:user)
article = create(:article, user: user)
5. Forgetting Database Cleanup
# spec/rails_helper.rb
config.use_transactional_fixtures = true
# Or use DatabaseCleaner for multi-database setups
Practice Questions
1. What's the difference between let and let!?
let lazy-loads and memoizes — the block runs only when first referenced. let! runs the block immediately before each test. Use let for objects you reference; use let! for setup that must exist.
2. What is factory_bot and why use it?
A fixtures replacement that defines test data with traits, associations, and sequences. Factories create valid data quickly without hardcoding values in every test.
3. What's the difference between a stub and a mock?
A stub replaces a method with a fixed return value. A mock sets an expectation that a method will be called. Stubs prepare state; mocks verify behavior.
4. What is a system spec?
An integration test that runs a real browser (Chrome, Firefox) to test the full stack from user interaction to database. System specs test the application as a user would use it.
Challenge: Write a comprehensive test suite for a User model with authentication, profile management, and email notification features.
Solution
# spec/models/user_spec.rb
require "rails_helper"
RSpec.describe User, type: :model do
describe "validations" do
it { should validate_presence_of(:name) }
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email).case_insensitive }
it { should validate_length_of(:password).is_at_least(8) }
end
describe "associations" do
it { should have_many(:articles).dependent(:destroy) }
it { should have_many(:comments).dependent(:destroy) }
it { should have_one(:profile).dependent(:destroy) }
end
describe "#authenticate" do
let(:user) { create(:user, password: "secret123") }
it "returns user with correct password" do
expect(user.authenticate("secret123")).to eq(user)
end
it "returns false with incorrect password" do
expect(user.authenticate("wrong")).to be_falsy
end
end
describe "#send_welcome_email" do
let(:user) { build(:user) }
it "sends an email after create" do
expect(WelcomeMailer).to receive(:welcome_email)
.with(user)
.and_return(double(deliver_later: true))
user.save!
end
end
describe ".active" do
let!(:active_user) { create(:user, last_login: 1.day.ago) }
let!(:inactive_user) { create(:user, last_login: 31.days.ago) }
it "returns users who logged in within 30 days" do
expect(User.active).to include(active_user)
expect(User.active).not_to include(inactive_user)
end
end
describe "#display_name" do
it "returns name when available" do
user = build(:user, name: "Alice")
expect(user.display_name).to eq("Alice")
end
it "returns email prefix when name is blank" do
user = build(:user, name: "", email: "alice@example.com")
expect(user.display_name).to eq("alice")
end
end
end
# spec/requests/users_spec.rb
require "rails_helper"
RSpec.describe "Users", type: :request do
describe "POST /users" do
let(:valid_params) do
{ user: { name: "Alice", email: "alice@test.com",
password: "password123", password_confirmation: "password123" } }
end
it "creates a user and sends welcome email" do
expect {
post users_path, params: valid_params
}.to change(User, :count).by(1)
.and change { ActionMailer::Base.deliveries.count }.by(1)
end
it "returns error for invalid data" do
post users_path, params: { user: { name: "" } }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
FAQ
{{< faq question="Should I use RSpec or Minitest?" >}} RSpec is more popular in the Rails community with richer matchers and DSL. Minitest ships with Ruby and is simpler. Choose RSpec for Rails projects; both are valid. {{< /faq >}}
{{< faq question="What is the difference between unit and integration tests?" >}} Unit tests test a single class/model in isolation. Integration tests (request specs, system specs) test the interaction between components — controller, model, view, and database. {{< /faq >}}
{{< faq question="How often should I run tests?" >}} Run related tests on every file save (Guard, Spring). Run the full suite before pushing. CI runs all tests on every Pull Request. Never merge with failing tests. {{< /faq >}}
{{< faq question="What is TDD?" >}} Test-Driven Development: Red-Green-Refactor cycle. Write a failing test first (Red), write minimal code to pass it (Green), then improve the code (Refactor). Ensures testable, well-designed code. {{< /faq >}}
{{< faq question="How do I test external API calls?" >}} Use WebMock or VCR gems. WebMock stubs HTTP requests. VCR records real responses once and replays them. Both prevent real network calls in tests and make tests deterministic. {{< /faq >}}
Try It Yourself
# Setup RSpec
rails new testing_demo
cd testing_demo
# spec/models/calculator_spec.rb
require "rails_helper"
RSpec.describe "Calculator", type: :model do
describe "basic arithmetic" do
it "adds two numbers" do
result = 2 + 3
expect(result).to eq(5)
end
it "subtracts numbers" do
expect(10 - 4).to eq(6)
end
end
describe "edge cases" do
it "handles zero" do
expect(0 + 5).to eq(5)
end
it "handles negative numbers" do
expect(-3 + 7).to eq(4)
end
end
describe "with context" do
subject { [1, 2, 3] }
it { should include(2) }
it { should_not include(5) }
its(:size) { should eq(3) }
its(:sum) { should eq(6) }
end
end
bin/rails generate rspec:install
bundle exec rspec
What's Next
Now that you understand testing, explore Ruby Metaprogramming — the power to write code that writes code.
| Topic | Description | Link |
|---|---|---|
| Ruby Metaprogramming | define_method, send, method_missing | {{< ref "33-metaprogramming-basics" >}} |
| Ruby DSL | Domain-Specific Language creation | {{< ref "34-dsl" >}} |
| Python Unittest | Compare Python's testing framework | Python |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro