Skip to content

Ruby Testing with RSpec — Factories Mocks System Tests and TDD Explained

DodaTech Updated 2026-06-28 9 min read

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