ruby / test framework

I used a custom test framework of about 250 lines of Ruby. It provided stubs, database transactions, and factories. The implementation is at the end of this article.

Test groups and test cases

Test groups inherited from a Test base class:

class MathTest < Test
  def test_greater_than
    ok { 10 > 5 }
  end

  def test_less_than
    ok { 3 < 7 }
  end
end

A test case was a public instance method named test_*. A file could hold one or more test groups.

Assertions

The ok method with a block was the only assertion:

# pass
ok { true }
ok { 2 + 2 == 4 }
ok { "hello" =~ /ello/ }

# fail
ok { false }
ok { nil }

ok required single-line brace syntax.

class NilTest < Test
  def test_nil
    val = nil
    ok { val == nil }
  end

  def test_not_nil
    val = "value"
    ok { val != nil }
  end
end

class RegexTest < Test
  def test_match
    got = "[email protected]"
    want = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    ok { got =~ want }
  end

  def test_no_match
    got = "text"
    ok { got !~ /[<>]/ }
  end
end

class ExceptionTest < Test
  def test_raised
    raised = false

    begin
      raise ArgumentError, "invalid argument"
    rescue ArgumentError
      raised = true
    end

    ok { raised }
  end

  def test_not_raised
    raised = false

    begin
      10 / 2
    rescue
      raised = true
    end

    ok { !raised }
  end
end

A failing ok printed a backtrace and exited.

Runner

I ran a test file directly:

ruby test/lib/db_test.rb

My framework randomized test order and printed a seed:

seed 1234
..
ok

Dots keep test output short. ENV["CI"] disables color codes.

Example output from failing tests:

seed 5678

MathTest#test_add

  fail:

    got == 6
    |   |
    |   false
    5

  path/to/file.rb:42:in `MathTest#test_add`

On failure, ok re-ran the block with tracing to build the message. The block can run twice, so I avoided side effects in ok blocks.

I re-ran with the same order using the seed:

ruby test/lib/db_test.rb --seed 1234

I ran a single test case from the command line:

ruby test/lib/db_test.rb --name test_fuzzy_like_pattern

Or from Vim with vim-test.

A suite file such as test/suite.rb required all test files:

require_relative "test_helper"

Dir["#{__dir__}/**/*_test.rb"].each { |f| require f }

Then I ran it:

ruby test/suite.rb

Before suite

One-time setup code went in test_helper.rb. It ran when Ruby loaded the file:

# before suite
DB.pool.exec(<<~SQL)
  INSERT INTO users (id, name, admin, email)
  VALUES (1, 'Admin', true, '[email protected]')
  ON CONFLICT DO NOTHING;

  ALTER SEQUENCE users_id_seq RESTART WITH 2;

  REFRESH MATERIALIZED VIEW cache_companies;
SQL

Database transactions

Each test case ran in a transaction that rolled back. The tests used a dedicated test Postgres cluster:

class TransactionTest < Test
  def test_insert
    co = insert_company(name: "Acme Inc")

    rows = db.exec("SELECT * FROM companies")
    ok { rows.size == 1 }
  end

  def test_another_insert
    # Database is clean (previous test rolled back)
    rows = db.exec("SELECT * FROM companies")
    ok { rows == [] }
  end
end

To test transaction behavior itself, I set @tx = false:

class TransactionBehaviorTest < Test
  def initialize
    super
    @tx = false
  end

  def test_rollback
    # Test actual transaction behavior
    # Changes cleaned up with DELETE after test
  end
end

Fresh database fixtures

Factory methods inserted fresh fixtures with DB:

class CompaniesTest < Test
  def test_create
    co = insert_company(name: "Acme Inc", status: "Active")

    ok { co.name == "Acme Inc" }
    ok { co.status == "Active" }
  end

  def test_with_relationships
    co = insert_company
    per = insert_person(name: "Jane Doe")
    pos = insert_position(
      person_id: per.id,
      company_id: co.id,
      company_name: co.name,
      title: "CTO"
    )

    ok { pos.person_id == per.id }
  end
end

Factories provided defaults and returned Data objects.

State-based

I preferred to assert on state in the database:

def test_create_company
  Companies::Create.new(db).call(name: "Acme Inc")

  row = db.exec("SELECT * FROM companies").first
  ok { row["name"] == "Acme Inc" }
end

Object stubs

Otherwise, I injected a stub for the collaborator:

