Testing frameworks are an essential part of building apps or frameworks. What goes into building one? This tutorial explores the steps needed to build such a tool from scratch: Specifier. Once everything is setup the following 'spec' can be executed:
class Echo
def say(message)
message
end
end
Specifier.specify Echo do
describe '#say' do
it 'says "Hello" if you say "Hello"' do
echo = Echo.new
expect(echo.say('Hello')).to equal('Hello')
end
end
end
Like other testing frameworks it is packaged as a gem. Building a gem is easy using Bundler - which configures the core files needed for packaging the library by running the following:
gem install bundler
bundle gem specifier --bin --test=rspec
cd specifier
The above generates a gemspec, default lib and spec files and folders, and a few other configuration files. The gemspec needs to be filled in prior to proceeding by removing any TODOs and adding in slop as dependencies (more on that later):
./specifier.gemspec
spec.files = Dir.glob('{exe,lib}/**/*') + %w[README.md Gemfile]
spec.summary = 'A testing framework written for fun.'
spec.description = 'This should probably never be used.'
spec.license = 'MIT'
# ...
spec.add_dependency 'slop'
spec.add_development_dependency 'bundler'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'rspec'
The above also generates a lib/specifier.rb
file. Anytime a new file is added it needs to be required in lib/specifier.rb
to be used as part of the gem (lib/specifier/version.rb
is required by default). The gem can be used by the console (exit and open when changing things):
bin/console
Specifier::VERSION # 0.1.0
A spec is composed of a number of an expectation and matchers. They are used by code like this:
expect('today').to equal('tomorrow')
For this example expect
instantiates an expectation and equal
instantiates a matcher. To get something close to this example these files can be added:
lib/specifier/matcher/equal.rb
module Specifier
module Matcher
class Equal
def initialize(value)
@value = value
end
def match?(expected)
@expected = expected
expected.eql?(@value)
end
def message
"expected #{@expected.inspect} got #{@value.inspect}"
end
end
end
end
lib/specifier/expectation.rb
module Specifier
class Expectation
def initialize(value)
@value = value
end
def to(matcher)
raise(matcher.message) unless matcher.match?(@value)
end
end
end
The above classes can be used together like this:
Specifier::Expectation.new('today').to(Specifier::Matcher::Equal.new('tomorrow'))
To get closer to the desired spec syntax the concept of a 'world' that specs are executed within needs to be introduced:
lib/specifier/world.rb
module Specifier
class World
def expect(value)
Expectation.new(value)
end
def equal(value)
Matcher::Equal.new(value)
end
end
end
Now the following works:
world = Specifier::World.new
world.expect('today').to world.equal('tomorrow')
A spec is also composed of a combination of contexts and examples. They are used by code like this:
describe 'a sample context' do
it 'a sample example' do
expect('today').to equal('tomorrow')
end
end
These are implemented using a few classes:
lib/specifier/example.rb
module Specifier
class Example
Result = Struct.new(:status, :message)
def initialize(description, &block)
@description = description
@block = block
end
def run(world)
world.instance_eval(&@block)
return Result.new(:pass)
rescue => message
return Result.new(:fail, message)
end
end
end
lib/specifier/context.rb
module Specifier
class Context
def self.setup(description, parent = nil, &block)
context = new(description, parent, &block)
parent.children << context if parent
context.instance_eval(&block)
context
end
def initialize(description, parent = nil, &block)
@description = description
@parent = parent
@block = block
@children = []
@examples = []
@definitions = []
end
def describe(description, &block)
self.class.setup(description, self, &block)
end
def it(description, &block)
@examples << Example.new(description, &block)
end
def run
puts description
@examples.each do |example|
world = Specifier::World.new
result = example.run(world)
puts "#{example.description} #{result.inspect}"
end
@children.each(&:run)
end
end
end
At this point the following syntax works:
context = Specifier::Context.new('root')
context.describe 'a sample context' do
it 'a sample spec' do
expect('today').to equal('tomorrow')
end
end
context.run
Specs are aided by using a variety of formatters and loggers. Loggers are different streams data can be sent to (usually STDOUT
). Formatters are how to structure the results (i.e. stars and dots, pass and fail, etc). For specifier a simple logger to STDOUT
and formatter that displays [PASS]
or [FAIL]
used:
lib/specifier/logger.rb
module Specifier
class Logger
def initialize(stream = STDOUT)
@stream = stream
end
def log(message = nil)
@stream.puts message
end
end
end
lib/specifier/formatter.rb
module Specifier
class Formatter
def initialize(logger)
@logger = logger
end
def record(example, result)
message =
case result.status
when :pass then "[PASS] #{example.description}"
when :fail then "[FAIL] #{example.description}"
end
@logger.log(message)
end
def context(context)
@logger.log(context.description)
yield
end
end
end
The logger and formatter can be setup as singletons on our base module and some boiler plate configuration:
lib/specifier.rb
module Specifier
def self.config
@config ||= Config.new
end
def self.logger
@logger ||= Logger.new
end
def self.formatter
@formatter ||= Formatter.new(logger)
end
def self.contexts
@contexts ||= []
end
def self.specify(scope, &block)
contexts << Context.setup(scope, &block)
end
def self.run
contexts.each(&:run)
end
end
Then the example can be modified to use the formatter / logger:
lib/specifier/context.rb
module Specifier
class Context
# ...
def run
Specifier.formatter.context(self) do
@examples.each do |example|
world = Specifier::World.new
result = example.run(world)
Specifier.formatter.record(example, result)
end
@children.each(&:run)
end
end
end
end
Now this example runs:
Specifier.specify 'a sample context' do
it 'ensures today is tomorrow' do
expect('today').to equal('tomorrow')
end
end
Specifier.run
Every useful testing framework offers a CLI, and specifier is no different. This is where slop is useful for handling our CLI interface:
exe/specifier
#!/usr/bin/env ruby
require 'specifier'
cli = Specifier::CLI.new
cli.parse
lib/specifier/runner.rb
module Specifier
class Runner
def initialize(paths:)
@paths = paths
end
def run
@paths.each do |path|
load path
end
Specifier.run
end
end
end
lib/specifier/cli.rb
module Specifier
class CLI
def parse(items = ARGV)
config = Slop.parse(items)
run(config)
end
private
def run(options)
paths = Set.new
options.arguments.each do |argument|
Find.find(argument) do |path|
paths << path if path.match?(/\A(.*).rb\Z/)
end
end
Runner.new(paths: paths).run
end
end
end
That's it! Specs anywhere can now be run using the gem CLI once installed:
rake install
specifier ./echo_spec.rb
To find a more detailed example of specifier run gem install specifier
or try Specifier.