rails-tdd
Skill9 starsChanged 9 months agoMITView on GitHub
Test-driven development for Ruby on Rails with RSpec, model/controller/request/system tests, Capybara, FactoryBot, test database management, and TDD workflow with 80%+ coverage
- Reads credentials
We split this skill into the rules it gives an agent
13 rules. About 0 are common, 12 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈12
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Test-driven development patterns for Ruby on Rails applications using RSpec, FactoryBot, and Capybara with comprehensive coverage of …
- Distinctive: line 47
Minimal implementation: - Write just enough code to pass
- Distinctive: line 49
Fast feedback: - Run tests frequently during development
- Distinctive: line 1309
Write tests first: - Follow TDD cycle
- Very specific: line 1312
Keep tests fast: - Use build over create when possible
- Distinctive: line 1315
Mock external services: - Don't hit real APIs in tests
- Distinctive: line 1317
Test callbacks: - Ensure side effects work
- Distinctive: line 1318
Test validations: - All validation rules
- Distinctive: line 1327
Don't over-mock: - Use real objects when fast enough
- Distinctive: line 1328
Don't test private methods: - Test public interface
- Distinctive: line 1330
Don't use sleep: - Use proper waits in system tests
- Distinctive: line 1331
Don't test implementation details: - Test behavior
- Distinctive: line 1332
Don't create brittle tests: - Avoid tight coupling
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rails TDD Workflow
Test-driven development patterns for Ruby on Rails applications using RSpec, FactoryBot, and Capybara with comprehensive coverage of models, controllers, requests, and system tests.
When to Activate
- Writing new Rails features or endpoints
- Adding functionality to existing Rails applications
- Fixing bugs in Rails codebase
- Refactoring Rails code
- Setting up test infrastructure for Rails projects
- Testing background jobs and mailers
TDD Workflow
Red-Green-Refactor Cycle
# Step 1: RED - Write failing test
RSpec.describe User do
it "validates email presence" do
user = User.new(name: "John Doe")
expect(user).not_to be_valid
expect(user.errors[:email]).to include("can't be blank")
end
end
# Step 2: GREEN - Make test pass
class User < ApplicationRecord
validates :email, presence: true
end
# Step 3: REFACTOR - Improve while keeping tests green
class User < ApplicationRecord
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
endTDD Principles
- Write tests first - Define expected behavior before implementation
- Minimal implementation - Write just enough code to pass
- Refactor with confidence - Tests ensure nothing breaks
- Fast feedback - Run tests frequently during development
- Test behavior, not implementation - Focus on outcomes
Test Structure
RSpec Configuration
spec/rails_helper.rb:
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
abort("Running in production mode!") if Rails.env.production?
require 'rspec/rails'
require 'factory_bot_rails'
require 'capybara/rspec'
# Load support files
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
# FactoryBot
config.include FactoryBot::Syntax::Methods
# Database cleanup
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
# Devise helpers (if using Devise)
config.include Devise::Test::IntegrationHelpers, type: :request
config.include Devise::Test::ControllerHelpers, type: :controller
endspec/spec_helper.rb:
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end
config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = "spec/examples.txt"
config.disable_monkey_patching!
config.default_formatter = "doc" if config.files_to_run.one?
config.order = :random
Kernel.srand config.seed
endDirectory Structure
spec/
├── rails_helper.rb
├── spec_helper.rb
├── support/
│ ├── factory_bot.rb
│ ├── database_cleaner.rb
│ └── shared_examples/
├── factories/
│ ├── users.rb
│ ├── posts.rb
│ └── comments.rb
├── models/
│ ├── user_spec.rb
│ ├── post_spec.rb
│ └── concerns/
├── controllers/
│ └── posts_controller_spec.rb
├── requests/
│ └── api/
│ └── posts_spec.rb
├── system/
│ ├── user_authentication_spec.rb
│ └── post_management_spec.rb
└── jobs/
└── notification_job_spec.rbModel Tests
Basic Model Testing
spec/models/user_spec.rb:
require 'rails_helper'
RSpec.describe User, type: :model do
describe "validations" do
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email).case_insensitive }
it { should validate_presence_of(:name) }
it { should validate_length_of(:password).is_at_least(8) }
context "email format" do
it "accepts valid email addresses" do
user = build(:user, email: "user@example.com")
expect(user).to be_valid
end
it "rejects invalid email addresses" do
user = build(:user, email: "invalid-email")
expect(user).not_to be_valid
expect(user.errors[:email]).to include("is invalid")
end
end
end
describe "associations" do
it { should have_many(:posts).dependent(:destroy) }
it { should have_many(:comments) }
it { should have_one(:profile).dependent(:destroy) }
end
describe "callbacks" do
it "normalizes email before save" do
user = create(:user, email: "USER@EXAMPLE.COM")
expect(user.reload.email).to eq("user@example.com")
end
it "sends welcome email after creation" do
expect {
create(:user)
}.to have_enqueued_job(WelcomeEmailJob)
end
end
describe "scopes" do
let!(:active_user) { create(:user, active: true) }
let!(:inactive_user) { create(:user, active: false) }
describe ".active" do
it "returns only active users" do
expect(User.active).to include(active_user)
expect(User.active).not_to include(inactive_user)
end
end
describe ".recent" do
it "orders users by creation date descending" do
older_user = create(:user, created_at: 2.days.ago)
newer_user = create(:user, created_at: 1.day.ago)
expect(User.recent.first).to eq(newer_user)
expect(User.recent.last).to eq(older_user)
end
end
end
describe "instance methods" do
let(:user) { create(:user) }
describe "#full_name" do
it "returns first and last name" do
user = build(:user, first_name: "John", last_name: "Doe")
expect(user.full_name).to eq("John Doe")
end
end
describe "#admin?" do
it "returns true for admin users" do
admin = create(:user, role: "admin")
expect(admin).to be_admin
end
it "returns false for regular users" do
user = create(:user, role: "user")
expect(user).not_to be_admin
end
end
end
endModel with Complex Logic
spec/models/post_spec.rb:
require 'rails_helper'
RSpec.describe Post, type: :model do
describe "validations" do
subject { build(:post) }
it { should validate_presence_of(:title) }
it { should validate_presence_of(:body) }
it { should validate_uniqueness_of(:slug).scoped_to(:user_id) }
it { should validate_length_of(:title).is_at_least(5).is_at_most(100) }
context "when published" do
before { allow(subject).to receive(:published?).and_return(true) }
it { should validate_presence_of(:published_at) }
end
end
describe "state machine" do
let(:post) { create(:post, :draft) }
it "starts as draft" do
expect(post).to be_draft
end
describe "#publish!" do
it "transitions from draft to published" do
expect { post.publish! }.to change { post.state }.from("draft").to("published")
end
it "sets published_at timestamp" do
expect { post.publish! }.to change { post.published_at }.from(nil)
end
it "sends notifications" do
expect {
post.publish!
}.to have_enqueued_job(NotificationJob).with(post.id)
end
end
describe "#archive!" do
let(:post) { create(:post, :published) }
it "transitions from published to archived" do
expect { post.archive! }.to change { post.state }.from("published").to("archived")
end
end
end
describe ".search" do
let!(:matching_post) { create(:post, title: "Rails Testing Guide") }
let!(:other_post) { create(:post, title: "Python Tutorial") }
it "finds posts matching title" do
results = Post.search("Rails")
expect(results).to include(matching_post)
expect(results).not_to include(other_post)
end
it "finds posts matching body" do
post = create(:post, body: "This post is about Rails testing")
results = Post.search("Rails testing")
expect(results).to include(post)
end
end
endController Tests
RESTful Controller Testing
spec/controllers/posts_controller_spec.rb:
require 'rails_helper'
RSpec.describe PostsController, type: :controller do
let(:user) { create(:user) }
let(:post) { create(:post, user: user) }
describe "GET #index" do
it "returns success response" do
get :index
expect(response).to be_successful
end
it "assigns @posts" do
posts = create_list(:post, 3)
get :index
expect(assigns(:posts)).to match_array(posts)
end
it "renders index template" do
get :index
expect(response).to render_template(:index)
end
end
describe "GET #show" do
it "returns success response" do
get :show, params: { id: post.id }
expect(response).to be_successful
end
it "assigns @post" do
get :show, params: { id: post.id }
expect(assigns(:post)).to eq(post)
end
context "when post does not exist" do
it "raises ActiveRecord::RecordNotFound" do
expect {
get :show, params: { id: 999999 }
}.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
describe "GET #new" do
context "when user is signed in" do
before { sign_in user }
it "returns success response" do
get :new
expect(response).to be_successful
end
it "assigns new post" do
get :new
expect(assigns(:post)).to be_a_new(Post)
end
end
context "when user is not signed in" do
it "redirects to sign in page" do
get :new
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe "POST #create" do
before { sign_in user }
context "with valid parameters" do
let(:valid_params) { { post: attributes_for(:post) } }
it "creates a new post" do
expect {
post :create, params: valid_params
}.to change(Post, :count).by(1)
end
it "redirects to created post" do
post :create, params: valid_params
expect(response).to redirect_to(Post.last)
end
it "sets success flash message" do
post :create, params: valid_params
expect(flash[:notice]).to eq("Post was successfully created.")
end
end
context "with invalid parameters" do
let(:invalid_params) { { post: { title: "" } } }
it "does not create post" do
expect {
post :create, params: invalid_params
}.not_to change(Post, :count)
end
it "renders new template" do
post :create, params: invalid_params
expect(response).to render_template(:new)
end
it "returns unprocessable entity status" do
post :create, params: invalid_params
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "PATCH #update" do
before { sign_in user }
context "with valid parameters" do
let(:new_attributes) { { title: "Updated Title" } }
it "updates the post" do
patch :update, params: { id: post.id, post: new_attributes }
post.reload
expect(post.title).to eq("Updated Title")
end
it "redirects to the post" do
patch :update, params: { id: post.id, post: new_attributes }
expect(response).to redirect_to(post)
end
end
context "with invalid parameters" do
let(:invalid_attributes) { { title: "" } }
it "does not update the post" do
original_title = post.title
patch :update, params: { id: post.id, post: invalid_attributes }
post.reload
expect(post.title).to eq(original_title)
end
it "renders edit template" do
patch :update, params: { id: post.id, post: invalid_attributes }
expect(response).to render_template(:edit)
end
end
context "when user is not the owner" do
let(:other_user) { create(:user) }
before { sign_in other_user }
it "does not allow update" do
patch :update, params: { id: post.id, post: { title: "Hacked" } }
expect(response).to have_http_status(:forbidden)
end
end
end
describe "DELETE #destroy" do
before { sign_in user }
it "destroys the post" do
post # create the post
expect {
delete :destroy, params: { id: post.id }
}.to change(Post, :count).by(-1)
end
it "redirects to posts list" do
delete :destroy, params: { id: post.id }
expect(response).to redirect_to(posts_url)
end
end
endRequest Tests
API Testing
spec/requests/api/posts_spec.rb:
require 'rails_helper'
RSpec.describe "Api::Posts", type: :request do
let(:user) { create(:user) }
let(:auth_headers) { { 'Authorization' => "Bearer #{user.auth_token}" } }
describe "GET /api/posts" do
let!(:posts) { create_list(:post, 10) }
it "returns posts" do
get "/api/posts"
expect(response).to have_http_status(:success)
end
it "returns JSON" do
get "/api/posts"
expect(response.content_type).to match(a_string_including("application/json"))
end
it "returns paginated posts" do
get "/api/posts", params: { page: 1, per_page: 5 }
json = JSON.parse(response.body)
expect(json['posts'].length).to eq(5)
end
it "filters by published status" do
published_post = create(:post, :published)
draft_post = create(:post, :draft)
get "/api/posts", params: { status: "published" }
json = JSON.parse(response.body)
ids = json['posts'].map { |p| p['id'] }
expect(ids).to include(published_post.id)
expect(ids).not_to include(draft_post.id)
end
end
describe "GET /api/posts/:id" do
let(:post) { create(:post) }
it "returns the post" do
get "/api/posts/#{post.id}"
expect(response).to have_http_status(:success)
end
it "includes post attributes" do
get "/api/posts/#{post.id}"
json = JSON.parse(response.body)
expect(json['post']['id']).to eq(post.id)
expect(json['post']['title']).to eq(post.title)
expect(json['post']['body']).to eq(post.body)
end
it "includes associated user" do
get "/api/posts/#{post.id}"
json = JSON.parse(response.body)
expect(json['post']['user']).to be_present
expect(json['post']['user']['id']).to eq(post.user_id)
end
context "when post does not exist" do
it "returns 404" do
get "/api/posts/999999"
expect(response).to have_http_status(:not_found)
end
it "returns error message" do
get "/api/posts/999999"
json = JSON.parse(response.body)
expect(json['error']).to eq("Post not found")
end
end
end
describe "POST /api/posts" do
let(:valid_params) do
{ post: attributes_for(:post) }
end
context "with authentication" do
it "creates a post" do
expect {
post "/api/posts", params: valid_params, headers: auth_headers
}.to change(Post, :count).by(1)
end
it "returns created status" do
post "/api/posts", params: valid_params, headers: auth_headers
expect(response).to have_http_status(:created)
end
it "returns created post" do
post "/api/posts", params: valid_params, headers: auth_headers
json = JSON.parse(response.body)
expect(json['post']['title']).to eq(valid_params[:post][:title])
end
context "with invalid params" do
let(:invalid_params) { { post: { title: "" } } }
it "does not create post" do
expect {
post "/api/posts", params: invalid_params, headers: auth_headers
}.not_to change(Post, :count)
end
it "returns unprocessable entity status" do
post "/api/posts", params: invalid_params, headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
end
it "returns errors" do
post "/api/posts", params: invalid_params, headers: auth_headers
json = JSON.parse(response.body)
expect(json['errors']).to be_present
end
end
end
context "without authentication" do
it "returns unauthorized status" do
post "/api/posts", params: valid_params
expect(response).to have_http_status(:unauthorized)
end
end
end
describe "PATCH /api/posts/:id" do
let(:post_record) { create(:post, user: user) }
let(:update_params) { { post: { title: "Updated Title" } } }
context "with authentication" do
it "updates the post" do
patch "/api/posts/#{post_record.id}",
params: update_params,
headers: auth_headers
post_record.reload
expect(post_record.title).to eq("Updated Title")
end
it "returns success status" do
patch "/api/posts/#{post_record.id}",
params: update_params,
headers: auth_headers
expect(response).to have_http_status(:success)
end
end
context "when user is not the owner" do
let(:other_user) { create(:user) }
let(:other_headers) { { 'Authorization' => "Bearer #{other_user.auth_token}" } }
it "returns forbidden status" do
patch "/api/posts/#{post_record.id}",
params: update_params,
headers: other_headers
expect(response).to have_http_status(:forbidden)
end
end
end
describe "DELETE /api/posts/:id" do
let!(:post_record) { create(:post, user: user) }
context "with authentication" do
it "destroys the post" do
expect {
delete "/api/posts/#{post_record.id}", headers: auth_headers
}.to change(Post, :count).by(-1)
end
it "returns no content status" do
delete "/api/posts/#{post_record.id}", headers: auth_headers
expect(response).to have_http_status(:no_content)
end
end
end
endSystem Tests with Capybara
Feature Testing
spec/system/post_management_spec.rb:
require 'rails_helper'
RSpec.describe "Post Management", type: :system do
let(:user) { create(:user) }
before do
driven_by(:selenium_chrome_headless)
sign_in user
end
describe "creating a post" do
it "allows user to create a post" do
visit posts_path
click_link "New Post"
expect(page).to have_current_path(new_post_path)
fill_in "Title", with: "My First Post"
fill_in "Body", with: "This is the content of my post"
click_button "Create Post"
expect(page).to have_content("Post was successfully created")
expect(page).to have_content("My First Post")
expect(page).to have_content("This is the content of my post")
end
it "shows validation errors for invalid post" do
visit new_post_path
fill_in "Title", with: ""
click_button "Create Post"
expect(page).to have_content("Title can't be blank")
expect(page).to have_current_path(posts_path)
end
it "allows adding tags to post" do
create(:tag, name: "Rails")
create(:tag, name: "Testing")
visit new_post_path
fill_in "Title", with: "Tagged Post"
fill_in "Body", with: "Post body"
check "Rails"
check "Testing"
click_button "Create Post"
expect(page).to have_content("Rails")
expect(page).to have_content("Testing")
end
end
describe "editing a post" do
let!(:post) { create(:post, user: user, title: "Original Title") }
it "allows user to edit their post" do
visit post_path(post)
click_link "Edit"
fill_in "Title", with: "Updated Title"
click_button "Update Post"
expect(page).to have_content("Post was successfully updated")
expect(page).to have_content("Updated Title")
expect(page).not_to have_content("Original Title")
end
end
describe "deleting a post" do
let!(:post) { create(:post, user: user) }
it "allows user to delete their post" do
visit post_path(post)
accept_confirm do
click_link "Delete"
end
expect(page).to have_content("Post was successfully deleted")
expect(page).not_to have_content(post.title)
end
end
describe "viewing posts" do
let!(:published_posts) { create_list(:post, 3, :published) }
let!(:draft_posts) { create_list(:post, 2, :draft) }
it "shows only published posts to visitors" do
sign_out
visit posts_path
published_posts.each do |post|
expect(page).to have_content(post.title)
end
draft_posts.each do |post|
expect(page).not_to have_content(post.title)
end
end
end
describe "searching posts" do
let!(:matching_post) { create(:post, title: "Rails Testing Guide", :published) }
let!(:other_post) { create(:post, title: "Python Tutorial", :published) }
it "finds posts matching search query" do
visit posts_path
fill_in "Search", with: "Rails"
click_button "Search"
expect(page).to have_content("Rails Testing Guide")
expect(page).not_to have_content("Python Tutorial")
end
end
describe "pagination" do
before { create_list(:post, 25, :published) }
it "paginates posts" do
visit posts_path
expect(page).to have_css(".post", count: 20)
expect(page).to have_link("Next")
click_link "Next"
expect(page).to have_css(".post", count: 5)
end
end
endAuthentication System Testing
spec/system/user_authentication_spec.rb:
require 'rails_helper'
RSpec.describe "User Authentication", type: :system do
before { driven_by(:selenium_chrome_headless) }
describe "sign up" do
it "allows new user to register" do
visit root_path
click_link "Sign Up"
fill_in "Email", with: "newuser@example.com"
fill_in "Password", with: "password123"
fill_in "Password confirmation", with: "password123"
click_button "Sign Up"
expect(page).to have_content("Welcome! You have signed up successfully")
expect(page).to have_content("newuser@example.com")
end
it "shows validation errors" do
visit new_user_registration_path
fill_in "Email", with: "invalid-email"
fill_in "Password", with: "short"
click_button "Sign Up"
expect(page).to have_content("Email is invalid")
expect(page).to have_content("Password is too short")
end
end
describe "sign in" do
let(:user) { create(:user, password: "password123") }
it "allows existing user to sign in" do
visit root_path
click_link "Sign In"
fill_in "Email", with: user.email
fill_in "Password", with: "password123"
click_button "Sign In"
expect(page).to have_content("Signed in successfully")
expect(page).to have_content(user.email)
end
it "shows error for invalid credentials" do
visit new_user_session_path
fill_in "Email", with: "nonexistent@example.com"
fill_in "Password", with: "wrongpassword"
click_button "Sign In"
expect(page).to have_content("Invalid Email or password")
end
end
describe "sign out" do
let(:user) { create(:user) }
it "allows user to sign out" do
sign_in user
visit root_path
click_link "Sign Out"
expect(page).to have_content("Signed out successfully")
expect(page).to have_link("Sign In")
end
end
endTest Database Management
Database Cleaner Setup
spec/support/database_cleaner.rb:
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
# For system tests that use a separate thread
config.before(:each, type: :system) do
DatabaseCleaner.strategy = :truncation
end
config.after(:each, type: :system) do
DatabaseCleaner.strategy = :transaction
end
endTest Data with FactoryBot
spec/factories/users.rb:
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
password { "password123" }
name { Faker::Name.name }
confirmed_at { Time.current }
trait :admin do
role { "admin" }
end
trait :unconfirmed do
confirmed_at { nil }
end
trait :with_posts do
transient do
posts_count { 3 }
end
after(:create) do |user, evaluator|
create_list(:post, evaluator.posts_count, user: user)
end
end
end
endspec/factories/posts.rb:
FactoryBot.define do
factory :post do
sequence(:title) { |n| "Post Title #{n}" }
body { Faker::Lorem.paragraphs(number: 3).join("\n\n") }
association :user
trait :published do
published { true }
published_at { 1.day.ago }
end
trait :draft do
published { false }
published_at { nil }
end
trait :with_comments do
transient do
comments_count { 5 }
end
after(:create) do |post, evaluator|
create_list(:comment, evaluator.comments_count, post: post)
end
end
trait :with_tags do
transient do
tags_count { 3 }
end
after(:create) do |post, evaluator|
create_list(:tag, evaluator.tags_count, posts: [post])
end
end
end
endspec/support/factory_bot.rb:
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
endFixtures vs Factories
Use FactoryBot for:
- Dynamic test data
- Complex object graphs
- Customizable test scenarios
- Most test cases
Use Fixtures for:
- Reference data (countries, categories)
- Data that rarely changes
- Performance-critical test suites
Testing Background Jobs
ActiveJob Testing
spec/jobs/notification_job_spec.rb:
require 'rails_helper'
RSpec.describe NotificationJob, type: :job do
include ActiveJob::TestHelper
describe "#perform" do
let(:post) { create(:post, :published) }
let(:subscribers) { create_list(:user, 3) }
before do
subscribers.each { |user| post.subscribers << user }
end
it "sends notifications to all subscribers" do
expect {
NotificationJob.perform_now(post.id)
}.to change { ActionMailer::Base.deliveries.count }.by(3)
end
it "enqueues the job" do
expect {
NotificationJob.perform_later(post.id)
}.to have_enqueued_job(NotificationJob).with(post.id)
end
it "enqueues with delay" do
expect {
NotificationJob.set(wait: 1.hour).perform_later(post.id)
}.to have_enqueued_job(NotificationJob)
.with(post.id)
.at(1.hour.from_now)
end
it "handles missing post gracefully" do
expect {
NotificationJob.perform_now(999999)
}.not_to raise_error
end
context "with job execution" do
around do |example|
perform_enqueued_jobs do
example.run
end
end
it "actually sends emails" do
NotificationJob.perform_later(post.id)
expect(ActionMailer::Base.deliveries.count).to eq(3)
end
end
end
endSidekiq Testing
spec/workers/hard_worker_spec.rb:
require 'rails_helper'
require 'sidekiq/testing'
RSpec.describe HardWorker do
describe "#perform" do
let(:post) { create(:post) }
it "enqueues job" do
expect {
HardWorker.perform_async(post.id)
}.to change(HardWorker.jobs, :size).by(1)
end
context "inline execution" do
around do |example|
Sidekiq::Testing.inline! do
example.run
end
end
it "processes the job" do
expect_any_instance_of(HardWorker).to receive(:perform).with(post.id)
HardWorker.perform_async(post.id)
end
end
end
endTesting Mailers
spec/mailers/user_mailer_spec.rb:
require 'rails_helper'
RSpec.describe UserMailer, type: :mailer do
describe "welcome_email" do
let(:user) { create(:user) }
let(:mail) { UserMailer.welcome_email(user) }
it "renders the headers" do
expect(mail.subject).to eq("Welcome to My App")
expect(mail.to).to eq([user.email])
expect(mail.from).to eq(["noreply@myapp.com"])
end
it "renders the body" do
expect(mail.body.encoded).to match(user.name)
expect(mail.body.encoded).to match("Welcome to My App")
end
it "includes confirmation link" do
expect(mail.body.encoded).to match(confirm_email_url(user.confirmation_token))
end
it "sends the email" do
expect {
mail.deliver_now
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
end
describe "password_reset" do
let(:user) { create(:user) }
let(:mail) { UserMailer.password_reset(user) }
it "includes reset link" do
expect(mail.body.encoded).to match(reset_password_url(user.reset_token))
end
it "has correct subject" do
expect(mail.subject).to eq("Password Reset Instructions")
end
end
describe "notification_email" do
let(:user) { create(:user) }
let(:post) { create(:post) }
let(:mail) { UserMailer.notification_email(user, post) }
it "includes post information" do
expect(mail.body.encoded).to match(post.title)
expect(mail.body.encoded).to match(post_url(post))
end
it "has user's name in greeting" do
expect(mail.body.encoded).to match("Hi #{user.name}")
end
end
endTesting Concerns
spec/models/concerns/publishable_spec.rb:
require 'rails_helper'
RSpec.describe Publishable do
let(:dummy_class) do
Class.new(ApplicationRecord) do
self.table_name = 'posts'
include Publishable
end
end
let(:instance) { dummy_class.new }
describe "scopes" do
before do
create(:post, :published)
create(:post, :draft)
end
it "includes published scope" do
expect(dummy_class.published.count).to eq(1)
end
it "includes draft scope" do
expect(dummy_class.draft.count).to eq(1)
end
end
describe "#publish!" do
it "sets published_at" do
expect {
instance.publish!
}.to change { instance.published_at }.from(nil)
end
it "sets published to true" do
expect {
instance.publish!
}.to change { instance.published }.from(false).to(true)
end
end
describe "#published?" do
it "returns true when published_at is set" do
instance.published_at = Time.current
expect(instance).to be_published
end
it "returns false when published_at is nil" do
instance.published_at = nil
expect(instance).not_to be_published
end
end
endCoverage Goals
SimpleCov Configuration
spec/spec_helper.rb (top of file):
require 'simplecov'
SimpleCov.start 'rails' do
add_filter '/spec/'
add_filter '/config/'
add_filter '/vendor/'
add_group 'Models', 'app/models'
add_group 'Controllers', 'app/controllers'
add_group 'Services', 'app/services'
add_group 'Jobs', 'app/jobs'
add_group 'Mailers', 'app/mailers'
minimum_coverage 80
minimum_coverage_by_file 70
endCoverage Targets
| Component | Target Coverage |
| Models | 90%+ |
| Controllers | 85%+ |
| Services | 95%+ |
| Background Jobs | 90%+ |
| Mailers | 85%+ |
| API Endpoints | 90%+ |
| Overall | 80%+ |
Running Coverage
# Run all tests with coverage
rspec
# View HTML report
open coverage/index.html
# Run specific test types
rspec spec/models
rspec spec/controllers
rspec spec/system
# Run with specific format
rspec --format documentation
# Run failed tests only
rspec --only-failures
# Run tests matching pattern
rspec --tag focus
rspec --tag ~slowTesting Best Practices
DO
- Write tests first - Follow TDD cycle
- Test behavior, not implementation - Focus on outcomes
- Use factories - Dynamic, flexible test data
- Keep tests fast - Use
buildovercreatewhen possible - Use descriptive names - Clear test intentions
- Test edge cases - Nil values, empty collections, boundaries
- Mock external services - Don't hit real APIs in tests
- Use shared examples - DRY up common test patterns
- Test callbacks - Ensure side effects work
- Test validations - All validation rules
- Use transactions - Fast database cleanup
- Test permissions - Authorization logic
DON'T
- Don't test Rails internals - Trust the framework
- Don't test third-party gems - Trust the library
- Don't ignore flaky tests - Fix or remove them
- Don't over-mock - Use real objects when fast enough
- Don't test private methods - Test public interface
- Don't leave pending tests - Complete or remove
- Don't use sleep - Use proper waits in system tests
- Don't test implementation details - Test behavior
- Don't create brittle tests - Avoid tight coupling
- Don't skip test database setup - Maintain schema
Quick Reference
Common Matchers
# Equality
expect(value).to eq(expected)
expect(value).to be_truthy
expect(value).to be_nil
# Collections
expect(array).to include(item)
expect(array).to match_array([1, 2, 3])
expect(array).to be_empty
# Strings
expect(string).to start_with("prefix")
expect(string).to end_with("suffix")
expect(string).to match(/regex/)
# Numbers
expect(number).to be > 10
expect(number).to be_between(1, 10)
# Changes
expect { action }.to change { object.attribute }.from(old).to(new)
expect { action }.to change { Model.count }.by(1)
# Errors
expect { action }.to raise_error(ErrorClass)
expect { action }.not_to raise_error
# Jobs
expect { action }.to have_enqueued_job(JobClass)
expect { action }.to have_enqueued_job(JobClass).with(args)
# HTTP
expect(response).to have_http_status(:success)
expect(response).to redirect_to(path)
expect(response).to render_template(:template)Factory Usage
# Build (in memory)
user = build(:user)
# Create (save to database)
user = create(:user)
# Build with stubbed persistence
user = build_stubbed(:user)
# With traits
user = create(:user, :admin, :with_posts)
# With overrides
user = create(:user, email: "specific@example.com")
# Create multiple
users = create_list(:user, 5)
users = create_list(:user, 3, :admin)
# Build associations
post = create(:post, user: user)Capybara Commands
# Navigation
visit path
click_link "Link Text"
click_button "Button Text"
# Forms
fill_in "Field Name", with: "value"
check "Checkbox"
uncheck "Checkbox"
choose "Radio Button"
select "Option", from: "Select"
attach_file "File", "path/to/file"
# Assertions
expect(page).to have_content("text")
expect(page).to have_css(".class")
expect(page).to have_link("Link")
expect(page).to have_button("Button")
expect(page).to have_field("Field")
expect(page).to have_current_path(path)
# JavaScript
accept_confirm { action }
dismiss_confirm { action }
accept_alert { action }
# Waiting
expect(page).to have_content("text", wait: 5)Summary
Rails TDD combines RSpec, FactoryBot, and Capybara for comprehensive testing. Follow the Red-Green-Refactor cycle, test at multiple levels (models, controllers, requests, system), maintain high coverage (80%+), test background jobs and mailers, and keep tests fast and maintainable. Write tests first to drive implementation and ensure code quality.
In this repository55 other files it gives its agents.
- backend-patternsSkill
- brainstormingSkill
- clarify-firstSkill
- clickhouse-ioSkill
- coding-standardsSkill
- continuous-learning-v2Skill
- dart-patternsSkill
- dart-testingSkill
- dispatching-parallel-agentsSkill
- django-patternsSkill
- django-securitySkill
- django-tddSkill
- django-verificationSkill
- eval-harnessSkill
- executing-plansSkill
- extract-patternsSkill
- finishing-a-development-branchSkill
- flutter-patternsSkill
- flutter-verificationSkill
- frontend-designSkill
- frontend-patternsSkill
- golang-patternsSkill
- golang-testingSkill
- iterative-retrievalSkill
- java-coding-standardsSkill
- jpa-patternsSkill
- logging-best-practicesSkill
- model-routingSkill
- postgres-patternsSkill
- python-patternsSkill
- python-testingSkill
- rails-patternsSkill
- rails-securitySkill
- rails-verificationSkill
- receiving-code-reviewSkill
- requesting-code-reviewSkill
- ruby-patternsSkill
- ruby-testingSkill
- security-reviewSkill
- security-scanSkill
- skill-stocktakeSkill
- springboot-patternsSkill
- springboot-securitySkill
- springboot-tddSkill
- springboot-verificationSkill
- strategic-compactSkill
- subagent-driven-developmentSkill
- systematic-debuggingSkill
- test-driven-developmentSkill
- using-git-worktreesSkill
- using-sp-eccSkill
- verification-before-completionSkill
- verification-loopSkill
- writing-plansSkill
- writing-skillsSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

