Build your own RSpec – introduction to DSL and metaprogramming

Ruby on Rails / RSpec

A gentle introduction to DSL and metaprogramming in Ruby.

DSL — Domain specific language is a programming technique based on language creation to describe the certain problem. The best example are routes in Ruby on Rails framework or RSpec syntax.

Metaprogramming — it is a programming technique in which given code have the ability to create another code. It is related closely to DSL as, without it, it will not be possible to use domain specific language.

RSpec — is a DSL testing tool written in Ruby to test Ruby code. It is available as a gem and you can easily use it in your Ruby on Rails application.

Although both DSL and metaprogramming are very large topics we would just lick them today in a very fun and easy to understand way. If you didn’t work with RSpec yet I encourage you to give it a try before further reading.

Why I should use DSL?

If you are still not sure why and where you should use DSL and metaprogramming I will show you one simple example before we dive into building our own RSpec. Let us consider simple config class:

class Server
  attr_reader :env, :domain
end

class Config
  def server
    @server ||= Server.new
  end
end

It’s a fake server configuration class. If we want to configure environment and server domain we have to call it like this:

config = Config.new
config.server.env = 'production'
config.server.domain = 'domain.com'

Not really config way of doing this. Boring! Let us use metaprogramming and have some fun by making it sweet. We would use instance_eval method.

Evaluate instance?

Yep, that is what we want to mostly use in this article. Let me explain it by writing a meaningful example of our new config class:

class Server
  attr_accessor :env, :domain
end

class Config
  def initialize(&block)
    instance_eval &block
  end

  def server
    @server ||= Server.new
  end
end

Config.new do
  server.env = 'production'
  server.domain = 'domain.com'
end

Very stylish, so config way. The only thing that has changed in our implementation is the addition of initialize method which takes only a block as an argument. Inside the method, we only call our today’s star instance_eval — what it does? Everything you will pass inside the block will be executed in the context of the Config class instance. In other words: inside the block, you have access to everything but without config prefix.

Example class

We want to build our RSpec so we need something we can test. In spite of Test Driven Development, we would create sample class first to better demonstrate the idea of this article.

class NumberService
  def number
    12
  end
end

We have our dumb class so now, using RSpec, we would write something similar:

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end

    it 'does not return 10' do
      expect(NumberService.new.number).not_to eq(10)
    end
  end
end

You already know instance_eval method so you probably know how we can build such code. Let’s discuss all method used in our test:

  • describe it takes class or string as an argument — both cases gives us the ability to output string so it’s ok. You will get what I mean when you run your tests with --format documentation flag.
  • it takes a description of a given execution path. With describe method, it will be used to display summary for our tests in a documentation way.
  • expect it simply takes a value. I didn’t dig into RSpec code so don’t compare it with source code as it may be far away from original implementation.
  • to and not_to are equivalent to == and != operators
  • eq is more like a sugar for better readability

We have to implement at least 6 methods. Let us start with the describe method first.

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end
end

Our method accepts two arguments where the first one is a description and the second one is block. We can now create the new instance of our class:

Describe.new NumberService do
  # something
end

The problem is that we want to use describe not Describe.new — to fix this we have to create a helper method:

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

describe NumberService do
  # check something
end

It looks way better now! On our example we have two nested describe blocks so we have to add a support for it:

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def describe(context_name, &block)
    Describe.new(context_name, &block)
  end
end

## Helper method

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

## Our test

describe NumberService do
  describe '#number' do

  end
end

It’s now possible to nest describe block in other describe block. You can test this code by saving its contents in rspec.rb file and running ruby rspec.rb

It’s time to implement it method which describes execution path:

class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def describe(context_name, &block)
    Describe.new(context_name, &block)
  end

  def it(context_name, &block)

  end
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do

    end
  end
end

Next step is to add a code to expect given result. In order to do this, we have to implement expect, to and eq methods. We will add them to a new class called Example:

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)

  end
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

Now our example does nothing as we only allowed to call methods in a chain but there is no implementation at all.

What should expect method do? Simply assign the result of a method call so it will be available for to method:

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)

  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

Our eq method is just a replacement for == but the problem is that we want to pass it to the to method and compare with result. The answer is Proc:

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    self
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

The last step is to implement to method — it should compare the result with expectation:

class Example
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

When you will use puts expect(NumberService.new.number).to eq(12) you will see true as an output — nice!

Output test result

We have our logic implemented but we still don’t know how to display test result in our console. Let us start with Example method and store test result in a public reader so Describe class can access it:

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

We know that can have many describe and it blocks so let us collect them in our Describe class to have access to them while generating test output:

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  private
  attr_accessor :describes
end

The last step is to implement rendering method. Let us name it test and add it to the Describe class:

class NumberService
  def number
    12
  end
end

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  def test
    puts context_name
    describes.each do |describe_node|
      puts "  " + describe_node.context_name
      describe_node.examples.each do |example_node|
        puts "    " + example_node.context_name
      end
    end
  end

  private
  attr_accessor :describes
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

rspec = describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

rspec.test

After running ruby rspec.rb output is as following:

NumberService
  #number
    returns 12

But how do we know that our spec passed or failed? Let us render failure in red and success in green. To do this we have to add colorize gem:

gem install colorize

Colorize gem allows to render text in a given color using #colorize method which takes a color symbol as an argument. In our case, it would be: :red or :green. Remember that we can call example_node.test_result to detect which color we should use. Here comes the final solution:

require 'colorize'

class NumberService
  def number
    12
  end
end

class Describe
  attr_reader :context_name, :examples

  def initialize(context_name, &block)
    @context_name = context_name
    @describes = []
    @examples = []
    instance_eval &block
  end

  def describe(context_name, &block)
    describes << Describe.new(context_name, &block)
  end

  def it(context_name, &block)
    examples << Example.new(context_name, &block)
  end

  def test
    puts context_name
    describes.each do |describe_node|
      puts "  " + describe_node.context_name
      describe_node.examples.each do |example_node|
        color = example_node.test_result ? :green : :red
        puts "    " + example_node.context_name.colorize(color)
      end
    end
  end

  private
  attr_accessor :describes
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

class Example
  attr_reader :context_name, :test_result

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def expect(result)
    @result = result
    self
  end

  def to(expectation)
    @test_result = expectation.call(result)
  end

  def eq(expectation)
    Proc.new { |n| n.eql?(expectation) }
  end

  private
  attr_reader :result
end

rspec = describe NumberService do
  describe '#number' do
    it 'returns 12' do
      expect(NumberService.new.number).to eq(12)
    end
  end
end

rspec.test

Further changes

It is a very simple and limited implementation but I hope it gave you a clue how RSpec is built. You can improve it now by moving class declarations to separated files and be adding support for the to_not method.

Huh, luckily RSpec is already created and we don’t have to build our own version to test our apps.