Outside-In Rails Testing: Why I Start with the Consumer, Not the Code
Testing Rails endpoints isn't just about making sure things work—it's about designing clean, maintainable systems. Over time, I’ve found that following an outside-in, consumer-first approach helps me write tests that actually matter.
This guide shows how I do it—with RSpec, FactoryBot, and a healthy dose of skepticism about when I even need a database model.
💡 What I Think About Testing
Let me start with something I’ve learned the hard way:
Tests don’t prove correctness—they reveal your assumptions.
I used to think green tests meant “it works.” But now I know:
If a test fails, it often means the requirements are fuzzy—not that my code is broken.
Also, Rails connects everything by default. That’s both a blessing and a curse. If you lean into it blindly, you’ll create tight coupling. If you design thoughtfully, you can take advantage of it.
🔍 Start from the Outside In
When I write an endpoint, I don’t begin with models or controllers. I start with the consumer—what does the outside world expect?
Let’s say I’m building a /books
endpoint. From the consumer's point of view, it should:
Respond to
GET /books
Return a JSON array of books
Include category information
So that’s where I start.
🛠 Step-by-Step Using RSpec
1. ✅ System Spec First (Capybara)
Even if I don’t care much about HTML in this case, I still like to begin with a high-level check:
# spec/system/books_spec.rb
require "rails_helper"
RSpec.describe "Books endpoint", type: :system do
it "shows the books page" do
visit "/books"
expect(page).to have_selector("h1", text: "Books")
end
end
Of course, this test fails. That’s the point.
Failure shows me what to build next.
2. ➕ Add the Route
# config/routes.rb
Rails.application.routes.draw do
resources :books, only: [:index]
end
3. 🧠 Stub the Controller
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
render html: "<h1>Books</h1>".html_safe
end
end
Now the system test passes. Sweet. But HTML isn’t enough—we need JSON.
4. 🔎 Write the First Request Spec
I test the API response before implementing it. Here’s the baseline:
# spec/requests/books_spec.rb
require "rails_helper"
RSpec.describe "Books API", type: :request do
describe "GET /books" do
context "when no books exist" do
it "returns an empty array" do
get "/books", headers: { "ACCEPT" => "application/json" }
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)).to eq([])
end
end
end
end
5. 🔧 Update the Controller to Handle JSON
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
respond_to do |format|
format.html { render html: "<h1>Books</h1>".html_safe }
format.json { render json: [] }
end
end
end
That gets the test green. Now it’s time to test with actual data.
6. 🧪 Test for Real Book Records
Let’s write a more complete spec with some data:
# spec/requests/books_spec.rb
RSpec.describe "Books API", type: :request do
describe "GET /books" do
context "when books exist" do
before do
create(:book, title: "The Great Gatsby", category: "Fiction")
create(:book, title: "Sapiens", category: "Non-fiction")
end
it "returns books with categories" do
get "/books", headers: { "ACCEPT" => "application/json" }
expect(response).to have_http_status(:success)
books = JSON.parse(response.body)
expect(books).to be_an(Array)
expect(books.size).to eq(2)
expect(books.map { |b| b["category"] }.sort).to eq(["Fiction", "Non-fiction"])
end
end
end
end
7. 🟩 Return Actual Data from the Controller
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
respond_to do |format|
format.html { render html: "<h1>Books</h1>".html_safe }
format.json { render json: books_with_categories }
end
end
private
def books_with_categories
Book.all.map do |book|
{
id: book.id,
title: book.title,
category: book.category
}
end
end
end
📦 Setting Up FactoryBot
Here's the factory I use instead of fixtures:
# spec/factories/books.rb
FactoryBot.define do
factory :book do
title { "Sample Book" }
category { "Fiction" }
end
end
🤔 Do I Even Need a Model?
Sometimes I just hard-code the data. Yeah, really.
Creating a Book
model is tempting, but I ask myself:
Will this data change frequently?
Do I need persistence?
Are there complex relationships?
Will I query/filter this later?
If the answer is mostly no, then I skip the model. It keeps things simpler.
But if I do need it...
📐 When I Do Use a Model
Generate it:
rails generate model Book title:string category:string
rails db:migrate
Update the controller:
def books_with_categories
Book.all.map do |book|
{
id: book.id,
title: book.title,
category: book.category
}
end
end
And I’m good to go.
🔄 Refactoring = Real Design
I’ve learned not to treat refactoring as “cleanup.” It’s design work.
After each passing test, I stop and ask:
Can I simplify this?
Is there a better abstraction?
Am I happy with how the code reads?
If I see a better shape for the code, I go for it. But I don’t refactor just to refactor.
🧱 Common Pitfalls I Avoid
Letting Rails bleed into my domain.
My business logic shouldn’t rely on ActiveRecord or any Rails-specific stuff.Writing tests that don’t give me feedback.
If a test doesn’t catch real issues or give me confidence, I delete it.Skipping refactoring.
Just because the test is green doesn’t mean I’m done. Good design is the goal.
🧭 What I Keep in Mind
TDD isn’t about testing—it’s about design.
I use tests to:
Think through user expectations
Drive the smallest possible implementation
Improve the design as I go
When I follow this workflow, I end up with tests that are meaningful, code that’s flexible, and apps that are easier to maintain.