ruby / test
I use a custom test framework for Ruby. My goals are:
- Fast: startup and runtime
- Simple: one assertion method
- Flexible: plain Ruby, easy to extend
- Debuggable: small codebase, easy to understand
- Features: stubs, db transactions, db factories, etc.
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:
- Creating shared fixtures
- Populating materialized views
- Other one-time expensive setup
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:
got= return value from unit under testwant= expected value (when comparing two computed values)row= single hash from a database query (.first)rows= array of hashes from a database query
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.