Matthew Lang avatar

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
  • describe groups examples by class/method/feature; context groups by state (“when X” / “with Y”) — functionally identical to describe, 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
  • let is 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).
  • subject names “the thing under test”; is_expected.to reads 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 on Klass (catches typos/renamed methods that a bare double would miss). Prefer this over double when 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).