module Companies
  class Import
    def initialize(db, client:)
      @db = db
      @client = client
    end

    def call(domain:)
      data, err = @client.fetch(domain)
      if err
        return "err: #{err}"
      end

      @db.exec(<<~SQL, [data["name"]])
        INSERT INTO companies (name)
        VALUES ($1)
      SQL

      "ok"
    end
  end
end

class CompaniesImportTest < Test
  def test_import
    client = stub(fetch: [{"name" => "Acme Inc"}, nil])

    got = Companies::Import.new(db, client: client).call(
      domain: "acme.com"
    )

    ok { got == "ok" }
    ok { client.called?(:fetch) }

    row = db.exec("SELECT * FROM companies").first
    ok { row["name"] == "Acme Inc" }
  end

  def test_api_error
    client = stub(fetch: [nil, "API rate limited"])

    got = Companies::Import.new(db, client: client).call(
      domain: "acme.com"
    )

    ok { got == "err: API rate limited" }
    ok { db.exec("SELECT * FROM companies") == [] }
  end
end

Stubs supported lambdas for transformations:

client = stub(
  transform: ->(text) { text.upcase },
  calculate: ->(a, b) { a + b }
)
ok { client.transform("hello") == "HELLO" }
ok { client.calculate(2, 3) == 5 }

Class method stubs

For class methods, I used stub_class:

class TimeTest < Test
  def test_frozen_time
    stub_class(Time, now: Time.at(0))

    ok { Time.now == Time.at(0) }
  end
end

My framework restored class method stubs after each test.

Class method stubs also supported lambdas:

# identity functions
stub_class(Convert::ExtractDomain, call: ->(host) { host })

# raise errors
stub_class(Aws::S3::Client, new: ->(*) {
  raise StandardError.new("auth failed")
})

# capture variables
err_msg = nil
stub_class(Sentry, capture_exception: ->(e) { err_msg = e.message })
some_code_that_raises
ok { err_msg == "expected error" }

Asserting stub calls

Every stub recorded its calls. I checked them with called?:

client = stub(fetch: [{"name" => "Acme Inc"}, nil])

Companies::Import.new(db, client: client).call(domain: "acme.com")

ok { client.called?(:fetch) }
ok { !client.called?(:delete) }

Or I asserted call count and arguments with calls:

ok { client.calls[:fetch].size == 2 }
ok { client.calls[:fetch][0][:args] == ["acme.com"] }
ok { client.calls[:fetch][0][:kwargs] == {domain: "acme.com"} }

Yielding stubs

For a method that yields, I used Object.new with def:

client = Object.new
def client.get_data(_)
  yield "chunk1"
  yield "chunk2"
end

got = []
client.get_data("http://example.com") { |chunk| got << chunk }
ok { got == ["chunk1", "chunk2"] }

For Object.new stubs, I captured values with instance variables:

client = Object.new
def client.process(_)
  @thread_ref = Thread.current
  yield "data"
end
def client.thread_ref
  @thread_ref
end

some_code_under_test(client)

ok { !client.thread_ref.alive? }

HTTP stubs

I stubbed HTTP requests to third-party services with WebMock:

class CompaniesImportTest < Test
  def test_import
    stub_request(:post, "https://api.example.com/companies")
      .with(body: {domain: "acme.com"})
      .to_return(status: 200, body: {name: "Acme Inc"}.to_json)

    got = Companies::Import.new(db).call(domain: "acme.com")

    ok { got == "ok" }
  end
end

WebMock stubs live in one global registry for the whole process. teardown called WebMock.reset!:

private def teardown
  # ... other cleanup ...
  WebMock.reset!
end

Style guide

I inlined code where I could. When a local variable helped, I used these names:

def test_length
  got = "hello".length
  want = 5
  ok { got == want }
end

Blank lines separated setup, exercise, and assertion:

def test_add
  a = 2
  b = 3

  got = a + b

  ok { got == 5 }
end

With several exercises, I grouped each exercise with its assertion:

def test_multiply
  got = 2 * 3
  ok { got == 6 }

  got = 4 * 5
  ok { got == 20 }

  got = 0 * 10
  ok { got == 0 }
end

I named fresh fixtures with abbreviations (co, per, u). A fixture re-read from the database got a db_ prefix:

co = insert_company(name: "foo")

Something.new(db).call("bar")

