An RSpec-like test DSL in Ruby, from scratch

RSpec is arguably one of the most influential Ruby DSLs in the ecosystem. Even if you prefer Minitest, the techniques behind it remain useful. Let’s rebuild a minimal core in ~50 lines.

What we aim for

We will implement a simple test runner that will handle the below case:

describe "Amazing RSpec example" do
  it "just works!" do
    expect(4).not_to eq(5)
    expect(4).to eq(4)
    expect(["a", "b", "c"]).to contain("b")
  end
end

This version, for simplicity, will run examples immediately as the file loads (RSpec collects them and runs later).

Step 1: No-op skeleton

I like starting my work on DSLs with an honest skeleton that doesn’t work yet, but doesn’t crash either.

Technically, you could get away with this:

def describe(desc) = nil

But this is not an honest skeleton. Such a describe method would happily run the following code:

describe("an example") { test_this_should_crash_but_will_not }

Blocks aren’t passed as normal arguments, but Ruby lets you capture them as &block (a Proc) or invoke them with yield – but in our case, we never do it.

Here’s a more honest example that properly calls describe and it blocks:

def describe(desc, &block)
  ExampleGroup.new(block).call
end

class ExampleGroup
  def initialize(block)
    @block = block
  end

  def call
    instance_eval(&@block)
  end

  def it(description, &block)
    instance_eval(&block)
  end
end


describe "Amazing RSpec example" do
  it "just works!" do 
    puts "really!"
  end
end

Quick pause. We have instance_eval here – a tool that is powerful, but also deserving an explanation.

instance_eval changes the context in which the block runs. Methods like it, expect, and eq are defined inside ExampleGroup, not in your test file’s outer scope. If we just called @block.call, Ruby would resolve those methods against the block’s original self, fail to find them, and raise “undefined method” errors. With instance_eval, the block executes as if it were written inside the ExampleGroup instance – making all its methods available.

This pattern shows up in many Ruby libraries. Whenever you see a block that mysteriously has access to methods you didn’t define – Rails routes, Gemfile declarations, Sinatra handlers – there’s often an instance_eval / instance_exec (or their cousins class_eval / class_exec) underneath. It’s a common trick for letting users write clean, declarative code while your class provides the vocabulary.

Step 2: Full skeleton

Let’s focus on what our target syntax requires:

expect(4).not_to eq(5)

For this code to work, expect needs to be available inside ExampleGroup. It takes an object and returns something that responds to to and not_to. We’ll call this class ObjectWithExpectation. It stores the actual value passed to its constructor and runs assertions in its to / not_to methods.

Finally, we have eq. This method is called within ExampleGroup and should return a matcher.

def describe(desc, &block)
  ExampleGroup.new(block).call
end

class ExampleGroup
  def initialize(block)
    @block = block
  end

  def call
    instance_eval(&@block)
  end

  def it(description, &block)
    instance_eval(&block)
  end

  def expect(actual)
    ObjectWithExpectation.new(actual)
  end

  def eq(expected) = nil
  def contain(expected) = nil
end

class ObjectWithExpectation
  def initialize(object)
    @object = object
  end

  def to(matcher) = nil
  def not_to(matcher) = nil
end

describe "Amazing RSpec example" do
  it "just works!" do
    expect(4).not_to eq(5)
    expect(4).to eq(4)
    expect(["a", "b", "c"]).to contain("b")
    puts "really!"
  end
end

Running this example will reach the end with no error. We are on the right path.

Step 3: Defining a matcher

Let’s go back to the example:

expect(4).not_to eq(5)

eq(5) should return a matcher – a callable taking one argument (actual value) and returning true if that value is equal to expected value:

def eq(expected)
  ->(actual) { actual == expected }
end

eq5 = eq(5)    # => #<Proc:0x00000001233af420 (lambda)>
eq5.call(5)    # => true
eq5.call(4)    # => false
eq5.call("s")  # => false

def contain(expected)
  ->(actual) { actual.include?(expected) }
end

contains_b = contain("b")        # => #<Proc:0x0000000123896f60 (lambda)>
contains_b.call(["a", "b", "c"]) # => true
contains_b.call([1, 2, 3])       # => false

Step 4: Calling the matcher

Before wiring everything together, let’s see the core idea in its simplest form – a plain method:

def assert_match(actual, matcher)
  matcher.call(actual)
end

assert_match(5, eq(5))   # => true
assert_match(4, eq(5))   # => false

In this toy runner, a matcher is just a lambda, and we call it with the actual value. ObjectWithExpectation wraps this pattern to enable the fluent expect(x).to(matcher) syntax:

class ObjectWithExpectation
  def initialize(object)
    @object = object
  end

  def to(matcher)
    matcher.call(@object)  # same idea, just wrapped
  end

  def not_to(matcher)
    !matcher.call(@object)
  end
end

ObjectWithExpectation.new(4).to(eq(4))      # => true
ObjectWithExpectation.new(4).to(eq(5))      # => false
ObjectWithExpectation.new(4).not_to(eq(5))  # => true

Step 5: Adding output

Returning booleans isn’t very useful for a test runner. Let’s print dots for passes and F’s for failures:

def to(matcher)
  if matcher.call(@object)
    print "."
  else
    print "F"
  end
end

def not_to(matcher)
  if !matcher.call(@object)
    print "."
  else
    print "F"
  end
end

Note: this prints one character per expectation (expect(...) call), not per it block like real RSpec.

Step 6: Complete solution

def describe(desc, &block)
  ExampleGroup.new(block).call
end

class ExampleGroup
  def initialize(block)
    @block = block
  end

  def call
    instance_eval(&@block)
  end

  def it(description, &block)
    instance_eval(&block)
  end

  def expect(obj)
    ObjectWithExpectation.new(obj)
  end

  def eq(value)
    ->(x) { x == value }
  end

  def contain(value)
    ->(x) { x.include?(value) }
  end
end

class ObjectWithExpectation
  def initialize(object)
    @object = object
  end

  def to(matcher)
    print matcher.call(@object) ? "." : "F"
  end

  def not_to(matcher)
    print matcher.call(@object) ? "F" : "."
  end
end

describe "Amazing RSpec example" do
  it "just works!" do
    expect(4).not_to eq(5)
    expect(4).to eq(4)
    expect(["a", "b", "c"]).to contain("b")
  end
end

Output: .... Feel free to experiment and make some examples fail.

The gist

For this toy runner it’s just two tricks: instance_eval to change the execution context (so it and expect resolve inside your class), and matchers as lambdas (so eq(5) is just ->(x) { x == 5 }). Chaining expect(x).to(matcher) reads like English but mechanically it just passes your value to a lambda.