ai/skills/rspec-rails/SKILL.md
Write Ruby on Rails specs with RSpec following best practices for unit tests, request specs, feature specs, and job specs. Use when writing or modifying RSpec test files for Rails applications.
npx skillsauth add kurko/dotfiles rspec-railsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When writing RSpec tests for Ruby on Rails applications, follow these guidelines to ensure comprehensive, maintainable, and well-structured test coverage.
spec/request and/or Capybara feature specs that already test the same thingOrganize specs to mirror application structure:
spec/
├── models/ # Model unit tests
├── lib/ # Library/service object tests
├── jobs/ # Background job tests
├── features/ # Capybara integration tests
├── requests/ # Request specs (instead of controller specs)
├── factories/ # FactoryBot factories
└── support/ # Test helpers and shared examples
subjectAlways use subject for the class or method under test. Prefer named subjects
for clarity:
# Good - named subject
subject(:fact) do
described_class.new(
organization: organization,
subject: task
)
end
# Good - simple subject
subject { described_class.new(task) }
# Also acceptable for job specs
subject(:perform_asana_job) do
AsanaJob.new.perform(sync_record.id)
end
context ExtensivelyUse context blocks to separate different input states and test scenarios. This
creates clear test organization and makes it easy to understand what's being
tested.
Rules:
context should have a corresponding let variable
inside the context block that corresponds to its value.For boolean conditions, always test both states:
let(:user) { create(:user, admin: admin_role) }
context 'when user is admin' do
let(:admin_role) { true }
it 'allows access to admin panel' do
# test admin behavior
end
end
context 'when user is not admin' do
let(:admin_role) { false }
it 'denies access to admin panel' do
# test non-admin behavior
end
end
Use context blocks for different scenarios:
let(:user) { create(:user, role: role) }
context 'when user is admin' do
let(:role) { :admin }
it 'allows access to admin panel' do
# test admin behavior
end
end
context 'when user is superadmin' do
let(:role) { superadmin }
it 'allows access to admin panel' do
# test superadmin behavior
end
end
context 'when user is viewer' do
let(:role) { viewer }
it 'denies access to admin panel' do
# test non-admin behavior
end
end
Nest contexts to represent state changes and dependencies, but don't overdo it. Aim for 2-3 levels maximum in most cases.
context 'when post is published' do
let(:post) { create(:post, status: status) }
before do
create(:comment, post: post, author: user)
end
it 'sends notification to author' do
expect(subject.notify).to eq(true)
end
context 'when post is later unpublished' do
before do
post.update!(published_at: nil)
end
it 'does not send further notifications' do
expect(subject.notify).to eq(false)
end
context 'when post is republished' do
before do
post.update!(published_at: Time.current)
end
it 'resumes sending notifications' do
expect(subject.notify).to eq(true)
end
end
end
end
describe for MethodsWhen unit testing a method, use describe with the method name:
# For instance methods, use #
describe '#timeline' do
it 'saves records for each analysis' do
expect(analyses.timeline(task)).to eq(expected_result)
end
end
# For class methods, use .
describe '.syncable' do
it 'returns projects in which membership is not paused' do
expect(Project.syncable).to match_array([project1, project2])
end
end
# For ActiveRecord scopes, nest under 'scopes'
describe 'scopes' do
describe '.with_analyses_and_expected_ordering' do
it 'returns tasks in the expected order' do
expect(Task.with_analyses_and_expected_ordering.map(&:name)).to eq(
[wip_task, previously_wip_task, new_task].map(&:name)
)
end
end
end
Prefer state that is injected via constructors when that state is inherent to the class:
# Good - state injected in constructor
subject(:fact) do
described_class.new(
organization: organization,
subject: task
)
end
let(:task) { ... }
# Good - for transformations
subject { described_class.new(task) }
let for Test DataUse let and let! appropriately:
let for lazy-loaded data (only created when referenced)let! for data that must exist before the test runs, like for testing model
scopes# Lazy-loaded, created only when referenced
let(:organization) { create(:organization) }
let(:project) { create(:project, id: 101) }
# Created immediately before each test
let!(:previously_wip_task) do
create(:task, name: 'previously_wip_task').tap do |task|
create(:analysis, :previously_wip, subject: task)
end
end
Focus on validations, scopes, and model methods:
RSpec.describe Project, type: :model do
describe 'scopes' do
describe '#syncable' do
let!(:project_with_user_membership) { create(:project, :with_user_memberships) }
let!(:project_without_user_membership) { create(:project, :asana) }
it 'returns projects with active memberships' do
expect(Project.syncable).to match_array([project_with_user_membership])
end
end
end
end
Rules:
Use for end-to-end user flows:
RSpec.feature 'UserAuthentication' do
let(:user) { create(:user) }
describe 'devise' do
before do
visit new_user_session_path
fill_in 'Email', with: user.email
fill_in 'Password', with: user.password
click_button 'Log in'
end
context 'when user login with existing account' do
it 'redirects to dashboard page' do
expect(page).to have_content('People')
end
end
context 'when user logout from session' do
it 'redirects to login page' do
click_link 'Sign out'
expect(page).to have_current_path(new_user_session_path)
end
end
end
end
Test background jobs with clear contexts for different commands/states:
RSpec.describe NotificationJob do
subject(:perform_job) do
NotificationJob.new.perform(notification_id)
end
let(:notification) { create(:notification, status: status) }
let(:notification_id) { notification.id }
let(:mailer) { instance_double(UserMailer) }
before do
allow(UserMailer).to receive(:new).and_return(mailer)
end
describe '#perform' do
context 'when notification is pending' do
let(:status) { :pending }
it 'sends email to user' do
expect(mailer).to receive(:send_notification).with(notification)
perform_job
expect(notification.reload).to be_sent
end
end
context 'when notification is already sent' do
let(:status) { :sent }
it 'does not send duplicate email' do
expect(mailer).not_to receive(:send_notification)
perform_job
end
end
end
end
Test domain logic and transformations:
RSpec.describe Posts::PublishService do
subject { described_class.new(post, user) }
let(:now) { Time.zone.parse('2020-01-01T12:00:00Z') }
let(:post) { create(:post, :draft, title: title) }
let(:user) { create(:user, :author) }
before do
travel_to(now)
end
describe '#publish' do
context 'when post is valid' do
let(:title) { 'My Blog Post' }
it 'sets published_at timestamp' do
expect do
subject.publish
end.to change { post.reload.published_at }.from(nil).to(now)
end
it 'creates an audit log entry' do
expect do
subject.publish
end.to change { AuditLog.count }.by(1)
end
end
context 'when post is missing required fields' do
let(:title) { nil }
it 'does not publish the post' do
expect do
subject.publish
end.not_to change { post.reload.published_at }
end
end
end
end
Use travel_to for time-dependent tests:
# Bad - setting values inline which makes harder to read
before do
travel_to(Time.zone.parse('2020-01-01 12:00:00'))
end
# Good - setting values as reusable let
let(:now) { '2020-01-01 12:00:00' }
before do
travel_to(Time.zone.parse(now))
end
expect().to syntax, never shouldmatch_array, eq, be_present, be_blankchange matcher for state changeseq over be. Make tests explicit.# Good - specific matcher
expect(Project.syncable).to match_array([project1, project2])
# Good - testing state change
expect do
subject.transform
end.to change { task.reload.events.count }.by(1)
# Good - testing error
expect { perform_asana_job }.to raise_error StandardError
eq over includeWhen asserting hash structures (like serializer output), prefer eq over include.
Using eq shows the complete expected structure, making tests more explicit and
catching unexpected changes to the hash.
# Bad - hides what else might be in the hash
expect(result).to include(
id: task.id,
name: "Test Task",
status: "todo"
)
# Good - explicit about the full structure
expect(result).to eq(
id: task.id,
name: "Test Task",
status: "todo",
segments: [],
deadline: nil,
deadlineDay: nil,
depth: 0,
parentId: nil,
hasChildren: false
)
Exception: Use include only when specifically testing for the presence of
certain keys without caring about the rest (e.g., testing that a specific field
was added to an existing large structure).
Create test doubles for external dependencies:
let(:client) { instance_double(::Asana::Client) }
before do
allow(::Asana::Client).to receive(:new).and_return(client)
expect(stub(Piezo::Asana::Tasks, client: client))
.to receive(:import_all)
.with(remote_project_id: syncable.remote_id)
end
Rules:
instance_double or class_double for test doubles over
double or mockAdd explanatory comments when the test setup or behavior needs context:
# Notice we didn't have an initial event, only when the user removed
# the task from the WIP section.
context 'when user moves the task out of the WIP section' do
# ...
end
# Specific ids to avoid matching with other model ids
let(:project) { create(:project, id: 101) }
Rules:
Use factories with traits and overrides for better description of values:
# Basic factory
let(:user) { create(:user) }
# With traits
let(:project) { create(:project, :with_user_memberships) }
let(:analysis) { create(:analysis, :previously_wip, subject: task) }
# With overrides
let(:task) do
create(
:task,
remote_created_at: Time.parse('2020-01-01T10:00:00Z'),
workspace: project.workspace
)
end
# Building associations
let!(:task) do
create(:task, name: 'task_name').tap do |task|
create(:analysis, :wip, subject: task)
end
end
When writing specs:
expect do
subject.transform
subject.transform # idempotent
end.to change { task.reload.events.count }.by(1)
let!(:active_record) { create(:record, :active) }
let!(:inactive_record) { create(:record, :inactive) }
it 'returns only active records' do
expect(Record.active).to match_array([active_record])
end
context 'when transitioning from state A to state B' do
before do
# Set up state A
end
context 'when condition X is true' do
# Nested context for specific transition scenario
end
context 'when condition X is false' do
# Alternative scenario
end
end
Remember: Write tests that are clear, focused, and maintainable. Future developers (including yourself) should be able to understand what's being tested and why just by reading the test structure.
tools
Create a GitHub pull request from the current branch. Use when user asks to create a PR, open a PR, submit a PR, push and create PR, or similar pull request workflows. Activates for phrases like "create a PR", "open a pull request", "submit PR", "push and PR", "make a PR for this", "open a draft PR".
data-ai
Merge the current worktree branch into main and sync main back. Use when the user says "merge to main", "ship it", "merge and continue", or after completing a task in a worktree and wanting to continue with the next one.
tools
Synchronize AI agent skills, commands, configs, permissions, hooks, and instructions across Claude Code, Codex CLI, and other Agent Skills-compatible tools. Use when the user asks to pull skills from Claude into Codex, sync Codex work back to Claude, migrate agent commands, reconcile frontmatter, update permissions, or keep agent setup files in parity.
testing
Write or update UI-independent use cases for QA. Use when the user says "write use cases", "add use cases", "QA use cases", "update use cases", "compose use cases", or when starting implementation of a new feature (after plan approval). Also activates for "what should we test", "regression cases", or "use cases for QA".