RSpec for Beginners: Structuring Your Tests

Well-organized tests are easier to read, understand, and maintain. This lesson will guide you through the fundamental concepts of organizing your RSpec tests, focusing on example groups, shared setup code, and metadata.

Why Structure Matters

Imagine trying to find a specific book in a library where all the books are just piled up randomly. It would be a nightmare, right? Similarly, if your tests are not well-structured, it becomes difficult to understand what each test is doing, and it’s hard to maintain them as your application grows.

RSpec provides tools to organize your tests into logical groups, making them easier to read and maintain. This lesson will cover the following:

  • Example Groups: How to group your tests logically.
  • Shared Setup Code: How to avoid repeating setup code in multiple tests.
  • Metadata: How to add extra information to your tests.

Example Groups: The Foundation of RSpec Tests

In RSpec, every test (or “spec”) is part of an example group. Think of example groups as containers that hold related tests. They serve several purposes:

  1. Logical Structure: They help you organize your tests based on the feature or functionality you’re testing.
  2. Context: They describe the context of what you’re testing.
  3. Scope: They act as a scope for shared logic, like setup code or helper methods.
  4. Setup/Teardown: They allow you to run common setup and teardown code.

describe and it

The two most basic building blocks of RSpec tests are describe and it:

  • describe: Creates an example group. It’s used to specify what you are testing.
  • it: Creates a single example (a test) within an example group. It’s used to specify how something should behave.

Let’s look at some examples in the context of an LMS application:

# spec/models/course_spec.rb

RSpec.describe Course do # Example group for the Course model
  it 'has a title' do # Example within the Course group
    course = Course.new(title: 'Introduction to Ruby')
    expect(course.title).to eq('Introduction to Ruby')
  end

  it 'has a description' do
    course = Course.new(description: 'Learn the basics of Ruby programming.')
    expect(course.description).to eq('Learn the basics of Ruby programming.')
  end
end

In this example:

  • RSpec.describe Course do creates an example group for the Course model.
  • it 'has a title' do and it 'has a description' do are individual tests within the Course group.

describe in Detail

The describe method can take different types of arguments:

  • String: A descriptive string of what you’re testing.
  • Ruby Class/Module/Object: The class, module, or object you’re testing.
  • Combination: A class/module/object and a string.
RSpec.describe 'Course Management API' do # String description
end

RSpec.describe Course do # Class name
end

RSpec.describe Course, 'when a student enrolls' do # Class name and string
end

Using a class name is beneficial because it helps catch spelling mistakes and allows for RSpec extensions.

You can also add metadata to describe blocks:

RSpec.describe Course, 'with quizzes', feature: :quizzes do
end

it in Detail

The it method takes a description of the behavior being specified. It can also take custom metadata:

RSpec.describe Assignment do
  it 'can be graded', priority: :high do
  end
end

Other Ways to Get the Words Right

RSpec provides aliases for describe and it to improve readability.

context Instead of describe

context is an alias for describe, but it’s used to group examples related to a shared situation or condition. It reads better in certain contexts.

RSpec.describe Course do
  context 'when a student is enrolled' do
    it 'allows the student to access course materials'
    it 'allows the student to submit assignments'
  end
end

example Instead of it

example is an alias for it, but it’s used when providing data examples rather than sentences about a subject.

RSpec.describe GradeCalculator, 'calculates grades' do
  example 'for a student with 90 points'
  example 'for a student with 75 points'
end

specify Instead of it

specify is used when examples have their own subject, not a common one. It’s useful for cross-cutting concerns.

RSpec.describe 'User Authentication' do
  specify 'users can log in with valid credentials'
  specify 'users cannot log in with invalid credentials'
end

Defining Your Own Names

You can define custom aliases for describe and it. For example, you can create pdescribe and pit for debugging with Pry:

RSpec.configure do |rspec|
  rspec.alias_example_group_to :pdescribe, pry: true
  rspec.alias_example_to :pit, pry: true

  rspec.after(:example, pry: true) do |ex|
    require 'pry'
    binding.pry
  end
end

Now, you can use pdescribe and pit to trigger Pry breakpoints in your tests.

Sharing Common Logic

To avoid repeating code, RSpec provides several techniques for sharing setup logic:

  • let Definitions: For simple initializations.
  • Hooks (before, after, around): For more complex setup and teardown.
  • Helper Methods: For reusable logic.

let Definitions

let is great for setting up anything that can be initialized in a line or two of code. It provides lazy evaluation (the code is only executed when the variable is used).

