RSpec Getting Started: Installation, Structure, and Core Syntax

RSpec Getting Started: Installation, Structure, and Core Syntax

RSpec is the dominant testing framework in the Ruby ecosystem. It's been around since 2005 and has shaped how Ruby developers think about tests. Unlike Minitest, RSpec leans hard into a domain-specific language that reads close to plain English. Whether that's a feature or a liability depends on your team, but there's no denying that RSpec is the default choice for most Rails projects.

This post covers everything you need to get up and running: installation, project structure, the core DSL, expect syntax, matchers, and how to run your specs.

Installation

Add RSpec to your Gemfile:

group :test do
  gem 'rspec', '~> 3.13'
end

Then run:

bundle install
rspec --init

rspec --init creates two files:

  • .rspec — default CLI flags
  • spec/spec_helper.rb — RSpec configuration

Your .rspec file will look like:

--require spec_helper
--format documentation

The --format documentation flag gives you human-readable output instead of dots. Useful during development, but slower. You can switch back to --format progress (the dot formatter) for CI runs.

For a Rails project, use the rspec-rails gem instead:

group :development, :test do
  gem 'rspec-rails', '~> 6.1'
end

Then:

rails generate rspec:install

This creates spec/spec_helper.rb, spec/rails_helper.rb, and .rspec.

Directory Structure

RSpec follows a convention: specs live in spec/ and mirror your lib/ or app/ structure.

my_project/
├── lib/
│   ├── user.rb
│   └── order.rb
├── spec/
│   ├── spec_helper.rb
│   ├── lib/
│   │   ├── user_spec.rb
│   │   └── order_spec.rb
│   └── support/
│       └── shared_examples.rb

Every spec file ends in _spec.rb. RSpec won't pick it up otherwise.

The Core DSL: describe, context, it

These three methods are the foundation of every RSpec test.

describe

describe defines an example group. It wraps a set of related tests. Pass it a class or a string:

describe User do
  # tests about User go here
end

describe "authentication" do
  # tests about authentication
end

When you pass a class, RSpec uses it as the implicit subject (more on that later).

context

context is an alias for describe. The convention is: use describe for the thing you're testing, and context for the conditions under which you're testing it.

describe User do
  context "when the user is an admin" do
    # ...
  end

  context "when the user is a guest" do
    # ...
  end
end

This distinction is purely stylistic — RSpec treats them identically — but it makes test output much more readable.

it

it defines an example (a single test). The string argument describes what should happen:

describe User do
  it "returns the full name" do
    user = User.new(first_name: "Alice", last_name: "Smith")
    expect(user.full_name).to eq("Alice Smith")
  end
end

You'll also see specify as an alias for it, useful when the description would be grammatically awkward with "it" in front.

The expect Syntax

RSpec uses expect(...).to and expect(...).not_to for assertions:

expect(actual).to eq(expected)
expect(actual).not_to eq(unexpected)

The older should syntax still works but is deprecated for most use cases. Stick with expect.

Matchers

Matchers are the right-hand side of an expectation. RSpec ships with a large standard library of them.

Equality

expect(2 + 2).to eq(4)           # value equality (==)
expect("hello").to eql("hello")  # stricter equality (eql?)
expect(obj).to equal(same_obj)   # object identity (equal?)

Truthiness

expect(value).to be_truthy
expect(value).to be_falsy
expect(value).to be_nil
expect(value).not_to be_nil
expect(value).to be true   # must be exactly true
expect(value).to be false  # must be exactly false

Comparisons

expect(10).to be > 5
expect(10).to be >= 10
expect(10).to be < 20
expect(10).to be_between(5, 15).inclusive

String matchers

expect("hello world").to include("world")
expect("hello").to start_with("hel")
expect("hello").to end_with("llo")
expect("hello123").to match(/\d+/)

Collection matchers

expect([1, 2, 3]).to include(2)
expect([1, 2, 3]).to include(1, 3)
expect([1, 2, 3]).to contain_exactly(3, 1, 2)  # order doesn't matter
expect([1, 2, 3]).to match_array([3, 2, 1])     # same as contain_exactly
expect([]).to be_empty
expect([1, 2, 3]).to have_attributes(length: 3)

Type matchers

expect("hello").to be_a(String)
expect(42).to be_an(Integer)
expect([]).to be_an(Array)
expect(user).to be_a(User)
expect(user).to be_kind_of(User)
expect(user).to be_instance_of(User)  # exact class, no inheritance

Predicate matchers

RSpec auto-generates matchers for any predicate method (methods ending in ?):

expect(user).to be_admin      # calls user.admin?
expect(array).to be_empty     # calls array.empty?
expect(string).to be_frozen   # calls string.frozen?
expect(hash).to have_key(:id) # calls hash.has_key?(:id)

Change matcher

One of RSpec's most useful matchers — tests that something changes as a result of an action:

expect { user.save }.to change { User.count }.by(1)
expect { user.save }.to change { User.count }.from(0).to(1)
expect { order.cancel }.to change(order, :status).from("pending").to("cancelled")

