Understanding RSpec Expectations: A Beginner’s Guide

Why Do We Need Expectations?

Imagine you’re building a Learning Management System (LMS). You might have a class that handles student enrollments. How do you know if your enrollment code is working correctly? You could manually test it, but that’s time-consuming and prone to errors. That’s where rspec-expectations comes in. It allows you to write automated tests that check if your code behaves as you intend.

rspec-core provides the structure for your tests, but rspec-expectations is what allows you to actually verify the results.

The Core Idea: Expectations

At its heart, an expectation is a statement about what you believe should be true at a specific point in your code. If that statement is not true, RSpec will tell you that your test has failed, along with a helpful message.

Anatomy of an Expectation

An expectation has three main parts:

  1. Subject: This is the thing you’re testing. It could be a variable, an object, or the result of a method call.
  2. Matcher: This is an object that defines what you expect to be true about the subject. It’s like a rule that the subject must follow.
  3. Custom Failure Message (Optional): This is a message that provides extra context when an expectation fails. It helps you understand why the test failed.

These parts are connected using expect, along with either to or not_to.

Example:

# Let's say we have a class for managing courses in our LMS
class Course
  attr_reader :students

  def initialize
    @students = []
  end

  def enroll(student)
    @students << student
  end
end

# Here's how we might test it using RSpec
RSpec.describe Course do
  it 'should enroll a student' do
    course = Course.new
    student = "Alice"
    course.enroll(student)
    expect(course.students).to include(student), 'Student was not enrolled'
  end
end
    

In this example:

  • course.students is the subject.
  • include(student) is the matcher. It checks if the students array includes the student variable.
  • 'Student was not enrolled' is the custom failure message.

Diving Deeper: expect, to, and not_to

expect

The expect method is like a wrapper that prepares your subject for testing. It takes the subject as an argument and returns an object that you can then use with to or not_to.

expect_example = expect(5)
# => #<RSpec::Expectations::ExpectationTarget:0x007fb4eb83a818 @target=5>
    

Matchers

Matchers are the heart of expectations. They perform the actual test by checking if the subject meets the specified criteria. RSpec provides many built-in matchers for common checks, like:

  • eq(value): Checks if the subject is equal to the given value.
  • be_within(delta).of(value): Checks if the subject is within a certain range of the given value.
  • include(value): Checks if the subject (an array or string) includes the given value.
  • be_empty: Checks if the subject (an array or string) is empty.
  • be_true: Checks if the subject is true.
  • be_false: Checks if the subject is false.
  • be_nil: Checks if the subject is nil.
be_five = eq(5)
# => #<RSpec::Matchers::BuiltIn::Eq:0x007fb4eb82dd98 @expected=5>

to and not_to

  • to: This method checks if the subject matches the matcher. If it does, the test passes. If not, the test fails.
  • not_to: This method checks if the subject does not match the matcher. If it doesn’t, the test passes. If it does, the test fails.
  • to_not: This is an alias for not_to.
expect(5).to eq(5) # => true (test passes)
expect(5).not_to eq(6) # => true (test passes)
expect(5).to_not eq(6) # => true (test passes)
expect(5).not_to eq(5) # Raises RSpec::Expectations::ExpectationNotMetError (test fails)

When Expectations Fail

When an expectation fails, RSpec provides a detailed error message. This message tells you:

  • Which expectation failed.
  • What the subject was.
  • What the matcher was expecting.
  • (If provided) Your custom failure message.

Example:

resp = Struct.new(:status, :body).new(404, 'Not Found')
expect(resp.status).to eq(200), "Expected status 200, but got #{resp.status}: #{resp.body}"
# Raises RSpec::Expectations::ExpectationNotMetError with the custom message

RSpec Expectations vs. Traditional Assertions

You might be familiar with traditional assertions (like assert in other testing frameworks). RSpec expectations are similar but offer several advantages:

  • Composability: Matchers can be combined in flexible ways.
  • Negation: Matchers can be automatically negated using not_to.
  • Readability: The syntax reads like an English description of the expected outcome.
  • More Useful Errors: Provides detailed information about failures.

Example:

expect([1, 2, 3, 4]).to all be_odd
# Error message:
# expected [1, 2, 3, 4] to all be odd
# object at index 1 failed to match:
# expected `2.odd?` to return true, got false

How Matchers Work (Simplified)

Matchers are objects that have two main methods:

  • matches?(actual): Returns true if the subject matches, false otherwise.
  • failure_message: Returns a message to display when the match fails.

Composing Matchers

Matchers can be combined in several ways to create more complex expectations:

  • Passing one matcher into another:
grades = [85, 92, 78, 95]
expect(grades).to start_with( be_within(5).of(90) )
# Checks if the first grade is within 5 of 90
  • Embedding matchers in arrays and hashes:
student = { name: 'Bob', grade: 88 }
expect(student).to include(name: 'Bob', grade: a_value_between(80, 90))
  • Combining matchers with logical and/or operators:
expect(10).to be_even.and be > 5
expect(7).to be_even.or be > 5

Generated Example Descriptions

RSpec can generate descriptions for your tests based on the code. This can reduce the amount of code you need to write.

RSpec.describe Course, '#enroll' do
  subject { Course.new }
  it { is_expected.to respond_to(:enroll) }
  it { should_not respond_to(:drop) }
end
  • subject: Defines the object being tested.
  • is_expected: Shorthand for expect(subject).
  • should: Local alias for expect(subject).to.

Posted

in

by