Skip to Content
Course content

103: Shared Examples in RSpec

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably noticed by now that in a growing codebase, you often end up with different classes that are supposed to "act" the same way. In Ruby, we love duck typing—if it walks like a duck and quacks like a duck, it's a duck. But when it comes to testing, that often leads to a boring kind of repetition: writing the exact same five tests for three different classes just because they all implement the same interface.

That's where shared examples come in. Instead of copy-pasting tests, we define the "behavior" once and tell RSpec to apply it to any class that should adhere to that contract.

The problem: Repeating ourselves with User and Project

Let's say we're building a system where both User and Project accounts can be "activated" or "deactivated". I've written a Togglable module to handle this, and I want to make sure both classes handle the state transition correctly.

module Togglable
  def toggle!
    @active = !@active
  end

  def active?
    !!@active
  end
end

class User
  include Togglable
  def initialize; @active = false; end
end

class Project
  include Togglable
  def initialize; @active = false; end
end

If I write my tests the traditional way, my spec file starts looking like this:

RSpec.describe User do
  it "starts inactive" do
    expect(User.new).not_to be_active
  end

  it "toggles activity" do
    user = User.new
    user.toggle!
    expect(user).to be_active
  end
end

RSpec.describe Project do
  it "starts inactive" do
    expect(Project.new).not_to be_active
  end

  it "toggles activity" do
    project = Project.new
    project.toggle!
    expect(project).to be_active
  end
end

This is fine for two classes, but if I add Organization, Task, and Folder, I'm just wasting time. It's also a maintenance nightmare; if the logic for toggle! changes, I have to update tests in five different files.

Defining the shared behavior

I'm going to move these common tests into a shared_examples block. I'll give it a descriptive name so that when the tests fail, the output tells me exactly which "behavior" is broken.

RSpec.shared_examples "a togglable object" do
  it "starts inactive" do
    expect(subject).not_to be_active
  end

  it "toggles activity" do
    subject.toggle!
    expect(subject).to be_active
  end
end

Now, I can use it_behaves_like inside my class specs. This tells RSpec: "Take all the tests inside 'a togglable object' and run them right here, using the current subject."

RSpec.describe User do
  subject { User.new }
  it_behaves_like "a togglable object"
end

RSpec.describe Project do
  subject { Project.new }
  it_behaves_like "a togglable object"
end

Wait, why is the Project test failing?

Here is where I usually trip up when I'm moving too fast. I decided to add a test to the shared example to ensure that toggling an object also updates a updated_at timestamp. I wrote it like this:

RSpec.shared_examples "a togglable object" do
  # ... previous tests ...

  it "updates the timestamp" do
    expect { subject.toggle! }.to change { subject.updated_at }.from(nil)
  end
end

Immediately, the User tests pass, but the Project tests crash with a NoMethodError: undefined method 'updated_at'. I forgot that while both classes are Togglable, only the User class had the updated_at attribute implemented. I tried to force a specific implementation detail into a general behavior spec.

The fix is to remember that shared examples should only test the interface (the "contract"), not the internal implementation of every class that uses it. If updated_at is only for Users, it belongs in user_spec.rb, not the shared example. I'll move that specific test back to the User block and keep the shared examples lean.

Tying it all together with it_behaves_like

By separating the "what it does" (the shared example) from the "who does it" (the class spec), the code becomes much cleaner. If I create a new Organization class tomorrow, I just add one line to its spec, and I instantly know it adheres to the Togglable contract. It's a powerful way to enforce consistency across your Ruby objects without bloating your test suite.




📋 Practical Task

Exercise: Implementing a Searchable Interface for Product and Category

You are building an e-commerce platform. Both the Product and Category classes must implement a searchable_name method that returns a lowercase string used for indexing in a search engine.

Your task:

  • Create a shared example called "a searchable object".
  • The shared example should contain two tests:
    1. It verifies that searchable_name returns a string.
    2. It verifies that searchable_name is always lowercase.
  • Implement the Product and Category classes so that they both respond to searchable_name.
  • Use it_behaves_like in the specs for both classes to ensure they both adhere to the searchable contract.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.