RSpec
RSpec is a behavior-driven testing DSL — the most common alternative to Minitest in the Ruby world, especially paired with Rails via rspec-rails.
Structure
RSpec.describe Post do
describe "#publish!" do
context "when already published" do
it "raises an error" do
post = build(:post, published_at: Time.current)
expect { post.publish! }.to raise_error(Post::AlreadyPublishedError)
end
end
context "when a draft" do
it "sets published_at" do
post = build(:post)
post.publish!
expect(post.published_at).to be_present
end
end
end
end
describegroups examples by class/method/feature;contextgroups by state (“when X” / “with Y”) — functionally identical todescribe, used for readability.it "does something" do ... end— one example (a test).
Matchers
expect(actual).to eq(expected) # ==
expect(actual).to eql(expected) # eql? (stricter, no type coercion)
expect(actual).to be(expected) # equal?, same object identity
expect(actual).to be_truthy / be_falsey
expect(actual).to be_nil
expect(collection).to include(x)
expect(collection).to match_array([a, b]) # same elements, any order
expect(string).to match(/regex/)
expect(obj).to respond_to(:method_name)
expect(obj).to be_an_instance_of(Klass)
expect(obj).to have_attributes(title: "Hello")
expect { block }.to raise_error(ErrorClass, "message")
expect { block }.to change { obj.reload.count }.by(1)
be_valid, be_empty, be_present etc. are “predicate matchers” — be_xxx calls xxx? on the object automatically.
let, let!, and subject
RSpec.describe Post do
subject(:post) { build(:post, title: "Hello") }
let(:author) { create(:user) }
let!(:existing_post) { create(:post) } # eagerly evaluated, runs before each example
it { is_expected.to be_valid }
end
letis lazily memoized per example — not evaluated until first referenced.let!forces evaluation before the example runs (useful when you need a record to exist in the DB regardless of whether the example references the variable directly).subjectnames “the thing under test”;is_expected.toreads naturally against it.
Hooks
before(:each) { ... } # before every example (default if you just write `before`)
before(:all) { ... } # once per describe/context block — be careful with DB state
after(:each) { ... }
around(:each) { |example| Timecop.freeze { example.run } }
Doubles and mocking
mailer = instance_double(UserMailer, welcome: true)
allow(UserMailer).to receive(:new).and_return(mailer)
expect(logger).to receive(:info).with("done").once
service.call
double— a bare test double, no relationship to a real class.instance_double(Klass)— verifies the stubbed methods actually exist onKlass(catches typos/renamed methods that a bare double would miss). Prefer this overdoublewhen stubbing real collaborators.allow(...).to receive(...)stubs;expect(...).to receive(...)sets an expectation that must be met by the end of the example.
Shared examples
RSpec.shared_examples "a taggable model" do
it "responds to #tags" do
expect(subject).to respond_to(:tags)
end
end
RSpec.describe Post do
it_behaves_like "a taggable model"
end
Useful for behavior shared across otherwise-unrelated classes (e.g. concerns/modules).
rspec-rails spec types
| Type | Directory | Tests |
|---|---|---|
| Model | spec/models/ |
validations, scopes, methods |
| Request | spec/requests/ |
full HTTP request/response cycle |
| System | spec/system/ |
real browser, via Capybara |
| Job | spec/jobs/ |
ActiveJob classes |
| Mailer | spec/mailers/ |
ActionMailer classes |
| View | spec/views/ |
rendered view output (less common now) |
Request specs have largely replaced controller specs as the recommended way to test controllers in modern Rails apps.
Running
bundle exec rspec # whole suite
bundle exec rspec spec/models/post_spec.rb
bundle exec rspec spec/models/post_spec.rb:12 # single example by line
bundle exec rspec --seed 1234 # reproduce a specific run order
bundle exec rspec --only-failures # rerun what failed last time
Config lives in spec/spec_helper.rb (plain Ruby) and spec/rails_helper.rb (loads Rails + database_cleaner/transactional fixtures + rspec-rails).