raise_error matcher

expect { divide(1, 0) }.to raise_error(ZeroDivisionError)
expect { divide(1, 0) }.to raise_error(ZeroDivisionError, "divided by 0")
expect { fetch_user(nil) }.to raise_error(ArgumentError, /invalid id/)

have_received (for mocking — preview)

expect(mailer).to have_received(:send_email).with("alice@example.com")

We'll cover mocking in depth in the next post.

Hooks: before and after

Run setup and teardown code around your examples:

describe User do
  before(:each) do   # runs before every example in this group
    @user = User.new(name: "Alice")
  end

  after(:each) do    # runs after every example
    # cleanup
  end

  before(:all) do    # runs once before all examples in this group
    # expensive setup
  end

  it "has a name" do
    expect(@user.name).to eq("Alice")
  end
end

In practice, before(:each) (also written as before) is by far the most common. before(:all) is useful for expensive setup like starting a database, but be careful — state leaks between examples.

let and let!

let is the preferred way to define helper values in RSpec. It's lazy — the block only runs when the value is first accessed in an example:

describe User do
  let(:user) { User.new(name: "Alice", email: "alice@example.com") }

  it "has a name" do
    expect(user.name).to eq("Alice")
  end

  it "has an email" do
    expect(user.email).to eq("alice@example.com")
  end
end

let! is eager — it runs before every example regardless of whether you reference it:

let!(:user) { User.create!(name: "Alice") }

Use let! when the side effect matters (like inserting into a database), not just the return value.

subject

When you pass a class to describe, RSpec sets an implicit subject:

describe User do
  it { is_expected.to respond_to(:full_name) }
end

is_expected is shorthand for expect(subject). You can also define subject explicitly:

describe User do
  subject(:user) { User.new(name: "Alice") }

  it "responds to full_name" do
    expect(user).to respond_to(:full_name)
  end
end

Running Specs

Run the full suite:

bundle exec rspec

Run a single file:

bundle exec rspec spec/lib/user_spec.rb

Run a specific example by line number:

bundle exec rspec spec/lib/user_spec.rb:42

Run examples matching a description:

bundle exec rspec --example "returns the full name"

Run only failed examples from the last run (requires --format json or persistent formatter):

bundle exec rspec --only-failures

To enable --only-failures, add this to spec_helper.rb:

RSpec.configure do |config|
  config.example_status_persistence_file_path = ".rspec_status"
end

Pending and Skipped Examples

Mark an example as pending:

it "does something not yet implemented" do
  pending "not built yet"
  expect(something).to eq(something_else)
end

Pending examples show up in output but don't fail the suite. If a pending example passes (the assertion succeeds), RSpec warns you — the pending annotation is no longer needed.

Skip an example entirely:

xit "is skipped" do
  # never runs
end

xdescribe "skipped group" do
  # nothing in here runs
end

Tagging Examples

You can tag examples and run only tagged subsets:

it "is slow", :slow do
  # ...
end

describe "integration tests", :integration do
  # ...
end

Run only tagged examples:

bundle exec rspec --tag slow
bundle exec rspec --tag ~slow  # exclude slow tests

Configure tags globally in spec_helper.rb:

RSpec.configure do |config|
  config.filter_run_when_matching :focus
  config.run_all_when_everything_filtered = true
end

Then use fit or :focus to temporarily focus on one test:

fit "I'm focused" do
  # only this runs
end

A Complete Example

Putting it all together — a spec for a simple BankAccount class:

# spec/lib/bank_account_spec.rb
require 'spec_helper'
require 'bank_account'

describe BankAccount do
  subject(:account) { BankAccount.new(balance: 100) }

  describe "#deposit" do
    context "with a positive amount" do
      it "increases the balance" do
        expect { account.deposit(50) }.to change { account.balance }.by(50)
      end
    end

    context "with a negative amount" do
      it "raises ArgumentError" do
        expect { account.deposit(-10) }.to raise_error(ArgumentError, "amount must be positive")
      end
    end
  end

  describe "#withdraw" do
    context "when funds are sufficient" do
      it "decreases the balance" do
        expect { account.withdraw(30) }.to change { account.balance }.from(100).to(70)
      end
    end

    context "when funds are insufficient" do
      it "raises InsufficientFundsError" do
        expect { account.withdraw(200) }.to raise_error(BankAccount::InsufficientFundsError)
      end

      it "does not change the balance" do
        expect { account.withdraw(200) rescue nil }.not_to change { account.balance }
      end
    end
  end
end

This structure — describe the class, describe each method, context for conditions, it for outcomes — is the standard RSpec pattern. Follow it consistently and your test output becomes self-documenting.

What's Next

RSpec's core DSL is intentionally small. The real power comes from mocking and stubbing (for isolating units), shared examples (for DRY test suites), and the Rails-specific helpers that tie everything together. The next post in this series covers doubles, allow, expect, and verifying doubles in depth.

Read more

Start now free