db_co = db.exec("SELECT * FROM companies WHERE id = $1", [co.id]).first
ok { db_co["name"] == "bar" }

A simple query went on one line:

note = db.exec("SELECT * FROM notes WHERE company_id = $1", [co.id]).first

A complex query used a heredoc:

job = db.exec(<<~SQL, [per.id]).first
  SELECT
    jobs.*
  FROM
    notes
    JOIN jobs ON jobs.args ->> 'note_id' = notes.id::text
  WHERE
    notes.person_id = $1
    AND jobs.queue = 'slack'
SQL

I asserted an empty result with rows == []:

rows = db.exec("SELECT * FROM tracking WHERE company_id = $1", [co.id])
ok { rows == [] }

For unordered comparisons, I mapped and sorted:

rows = db.exec("SELECT * FROM list_items WHERE company_id = $1", [co.id])
ok { rows.map { |r| r["list_id"] }.sort == [748, 541].sort }

For error messages, I used include?:

ok { got[:errs].include?("Company is required") }

Rack handler testing

For web framework handler tests, I extended Test with Rack::Test. See response modes for how a handler declared its modes.

require_relative "test_helper"
require "rack"
require "rack/test"

class HandlerTest < Test
  include Rack::Test::Methods

  def app
    Framework::Server.app(db)
  end

  def db
    @db ||= DB.new
  end

  def sign_in
    sign_in_as(insert_user(active: true))
  end

  def sign_in_as(user)
    jar = EncryptedCookieJar.new({}, ENV.fetch("SECRET_KEY_BASE"))
    jar.set(user.remember_token, expires: Time.now + 3600)
    headers = {}
    jar.write_to_headers(headers)
    full_cookie = headers["Set-Cookie"].first.match(/^([^;]+)/)[1]
    name, value = full_cookie.split("=", 2)
    encoded_value = URI.encode_www_form_component(value)
    set_cookie("#{name}=#{encoded_value}")
  end

  def flash
    Flash.new(last_request)
  end

  class Flash
    def initialize(request)
      @request = request
    end

    def [](key)
      rack_session = @request.env["rack.session"]
      if rack_session.nil?
        return nil
      end

      flash_hash = rack_session[:flash]
      if flash_hash.nil?
        return nil
      end

      flash_hash[key]
    end
  end
end

I used HandlerTest for handler tests:

class CompaniesHandlerShowTest < HandlerTest
  def test_index
    sign_in
    co = insert_company(name: "Acme Inc")

    resp = get("/companies/#{co.id}")

    ok { resp.status == 200 }
    ok { resp.body.include?("Acme Inc") }
  end
end

Test#run_group reused one instance per class, so the Rack::Test cookie jar carried session state from one test method into the next. teardown cleared it:

private def teardown
  super
  clear_cookies
end

Ping test coverage

A ping test ran the full handler path and asserted status 200. One test class mapped to one handler class:

CompaniesHandler::ShowCompaniesHandlerShowTest
Admin::UsersHandler::IndexAdminUsersHandlerIndexTest

Each test class defined ping methods based on declared modes:

class CompaniesHandlerShowTest < HandlerTest
  def test_ping_html
    sign_in
    co = insert_company

    resp = get("/companies/#{co.id}")

    ok { resp.status == 200 }
    ok { resp.body.include?(co.name) }
  end

  def test_ping_ajax
    sign_in
    co = insert_company

    resp = get("/companies/#{co.id}", {}, {"HTTP_AH_REFERER" => "/"})

    ok { resp.status == 200 }
    ok { !resp.body.include?("<html") }
  end
end
class PingCoverageTest < HandlerTest
  def test_report_missing_ping_coverage
    routes_app = Framework::Server.routes(db)
    missing = []

    routes_app.instance_variable_get(:@routes).each do |method, entries|
      entries.each do |_, path, handler|
        if handler.is_a?(Array) # skip redirects
          next
        end

        klass = handler.class
        modes = klass.modes
        test_class = find_test_class(klass)

        if modes.include?(:html)
          if test_class.nil? || !test_class.method_defined?(:test_ping_html)
            missing << "#{method} #{path} (#{klass}) missing test_ping_html"
          end
        end

        if modes.include?(:ajax)
          if test_class.nil? || !test_class.method_defined?(:test_ping_ajax)
            missing << "#{method} #{path} (#{klass}) missing test_ping_ajax"
          end
        end
      end
    end

    ok { missing == [] }
  end

  private def find_test_class(klass)
    parts = klass.name.split("::")
    parts.shift # remove top-level module
    test_class_name = parts.join + "Test"

    begin
      Object.const_get(test_class_name)
    rescue NameError
      nil
    end
  end