let(:student) { Student.new(name: 'Alice') }
let(:course) { Course.new(title: 'Advanced Ruby') }

it 'allows a student to enroll in a course' do
  student.enroll(course)
  expect(student.courses).to include(course)
end

Hooks

Hooks are used when let blocks are insufficient (e.g., side effects, database transactions).

  • before: Runs before each example.
  • after: Runs after each example (even if it fails).
  • around: Wraps examples, running code before and after.

before and after

RSpec.describe Assignment do
  before(:example) do
    @original_time = Time.now
  end

  after(:example) do
    Time.stub(:now, @original_time)
  end

  it 'has a due date' do
    assignment = Assignment.new(due_date: Time.now + 7.days)
    expect(assignment.due_date).to be > Time.now
  end
end

Tip: Favor before over after for data cleanup to ensure failures happen in the correct example.

around

RSpec.describe Assignment do
  around(:example) do |ex|
    original_time = Time.now
    ex.run
    Time.stub(:now, original_time)
  end

  it 'has a due date' do
    assignment = Assignment.new(due_date: Time.now + 7.days)
    expect(assignment.due_date).to be > Time.now
  end
end

Config Hooks

Hooks can be defined in RSpec.configure for the entire suite.

RSpec.configure do |config|
  config.around(:example) do |ex|
    original_time = Time.now
    ex.run
    Time.stub(:now, original_time)
  end
end

Important: Use config hooks for incidental details, not essential logic.

Scope

Hooks can run once per example (:example), once per group (:context), or once per suite (:suite).

  • :example is the default scope.
  • :context is used for time-intensive operations that don’t interact with per-example lifecycles.
  • :suite hooks run once before the entire test suite.
RSpec.describe 'Course Management System' do
  before(:context) do
    Database.connect
  end

  after(:context) do
    Database.disconnect
  end
end

RSpec.configure do |config|
  config.before(:suite) do
    FileUtils.rm_rf('tmp')
  end
end

Warning: Use :context hooks cautiously, as they can cause issues with database code.

When to Use Hooks

Hooks should:

  • Remove duplicated or incidental details.
  • Express English descriptions as executable code.

Avoid overusing hooks, as it can make tracing program flow difficult.

Helper Methods

Example groups are Ruby classes, so you can define helper methods.

RSpec.describe GradeCalculator do
  def calculate_grade(score)
    GradeCalculator.new.calculate(score)
  end

  it 'returns A for a score of 90' do
    expect(calculate_grade(90)).to eq('A')
  end
end

Important: Helper methods should make it explicit what behavior is being tested.

Putting Helpers in a Module

Helper methods can be defined in a separate module and included in example groups.

module LMSHelpers
  def create_student(name)
    Student.new(name: name)
  end
end

RSpec.describe Course do
  include LMSHelpers

  it 'allows a student to enroll' do
    student = create_student('Bob')
    # ...
  end
end

Including Modules Automatically

Use config.include in RSpec.configure to include a module in all example groups.

RSpec.configure do |config|
  config.include LMSHelpers
end

Warning: Avoid hiding important details behind config.include.

Sharing Example Groups

Shared example groups allow reusing examples, let constructs, and hooks.

  • shared_context and include_context: For reusing setup and helper logic.
  • shared_examples and include_examples: For reusing examples.
  • it_behaves_like: Creates a new, nested example group to hold shared code.

Sharing Contexts

RSpec.shared_context 'student setup' do
  let(:student) { Student.new(name: 'Alice') }
  before { @original_time = Time.now }
  after { Time.stub(:now, @original_time) }
end

RSpec.describe Course do
  include_context 'student setup'

  it 'allows a student to enroll' do
    # ...
  end
end

Sharing Examples

RSpec.shared_examples 'a model with a title' do
  it 'has a title' do
    expect(subject.title).to be_a(String)
  end
end

RSpec.describe Course do
  subject { Course.new(title: 'Ruby Basics') }
  it_behaves_like 'a model with a title'
end

RSpec.describe Assignment do
  subject { Assignment.new(title: 'Homework 1') }
  it_behaves_like 'a model with a title'
end

Note: Shared examples are typically placed in spec/support.

Nesting

include_examples pastes the shared examples directly into the describe block, which can cause conflicts. it_behaves_like nests the shared examples in their own context, avoiding conflicts.

Tip: When in doubt, choose it_behaves_like.

Customizing Shared Groups With Blocks

RSpec.describe Course do
  it_behaves_like 'a model with a title' do
    subject { Course.new(title: 'Advanced Ruby') }
  end
end

Posted

in

by