- Tests should be reliable - They should pass when code works and fail when it doesn't
- Tests should be easy to write - Simple syntax and clear patterns
- Tests should be easy to understand - Readable by you and your team, now and in the future
Each test should clearly state what it's testing using active voice:
# Good - uses active voice
it "requires a nickname" do
user = User.new(nickname: nil)
expect(user).to be_invalid
expect(user.errors[:nickname]).to include("can't be blank")
end
# Avoid - uses passive voice
it "should have a nickname"
Keep unit tests focused on a single behavior:
# Good - separate tests for each validation
it "requires a nickname" do
user = FactoryBot.build(:user, nickname: nil)
expect(user).to be_invalid
expect(user.errors[:nickname]).to include("can't be blank")
end
it "requires an email" do
user = FactoryBot.build(:user, email: nil)
expect(user).to be_invalid
expect(user.errors[:email]).to include("can't be blank")
end
Always test what should happen AND what shouldn't happen:
describe "scope by_word_in_name" do
context "when a match is found" do
it "returns matching recipes" do
recipe = FactoryBot.create(:recipe, name: "Pepperoni Pizza")
results = Recipe.by_word_in_name("pepperoni")
expect(results).to include(recipe)
end
end
context "when no match is found" do
it "returns an empty collection" do
FactoryBot.create(:recipe, name: "Cheese Pizza")
results = Recipe.by_word_in_name("veggie")
expect(results).to be_empty
end
end
end
Model specs should test:
- Validations
- Instance methods
- Class methods and scopes
- Edge cases
RSpec.describe User, type: :model do
# Test basic validity
it "is valid with an email, password, and nickname" do
user = FactoryBot.build(:user)
expect(user).to be_valid
end
# Test validations
it "requires a nickname" do
user = FactoryBot.build(:user, nickname: nil)
expect(user).to be_invalid
expect(user.errors[:nickname]).to include("can't be blank")
end
# Test instance methods
describe "#new_to_site?" do
it "returns true for users created within the last month" do
user = FactoryBot.build(:user, created_at: Time.now)
expect(user).to be_new_to_site
end
it "returns false for users created more than a month ago" do
user = FactoryBot.build(:user, created_at: 2.months.ago)
expect(user).to_not be_new_to_site
end
end
end
Rails and RSpec recommend request specs for testing controllers:
RSpec.describe "Recipes", type: :request do
describe "GET /index" do
context "as a guest" do
it "redirects to sign-in" do
get recipes_path
expect(response).to redirect_to sign_in_path
end
end
context "as an authenticated user" do
it "displays recipes list" do
user = FactoryBot.create(:user)
get recipes_path(as: user)
expect(response).to be_successful
end
end
end
end
Always test guest, authenticated, and authorized states:
describe "GET /edit" do
context "as a guest" do
it "redirects to sign-in" do
recipe = FactoryBot.create(:recipe)
get edit_recipe_path(recipe)
expect(response).to redirect_to sign_in_path
end
end
context "as the recipe owner" do
it "displays edit form" do
user = FactoryBot.create(:user)
recipe = FactoryBot.create(:recipe, user: user)
get edit_recipe_path(recipe, as: user)
expect(response).to be_successful
end
end
context "as another user" do
it "returns 404" do
user = FactoryBot.create(:user)
recipe = FactoryBot.create(:recipe)
get edit_recipe_path(recipe, as: user)
expect(response).to have_http_status(:not_found)
end
end
end
Focus on user workflows, not implementation details:
RSpec.describe "User login", type: :system do
before do
driven_by(:rack_test) # Use Selenium only when needed
end
it "allows user to sign in and out" do
user = FactoryBot.create(:user,
email: "[email protected]",
password: "password123"
)
visit root_path
click_link "Sign in"
fill_in "Email", with: "[email protected]"
fill_in "Password", with: "password123"
click_button "Sign in"
expect(page).to have_content user.nickname
expect(page).to have_button "Sign out"
click_button "Sign out"
expect(page).to have_link "Sign in"
end
end
# For JavaScript interactions
before do
driven_by(:selenium_chrome_headless)
end
FactoryBot.define do
factory :user do
sequence(:nickname) { |n| "test_user#{n}" }
sequence(:email) { |n| "test_user#{n}@example.com" }
password { "password" }
end
end
FactoryBot.define do
factory :recipe do
name { "Delicious Recipe" }
association :category
association :user
trait :invalid do
name { nil }
end
trait :with_comments do
after(:create) do |recipe|
create_list(:comment, 3, recipe: recipe)
end
end
end
end
# Usage
FactoryBot.create(:recipe, :invalid)
FactoryBot.create(:recipe, :with_comments)
- Use
build
instead ofcreate
when possible - Be mindful of associations creating extra records
RSpec.describe Comment, type: :model do
let(:user) { FactoryBot.create(:user) }
let(:recipe) { FactoryBot.create(:recipe) }
it "is valid with all attributes" do
comment = Comment.new(
user: user,
recipe: recipe,
comment: "Great recipe!"
)
expect(comment).to be_valid
end
end
# spec/support/login_support.rb
module LoginSupport
def sign_in_as(user)
visit sign_in_path
fill_in "Email", with: user.email
fill_in "Password", with: user.password
click_button "Sign in"
end
end
RSpec.configure do |config|
config.include LoginSupport
end
# spec/support/contexts/recipe_setup.rb
RSpec.shared_context "recipe setup" do
let(:user) { FactoryBot.create(:user) }
let(:recipe) { FactoryBot.create(:recipe, user: user) }
let(:category) { FactoryBot.create(:category) }
end
# Usage in specs
include_context "recipe setup"
describe
for general functionalitycontext
for specific states
RSpec.describe Recipe, type: :model do
describe "#by_word_in_name" do
context "when matches exist" do
# tests for matching scenarios
end
context "when no matches exist" do
# tests for non-matching scenarios
end
end
end
Avoid overly complex nesting that makes tests hard to follow.
- ✅ Test edge cases and validations
- ✅ Use factories to simplify test data creation
- ✅ Keep tests focused and readable
- ✅ Test at the appropriate level (unit vs integration)
- ✅ Use
before
blocks for common setup - ✅ Aggregate failures in integration tests when appropriate
- ❌ Over-mock (especially Active Record methods)
- ❌ Create overly DRY tests that sacrifice readability
- ❌ Test private methods directly
- ❌ Skip writing tests for "simple" functionality
- ❌ Use fixtures instead of factories
# Run all tests
bin/rspec
# Run specific file
bin/rspec spec/models/user_spec.rb
# Run specific test by line number
bin/rspec spec/models/user_spec.rb:42
# Run only model specs
bin/rspec spec/models
# Run only request specs
bin/rspec spec/requests
# Run only feature/system specs
bin/rspec spec/features
bin/rspec spec/system
# Run specific types of specs
bin/rspec spec/controllers spec/models
bin/rspec --tag type:model
bin/rspec --tag type:request
# Skip specific types of specs
bin/rspec --exclude-pattern "spec/{features,system}/**/*_spec.rb"
bin/rspec --tag ~type:system
bin/rspec --tag ~slow
# Run only specs marked with specific tags
bin/rspec --tag focus
bin/rspec --tag integration
# Run with documentation format
bin/rspec --format documentation
- Start with the basics - Don't try to test everything perfectly from the start
- Tests are documentation - Write them to be understood by future developers
- Balance DRY with readability - Sometimes duplication in tests is acceptable
- Test behavior, not implementation - Focus on what the code does, not how
- Use the testing pyramid - More unit tests, fewer integration tests
- Practice Red-Green-Refactor - Write failing tests first, make them pass, then improve