Verity
Metadata-first Ruby tests. Each test is a structured record — description, tags, timeout, resource hints — registered at load time and synced into a SQLite manifest. The runner then executes the manifest, either in a single process or across forked workers that claim tests atomically from it as a work queue.
Requirements
- Ruby ≥ 3.3
-
sqlite3(> 2.0) and> 1.0), installed as gem dependenciesprism(
Installation
Add to your Gemfile:
gem "verity"Or build and install from a checkout:
gem build verity.gemspec && gem install verity-*.gemQuick start
Create verity/math_test.rb (the default discovery glob is verity/**/*_test.rb):
test "addition works" do
assert_equal actual: 1 + 1, expected: 2
end
group "Division", tags: [:math] do
test "raises on zero" do
assert_raises(ZeroDivisionError) { 1 / 0 }
end
test "floats are close enough", timeout: 1 do
assert_in_delta actual: 0.1 + 0.2, expected: 0.3, delta: 1e-9
end
endRun it:
bundle exec veritytest and group are available at the top level of every discovery file — require "verity" mixes the DSL into Object.
Writing tests
test
test(description, skip: false, focus: false, tags: [], timeout: nil, requires: [], **resources) { ... }| Keyword | Meaning |
|---|---|
skip: |
true → never runs; reported as skipped |
focus: |
true → when any test is focused, only focused tests run (see Selecting tests) |
tags: |
Descriptive labels for filtering and CI ([:slow, :integration]). No reserved names — :skip / :focus in tags: do nothing |
timeout: |
Positive finite Numeric seconds, or nil. Validated at load time; exceeding it is an :error
|
requires: |
Free-form dependency hints (e.g. [:active_record]). Stored as metadata only — nothing acts on it |
**resources |
Every other keyword. Used for resource conflicts (e.g. tables: [:users]) |
Because unknown keywords land in resources, a typo such as skipp: true is silently accepted as a resource rather than raising.
group
Nest tests under titled sections. group takes the same skip:, focus:, and tags: keywords and requires a block:
group "Authentication", tags: [:integration] do
group "sessions", focus: true do
test "creates a session" do
# ...
end
end
end
group "WIP", skip: true do
test "not scheduled yet" do
end
end-
tags:cascade to every nested test (outer groups first) and combine with the test's own tags — seeVerity.effective_tags(test). -
skip:andfocus:cascade: a test's effective value is its own keyword OR any enclosing group's. - Each test records its
group_path(array of titles), whichDocumentationReporterprints as an outline. - The group stack is reset before each discovery file loads, so an unclosed
groupin one file can't leak into the next.
Assertions
Assertions comparing two values take keyword arguments, not Minitest-style positional ones. Every assertion also accepts message: (a String, or a Proc evaluated only on failure).
| Assertion | Passes when |
|---|---|
assert(check) / refute(check)
|
check is truthy / falsy |
assert_nil(actual) / refute_nil(actual)
|
actual.nil? / not |
assert_equal(actual:, expected:) / refute_equal(...)
|
actual == expected / not (failure shows a diff) |
assert_same(actual:, expected:) / refute_same(...)
|
actual.equal?(expected) / not |
assert_match(pattern:, actual:) / refute_match(...)
|
Regexp =~, or String include? / not |
assert_includes(item:, collection:) / refute_includes(...)
|
collection.include?(item) / not |
assert_in_delta(expected:, actual:, delta:) / refute_in_delta(...)
|
within delta (plus a float-epsilon tolerance) / not |
assert_raises(*classes, match: nil) { } |
block raises one of classes (at least one required); match: checks the message (String substring or Regexp). Returns the exception |
refute_raises(*classes, match: nil) { } |
block doesn't raise the listed classes (no classes → must not raise at all) |
A failed assertion raises Verity::AssertionError (status :fail); a timeout raises Verity::TestTimeoutError (status :error).
Hooks
Verity.before_worker_start { |worker_id| } # once per worker process (or once in a single-process run)
Verity.before_test { } # before each test; raising makes the test :error
Verity.after_test { } # after each test, always; exceptions are warned and swallowedHooks are global and accumulate; there is no API to remove one.
Resource conflicts
In parallel runs you can stop two tests that touch the same resource from running at the same time. Register a resolver, then tag tests with that resource keyword:
Verity.register_resource :tables,
conflicts_with: ->(mine, theirs) { (mine & theirs).any? }
test "creates a user", tables: [:users] do
# ...
endmine and theirs arrive as Array<String>. When the only pending tests conflict with tests that are already running, a worker sleeps briefly and retries. Conflict avoidance is best-effort: claiming isn't transactional with the conflict check, so two workers can occasionally start conflicting tests at the same moment.
Running
./bin/verity # from a checkout
bundle exec verity # from an app that depends on the gem| Option | Effect |
|---|---|
-w, --workers COUNT
|
Parallel worker processes: a positive integer or cpus
|
-t, --tag NAME
|
Run only tests whose effective tags include NAME. Repeatable (OR). A leading : is optional |
--exclude-tag NAME |
Skip tests carrying NAME. Repeatable. Exclusion wins over inclusion |
-r, --reporter NAME
|
Output reporter (see Reporters) |
--order MODE |
random (default) or fingerprint (sorted, deterministic) |
--seed INTEGER |
Fix the shuffle seed. Forces shuffled order even with --order fingerprint
|
-h, --help
|
Print usage |
There is no --version flag; use Verity::VERSION.
Choosing files and lines
Positional arguments replace the configured test_globs. Each is expanded with File.expand_path and passed to Dir.glob, so files and quoted globs both work:
verity verity/models/user_test.rb
verity 'verity/models/**/*_test.rb' verity/lib/auth_test.rb
verity verity/models/user_test.rb:42 # only the test or group defined on line 42- Pass files or globs, not bare directories —
verity verity/currently crashes. Useverity 'verity/**/*_test.rb'. -
path:LINEmust point at the line of atestorgroupcall. A line inside a test body matches nothing. - A path that matches nothing runs zero tests and exits 0.
Ordering
Tests run in random order by default. When the seed is chosen automatically it's printed to stderr as a bare integer before the run, so you can reproduce an order with --seed N.
Exit status
| Code | Meaning |
|---|---|
0 |
Every executed test passed — including when no tests matched |
1 |
At least one test failed or errored, or the run crashed (e.g. a SyntaxError in a test file, or --seed given a non-integer) |
2 |
Invalid CLI option (unknown flag, bad --order, missing file in path:LINE) or an ArgumentError during load/run (unknown reporter, invalid timeout:, :memory: manifest with more than one worker) |
Selecting tests
The runnable set is computed in this order:
-
Skip — tests whose effective
skipis true are removed (and reported as skipped). - Focus — if any remaining test is focused, only focused tests stay. If every remaining test is focused, this step changes nothing.
-
Tags —
included_tags/--tagkeeps tests with any matching effective tag; thenexcluded_tags/--exclude-tagremoves tests with any matching tag. Verity warns if inclusion matches nothing. -
Location —
path:LINEfilters keep only tests at, or nested in a group at, those lines. - Order — shuffle (random) or sort by fingerprint.
A test with both skip: true and focus: true is skipped. Single-process summaries end with (focus) when focus narrowed the suite, and (tags) when a tag filter is set.
flowchart TD
A["All registered tests"] --> C{"effective skip?"}
C -->|yes| S["reported as skipped"]
C -->|no| E{"any candidate focused?"}
E -->|yes| Fy["keep focused only"]
E -->|no| T
Fy --> T["tag filters:<br/>include (OR), then exclude"]
T --> L["path:LINE filters"]
L --> O["shuffle or sort by fingerprint"]
O --> RUN["runnable"]
Configuration
Call Verity.configure before Verity.run, typically from a helper loaded before discovery. The defaults:
Verity.configure do |c|
c.test_globs = ["verity/**/*_test.rb"]
c.manifest_path = "verity/manifest.db" # relative to cwd; or ":memory:" (single worker only)
c.worker_count = :cpus # or a positive Integer
c.reporter = Verity::Reporters::ColoredDotsReporter.new($stdout)
c.included_tags = []
c.excluded_tags = []
c.test_order = :random # or :fingerprint
c.shuffle_seed = nil # Integer to fix the order
end-
test_globs: patterns passed toDir.glob. The matches are de-duplicated and sorted intotest_files. -
manifest_path: the SQLite database file. Add it to your.gitignore. A":memory:"manifest needsworker_count = 1set explicitly, because the default is parallel. -
worker_count::cpus(or:cpu,"cpus","cpu") meansEtc.nprocessors. The default makes runs parallel on any multi-core machine (see Parallel runs). Parallel runs need a file manifest andKernel#fork. -
included_tags/excluded_tags: arrays of tag names. Strings are converted to Symbols.
Lower-level entry points:
-
Verity.run: loadstest_files, syncs the runnable set into the manifest, and runs it. -
Verity.load_discovery!: only clears the registry and loadstest_files. Use it when building your own harness.
Reporters
Pick one with -r NAME or c.reporter = .... The built-in names are case-insensitive:
| Name | Class | Output |
|---|---|---|
colored, colored_dots (default) |
ColoredDotsReporter |
. pass, F fail, E error, S skip, colored when stdout is a TTY |
dots |
DotsReporter |
the same characters, no color |
documentation, doc
|
DocumentationReporter |
an outline of group titles and test descriptions, colored on a TTY |
null, none, silent
|
NullReporter |
nothing |
Color can be controlled from the environment:
-
NO_COLORturns color off when it is set to any value, and takes precedence. -
FORCE_COLORorVERITY_FORCE_COLORset to1,trueoryesturns color on even when stdout isn't a TTY.
Two more classes ship with Verity:
-
CompositeReporter.new(*reporters)fans each callback out to several reporters. -
TestReporterrecords every callback in memory (see below).
Custom reporters
On the command line, pass -r path/to/file.rb:ClassName. Verity loads the file and calls ClassName.new with no arguments. The class must include Verity::Reporter:
class MyReporter
include Verity::Reporter
def on_run_start(total:, worker_id:)
# total: expected number of tests (nil if unknown)
end
def on_test_complete(result:, worker_id:)
# result.test (a Verity::Test), result.status (:pass | :fail | :error | :skip), result.error
end
def on_run_finish(summary:, worker_id:)
# summary keys: :total, :passed, :failed, :errored, :skipped, :focus, :tag_filter
end
# Called instead of on_run_finish after a parallel run (coordinator process only).
# counts: {"passed", "failed", "errored", "skipped", ...} => Integer
# problem_rows: [{ fingerprint:, description:, status:, failure: }, ...]
def on_parallel_complete(counts:, problem_rows:)
end
endresult.test is a Verity::Test (a Data struct):
| Field | Description |
|---|---|
fingerprint |
Identity string, used for --order fingerprint and in failure reports |
description |
The string passed to test
|
skip, focus
|
Effective booleans (own keyword OR any enclosing group's) |
tags |
The test's own tags:
|
timeout |
Numeric seconds or nil
|
requires |
Declared hints |
resources |
Extra keywords, e.g. { tables: [:users] }
|
file, line
|
Location of the test call |
fn |
The test body |
group_path |
Enclosing group titles, outermost first |
inherited_group_tags |
Tags from enclosing groups, outermost first |
group_scopes |
GroupScope (title, file, line) for each enclosing group, used by path:LINE
|
TestReporter
Verity::Reporters::TestReporter records every callback in memory, which is useful for testing integrations:
reporter = Verity::Reporters::TestReporter.new
Verity.configure { |c| c.reporter = reporter }
Verity.run
reporter.test_completes.count { _1[:status] == :pass }| Reader | Entries |
|---|---|
run_starts |
{ total:, worker_id: } |
test_completes |
{ status:, error:, worker_id: } |
run_finishes |
{ summary:, worker_id: } |
parallel_finishes |
{ counts:, problem_rows: } |
Execution model
Parallel runs
When worker_count > 1, the coordinator:
- loads the discovery files;
- writes the runnable set into the SQLite manifest;
- forks the workers.
Each worker claims one pending row at a time, marking it running, then runs the test and records the result. When the queue is empty the worker exits.
After all workers exit, the coordinator does three things:
- It marks any row still
running(left by a crashed worker) as errored. - It replays every recorded result through the reporter.
- It calls
on_parallel_complete.
flowchart TB
subgraph coord["Coordinator (parent process)"]
L["load_discovery!"] --> B["sync_manifest!<br/>filter, order, replace_tests"]
B --> C["fork N workers"]
C --> F["wait for all workers"]
F --> G["reclaim_abandoned_running!"]
G --> H["replay results,<br/>report skipped tests"]
H --> I["on_parallel_complete"]
end
DB[("SQLite manifest")]
subgraph wk["Each forked worker (run_manifest)"]
W["claim_next"] --> X{"row claimed?"}
X -->|yes| R["run test"]
R --> Rec["record result"]
Rec --> W
X -->|blocked by resource| Z["sleep, retry"] --> W
X -->|queue empty| Ex["exit"]
end
B --> DB
C --> wk
W <--> DB
Rec --> DB
G --> DB
Workers report to a NullReporter, and all output comes from the replay. Replayed results differ from single-process results in a few ways:
-
on_run_finishisn't called.on_parallel_completereplaces it, so the built-in dots/documentation summary line (including(focus)and(tags)) doesn't appear. - Replayed
Verity::Testobjects have emptygroup_path,inherited_group_tagsandgroup_scopes. The documentation reporter therefore prints no group headings. - Errors are rebuilt as
RuntimeErrorwith the message"OriginalClass: message".
Run with -w 1 if you need the full single-process reporting.
A test's outcome
stateDiagram-v2
[*] --> Skipped: effective skip
[*] --> Running: enqueued and claimed
Running --> Passed: body completes
Running --> Failed: AssertionError
Running --> Errored: timeout, before_test raised, or other StandardError
Skipped --> [*]
Passed --> [*]
Failed --> [*]
Errored --> [*]
Only StandardError is rescued. An exit or Interrupt inside a test kills the worker. In a parallel run, that test is then reported as errored ("abandoned").
Rails applications
Verity includes the following Rails pieces:
- an install generator;
- an adapter (
require "verity/rails") that adds transactional tests, reconnect-after-fork, fixtures, integration sessions and Rails test helpers; - a Railtie that defines the
rake verityandrake verity:runtasks.
Setup
-
Add
gem "verity"to the Gemfile (in the test group). -
Run
bin/rails generate verity:install. It creates:-
test/verity_helper.rb; - an executable
bin/verity; - a sample test,
test/verity/sample_test.rb.
It also adds
tmp/verity_manifest.dbto.gitignore. -
-
Write tests in
test/verity/**/*_test.rb. Seeexamples/rails/test/verity/users_test.rb. -
Run
bin/verity. It loads the helper, then accepts every CLI option, e.g.bin/verity -w 4 -t slow.
bundle exec verity doesn't load test/verity_helper.rb, so in a Rails app use bin/verity.
Rake task
The Railtie isn't loaded automatically yet. To get rake verity, require it in your Rakefile before tasks load:
require_relative "config/application"
require "verity/railtie"
Rails.application.load_tasksbin/rake verity boots :environment, loads test/verity_helper.rb and runs the suite. Rake treats extra arguments as task names, so don't pass CLI flags through rake. Use the environment variables the generated helper reads:
VERITY_WORKERS=4 bin/rake verity
VERITY_PER_WORKER_DB=1 bin/rake verityHelper
The generated test/verity_helper.rb:
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "verity/rails"
Verity.configure do |c|
c.test_globs = ["test/verity/**/*_test.rb"]
c.manifest_path = "tmp/verity_manifest.db"
c.worker_count = ENV.fetch("VERITY_WORKERS", "cpus")
end
Verity::Rails.configure do |c|
c.transactional_tests = true
c.reconnect_after_fork = true
c.per_worker_database = ENV["VERITY_PER_WORKER_DB"] == "1"
c.fixture_sets = [] # e.g. [:users] or :all
c.delegate_http_methods = false
end
Verity.register_resource :tables,
conflicts_with: ->(mine, theirs) { (mine & theirs).any? }
Verity::Rails.install!
Verity::Rails.configure options
| Option | Default | Effect |
|---|---|---|
transactional_tests |
false |
Wraps each test in a non-joinable transaction on the primary connection and rolls it back afterwards |
reconnect_after_fork |
true |
Re-establishes the ActiveRecord connection in each worker (skipped when per_worker_database is on) |
per_worker_database |
false |
Sets TEST_ENV_NUMBER ("", "1", "2", …) per worker and reconnects. Your database.yml must interpolate it, parallel_tests style |
fixture_sets |
[] |
Fixture sets to load once per worker ([:users] or :all). Each set gets an accessor such as users(:alice); the label defaults to :one
|
fixture_path |
nil |
Directory for fixtures; nil means Rails.root/test/fixtures
|
integration_app |
nil |
Rack app for integration_session; nil means Rails.application
|
reset_integration_session_per_test |
true |
Starts a fresh integration session for each test |
delegate_http_methods |
false |
Defines top-level get, post, patch, put, delete and head that delegate to integration_session
|
Database isolation
With a shared test database, choose one of these:
- Use
transactional_teststogether with resource conflicts (tables: [...], as registered in the generated helper). - Enable
per_worker_databaseand prepare one database per worker in CI.
Fixtures are loaded outside the per-test transaction, so they're committed to the database.
Integration tests
Call integration_session.get "/path", or turn on delegate_http_methods. In integration tests you can use:
-
assert_responsewith:success,:redirect,:missing,:erroror an Integer; -
assert_redirected_towith a String or a Regexp.
Rails test helpers
When the relevant gems are loaded, install! mixes these modules into Object, so they're available in every test block:
ActiveSupport::Testing::TimeHelpersActiveJob::TestHelperActionMailer::TestHelper
install! also sets ActiveJob::Base.queue_adapter = :test. After each test, after_test runs travel_back, clears enqueued jobs, and clears ActionMailer::Base.deliveries.
Limitations
-
Rails assertion helpers. Rails' helpers call
assert_equal a, bpositionally, and Verity'sassert_equaltakes keywords. As a result, these currently raiseArgumentError:assert_differenceassert_enqueued_jobsassert_performed_jobsassert_enqueued_withassert_emails
Non-asserting helpers work:
travel_to,freeze_time,perform_enqueued_jobs. -
assert_raisesis shadowed. IncludingActiveSupport::Testing::Assertionsreplaces Verity'sassert_raiseswith Rails' version, which has nomatch:keyword. -
Methods on
Object. Every helper above is defined on every object. A receiver that lacks a method of the same name, such as a stray implicitget, silently resolves to the helper instead of raisingNoMethodError. -
No system, controller or Capybara tests. Keep those on
ActionDispatch::SystemTestCaseorActionController::TestCase.
Known limitations
- Tests defined in a loop, such as
%w[a b].each { |n| test "t #{n}" do ... end }, share a fingerprint. Syncing them into the manifest fails withSQLite3::ConstraintException. - Fingerprints depend on the working directory, and tests inside any block, including
group, get a fingerprint based on file and line. Running from a different directory or moving a test changes its identity. The manifest is rebuilt on every run, so today this only affects--order fingerprintand failure labels.
Contributing
See CONTRIBUTING.md. This repository tests Verity three ways, comparing the same scenarios across loaders and assertion styles:
| Suite | Location |
|---|---|
| Verity, using its own DSL | verity/ |
| Minitest | test/ |
| RSpec | spec/verity/ |
Run all three with bin/test_all.
License
MIT — see LICENSE.