end

A new handler or mode without a ping test failed the build.

Suite files

Handler tests had their own suite file:

# test/ruby_suite.rb
require_relative "test_helper"

Dir.glob(File.join(__dir__, "**", "*_test.rb"))
  .reject { |f| f.include?("/handle/") }
  .sort
  .each { |f| require f }

# test/rack_suite.rb
require_relative "rack_helper"

Dir.glob(File.join(__dir__, "lib", "handle", "**", "*_test.rb"))
  .sort
  .each { |f| require f }

I ran them separately:

ruby test/ruby_suite.rb  # fast, no Rack
ruby test/rack_suite.rb  # slower, loads Rack app

An at_exit hook in test/test_helper.rb ran the suite.

Implementation

The test/test_helper.rb file:

ENV["APP_ENV"] = "test"

require "prism"
require "webmock"
require_relative "../lib/db"
require_relative "factories"

WebMock.enable!
WebMock.disable_net_connect!(allow_localhost: true)

DB.configure do |c|
  c.pool_size = 1
  c.reap = false
end

class Test
  class Failure < StandardError; end

  include Factories
  include WebMock::API

  if ENV["CI"]
    GREEN = ""
    RED = ""
    RESET = ""
  else
    GREEN = "\e[32m"
    RED = "\e[31m"
    RESET = "\e[0m"
  end

  @@groups = []
  @@seed = nil
  @@name = nil

  i = 0
  while i < ARGV.length
    case ARGV[i]
    when "--seed"
      @@seed = ARGV[i + 1].to_i if i + 1 < ARGV.length
      i += 2
    when "--name"
      @@name = ARGV[i + 1] if i + 1 < ARGV.length
      i += 2
    else
      i += 1
    end
  end

  def self.inherited(c)
    @@groups << c
  end

  def self.run_suite
    seed = @@seed || rand(1000..9999)
    srand seed
    puts "seed #{seed}"

    @@groups.shuffle.each do |c|
      c.run_group
    end

    print "\n#{GREEN}ok#{RESET}\n"
  end

  def self.run_group
    group = new

    tests = public_instance_methods(false)
      .grep(/^test_/)
      .shuffle

    if @@name
      tests = tests.select { |t| t.to_s == @@name }
      if tests == []
        return
      end
    end

    if tests == []
      return
    end

    tests.each { |test| group.run_test(test) }
  end

  def db
    DB.pool
  end

  def initialize
    @tx = true
    @stubs = []
  end

  def run_test(test)
    setup
    send(test)
    print "#{GREEN}.#{RESET}"
  rescue => err
    puts "\n#{RED}#{self.class}##{test}#{RESET}"
    lines = err.backtrace.reject { |l| l.include?(__FILE__) }.join("\n  ")
    puts "\n  #{RED}fail:\n#{err}#{RESET}\n\n  #{lines}"
    exit 1
  ensure
    teardown
  end

  def ok(&block)
    if block.call
      return
    end

    rets = []
    tgt = Thread.current
    trace = TracePoint.new(:return, :c_return) do |tp|
      if Thread.current == tgt
        rets << [tp.callee_id.to_s, tp.return_value]
      end
    end
    trace.enable { block.call }

    file, lnum = block.source_location
    line = File.readlines(file)[lnum - 1]
    if line !~ /ok\s*\{\s*(.+)\s*\}/
      raise ArgumentError, "ok requires single-line brace syntax: ok { expr }"
    end
    src = $1.strip

    # method name => [column positions]
    mcols = Hash.new { |h, k| h[k] = [] }
    begin
      visit_ast_node(Prism.parse(src).value, mcols)
    rescue
      raise Test::Failure, "\n    #{src}\n    (unable to parse source)"
    end
    lvars = block.binding.local_variables.map(&:to_s)

    # match return values to column positions
    vals = []
    seen = Hash.new(0)
    rets.each do |name, value|
      cols = mcols[name]
      if cols.any? && seen[name] < cols.length
        vals << [cols[seen[name]], name, value]
        seen[name] += 1
      end
    end

    # Prism treats bare identifiers as method calls; add local variables
    mcols.each do |name, cols|
      if lvars.include?(name) && vals.none? { |_, n, _| n == name }
        cols.each do |col|
          vals << [col, name, block.binding.local_variable_get(name.to_sym)]
        end
      end
    end

    if vals.empty?
      raise Test::Failure, "\n    #{src}\n    (no values to show; try assigning to local variables)"
    end

    # rightmost first for building output lines
    vals.sort_by! { |col, _, _| -col }

    max = vals.map(&:first).max
    pline = " " * (max + 1)
    vals.each { |col, _, _| pline[col] = "|" }

    vlines = []
    vals.each_with_index do |(col, _, value), i|
      ins = begin
        value.inspect
      rescue
        "#<inspect failed>"
      end
      out = " " * (max + 1)
      vals.each_with_index do |(c, _, _), j|
        if j > i
          out[c] = "|"
        end
      end
      if col + ins.length > out.length
        out = out[0...col] + ins
      else
        out[col, ins.length] = ins
      end
      vlines << out.rstrip
    end

    msg = [src, pline.rstrip, *vlines].join("\n")
    raise Test::Failure, "\n    " + msg.gsub("\n", "\n    ")
  end

  def stub(methods)
    obj = Object.new
    calls = Hash.new { |h, k| h[k] = [] }

    methods.each do |meth, return_value|
      obj.define_singleton_method(meth) do |*args, **kwargs, &block|
        calls[meth] << {args: args, kwargs: kwargs}
        if return_value.is_a?(Proc)
          return_value.call(*args, **kwargs, &block)
        else
          return_value
        end
      end
    end

    obj.define_singleton_method(:called?) do |meth|
      calls[meth] != []
    end

    obj.define_singleton_method(:calls) do
      calls
    end

    obj
  end

  def stub_class(klass, methods)
    methods.each do |meth, return_value|
      orig = klass.method(meth)
      @stubs << [klass, meth, orig]

      klass.define_singleton_method(meth) do |*args, **kwargs, &block|
        if return_value.is_a?(Proc)
          return_value.call(*args, **kwargs, &block)
        else
          return_value
        end
      end
    end
  end

  private def setup
    if @tx
      db.exec("BEGIN")
    end
  end

  private def teardown
    @stubs.reverse.each do |klass, meth, orig|
      klass.define_singleton_method(meth, orig)
    end
    @stubs = []

    WebMock.reset!

    if @tx
      db.exec("ROLLBACK")
    else
      tablenames = db.exec(<<~SQL).map { |row| row["tablename"] }
        SELECT
          tablename
        FROM
          pg_tables
        WHERE
          schemaname = 'public'
          AND tablename != 'users'
        ORDER BY
          tablename
      SQL

      tablenames.each do |t|
        db.exec("DELETE FROM #{t}")
      end

      # app-specific cleanup of all users except admin fixture
      db.exec("DELETE FROM users WHERE id != 1")
    end
  end

  private def visit_ast_node(node, cols)
    if node.nil?
      return
    end

    if node.is_a?(Prism::CallNode)
      name = node.name.to_s
      c = node.message_loc&.start_column || node.location.start_column
      cols[name] << c
    end

    node.compact_child_nodes.each { |child| visit_ast_node(child, cols) }
  end
end

at_exit { Test.run_suite }

The test/factories.rb file:

module Factories
  class Sequence
    def initialize
      @counter = 0
    end

    def next
      @counter += 1
    end
  end

  SEQ = Sequence.new

  def insert_company(o = {})
    insert_into("companies", {
      name: o[:name] || "Company #{SEQ.next}"
    }.merge(o))
  end

  def insert_person(o = {})
    insert_into("people", {
      name: o[:name] || "Person #{SEQ.next}"
    }.merge(o))
  end

  def insert_position(o = {})
    insert_into("positions", {
      company_id: o[:company_id] || insert_company.id,
      person_id: o[:person_id] || insert_person.id,
      title: o[:title] || "CEO, Founder"
    }.merge(o))
  end

  private def insert_into(table, attrs)
    row = db.exec(<<~SQL, attrs.values).first
      INSERT INTO #{table} (
        #{attrs.keys.join(", ")}
      ) VALUES (
        #{(1..attrs.size).map { |i| "$#{i}" }.join(", ")}
      )
      RETURNING *
    SQL

    Data.define(*row.keys.map(&:to_sym)).new(*row.values)
  end
end

← All articles