ruby / test

I use a custom test framework for Ruby. My goals are:

It has ~250 lines of code that are included at the end of this article.

Test groups and test cases

Test groups inherit 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

Test cases are public instance methods whose names start with test_.

One or more test groups can be defined in the same file.

Assertions

The ok method with a block is the only assertion:

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

# fail
ok { false }
ok { nil }

Always use single-line brace syntax ok { expr }. Multi-line blocks and do...end are not supported.

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 = "user@example.com"
    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

If the expression passed to ok is true, the assertion passes and the test continues.

If the expression is false, the assertion fails and the test runner prints a backtrace and exits immediately with a non-zero status code.

Runner

Run a test file directly:

ruby test/lib/db_test.rb

The framework randomizes test order and prints a seed:

seed 1234

DBTest
  test_exec_special_chars
  test_fuzzy_like_pattern

ok

The test_ outputs are green for passing tests and red for failing tests. If ENV["CI"] is set, no color codes are used.

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 fail, the ok block executes a second time to trace and build the automated failure message. On pass, it does not trace on for performance reasons. So, avoid side effects in ok blocks.

Re-run with the same order using the seed:

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

Run a single test case from the command line:

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

Or, run a single test case from Vim with a vim-test runner.

To run multiple test files, create a file that requires them all, e.g. test/suite.rb:

require_relative "test_helper"

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

Then run it:

ruby test/suite.rb

Before suite

Before any tests run, the framework can execute one-time setup code directly in test_helper.rb. This runs once when the file is loaded:

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

  ALTER SEQUENCE users_id_seq RESTART WITH 2;

  REFRESH MATERIALIZED VIEW cache_companies;
SQL

This is useful for:

Database transactions

Each test case runs in a transaction that rolls back to isolate each test:

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, 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

Database factories

Factory methods for test data work 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 provide defaults and return Data objects with attribute accessors.

State-based

Prefer state-based assertions whenever possible. Assert results or side effects 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

When state-based testing isn't practical, object stubs can help isolate collaborators. Use stub via dependency injection:

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 support 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, use stub_class:

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

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

Class method stubs are automatically restored after each test.

Class method stubs also support 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

All stubs are "spies" whose method calls can be asserted 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, assert 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"} }

calls returns a hash mapping method names to arrays of call records. Each call record is a hash with :args and :kwargs keys.

Yielding stubs

For methods that yield, instead of stub, use 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, capture 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? }

Style guide

Prefer inlining code and avoiding unnecessary local variables. When local variables clarify tests, use these conventions:

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

Typically, separate setup, exercise, and assertion phases with blank lines:

def test_add
  a = 2
  b = 3

  got = a + b

  ok { got == 5 }
end

When exercising the system under test multiple times, group exercise and assertion together:

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

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

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

To reduce verbosity, name fresh fixtures with abbreviations (co, per, u). When querying a changed fixture from the database, prefix the variable with db_ to distinguish it from the original:

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" }

For SQL in assertions, use one-line SELECT * for simple predicates:

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

For more complex queries, use heredocs:

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

Assert empty tables with ok { rows == [] }, not size == 0.

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

For unordered comparisons, map and sort:

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, prefer include? over full-array equality:

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

Rails controller testing

For Rails controller tests, extend Test with Rack::Test methods and helpers:

require_relative "test_helper"
require File.expand_path("../config/environment", __dir__)
require "rackup"

class ControllerTest < Test
  include Rack::Test::Methods

  def app
    Rails.application
  end

  def sign_in
    set_cookie("remember_token=test")
  end

  def sign_in_as(user)
    set_cookie("remember_token=#{user.remember_token}")
  end

  def cookies
    @cookies ||= Cookies.new(rack_mock_session.cookie_jar)
  end

  class Cookies
    def initialize(jar)
      @jar = jar
    end

    def [](name)
      cookie = @jar.get_cookie(name.to_s)
      cookie&.value
    end
  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.dig("flash", "flashes")
      if flash_hash.nil?
        return nil
      end

      flash_hash[key.to_s]
    end
  end

  # override Rack::Test methods to return last_response
  def get(path, params = {}, headers = {})
    super
    last_response
  end

  def post(path, params = {}, headers = {})
    super
    last_response
  end

  private def teardown
    clear_cookies
    header "Ajax-Referer", nil
    super
  end
end

Use ControllerTest for Rails controller tests:

class CompaniesControllerTest < ControllerTest
  def test_index
    sign_in
    co = insert_company(name: "Acme Inc")

    resp = get("/companies")

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

  def test_create
    sign_in

    resp = post("/companies", {company: {name: "New Co"}})

    ok { resp.status == 302 }
    ok { flash[:notice] == "Company created" }
    ok { cookies["remember_token"] == "test" }
  end
end

Separate controller tests from other tests with different suite files:

# test/ruby_suite.rb
require_relative "test_helper"

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

# test/rails_suite.rb
require_relative "rails_helper"

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

Run them separately:

ruby test/ruby_suite.rb  # fast, no Rails
ruby test/rails_suite.rb # slower, loads Rails

An at_exit hook in test/test_helper.rb automatically runs each 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}\n"

    @@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

    puts "\n#{self}"
    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)
    puts "  #{GREEN}#{test}#{RESET}"
  rescue => err
    puts "  #{RED}#{test}#{RESET}"
    lines = err.backtrace.reject { |l| l.include?(__FILE__) }.join("\n  ")
    puts "\n#{RED}fail: #{err}#{RESET}\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 = []

    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

There are other Ruby testing frameworks available, but this one is optimized for my happiness.

← All articles