A small lightweight runner for HTTP regression tests
2
stars
24
commits
Rust
primary language
Sep 9, 2026
updated
A regression test runner for HTTP APIs. Suites are declared in TOML, assertions are written in Rhai scripts, and results come out in whatever format the consumer speaks — a terminal, a CI report, or an IDE test tree.
It is a single Rust binary. There is no runtime to install next to it, no project scaffolding, and no configuration beyond the suite itself.
Documentation: https://shortyping.github.io/snag/
$ snag demo/suite.toml -t fast -v
running 1 test across 1 suite
PASS assertion helpers, no network [8ms]
| running against local
test result: ok. 1 passed; 0 failed; 0 timed out; 0 skipped; finished in 8ms
cargo install --git https://github.com/ShortyPing/snag.git
Or grab a binary from the latest release:
curl -fsSLO https://github.com/ShortyPing/snag/releases/latest/download/snag-x86_64-unknown-linux-gnu
chmod +x snag-x86_64-unknown-linux-gnu
sudo mv snag-x86_64-unknown-linux-gnu /usr/local/bin/snag
Swap the asset name for snag-aarch64-apple-darwin on Apple Silicon, or
snag-x86_64-pc-windows-msvc.exe on Windows. Once installed, snag update
replaces it in place. Building from a clone works too — cargo build --release,
binary at target/release/snag. See the
installation guide
for shell completions and CI setups.
snag # run every suite found under the current directory
snag demo/suite.toml # run one suite (the `run` verb is implicit)
snag list --long # discover tests without executing them
snag check # parse manifests and compile scripts only
snag init # scaffold a suite.toml plus an example script
snag update # replace the binary with the latest release
A suite is any file named suite.toml, snag.toml, or *.snag.toml. Naming a
file explicitly on the command line bypasses that convention.
title = "Snag Demo Regression Suite"
timeout = "10s" # default for every test in the file
[variables] # visible to every test in the file
base_url = "https://example.com"
[[test]]
id = "example-responds" # unique within the file
name = "example.com answers 200"
tags = ["network", "smoke"]
parallel_safe = true # false pins the test to a serial phase
timeout = "20s" # overrides the file-level default
file = "./example_test.snag"
[test.variables] # override suite variables for this test only
base_url = "https://httpbin.org"
Precedence, loosest to tightest: suite variable → test variable, and file
timeout → test timeout → --timeout.
Variables land in scope as constants, so base_url is just an identifier.
let res = post(`${base_url}/post`)
.header("accept", "application/json")
.json(#{ project: "snag", ok: true })
.send();
assert_ok(res);
assert_eq(field(res.json(), "json.project"), "snag");
Requests — get, post, put, patch, delete, head build a request;
.header(k, v), .bearer(token), .body(text) and .json(value) chain onto
it; .send() performs it.
Responses — res.status, res.ok, res.text, res.duration_ms,
res.header("name"), res.json(), and field(value, "a.b.0") to walk a decoded
body by dotted path (a missing key fails the test rather than returning ()).
Assertions — assert_status, assert_ok, assert_body_contains,
assert_faster_than, assert_eq, assert_contains, assert(cond, msg), fail(msg).
Auth — basic(user, password) returns a ready Basic <base64> header value;
.bearer(token) covers the other common case.
Misc — env("NAME") (fails if unset), env_or("NAME", "default"),
sleep_ms(n), print(...) and print_response(res). Printed output is captured
per test, shown under failures, and under passes with -v.
Full list in the script API reference.
Shared steps live in their own scripts and are wired up per suite or per test. Suite hooks wrap test hooks: setup runs outside-in, teardown unwinds inside-out.
setup = "./login.snag" # every test in the file
teardown = { file = "./reset.snag", always = true } # `always = false` skips it after a failure
[[test]]
id = "cart-create"
file = "./cart_create.snag"
setup = ["./seed.snag"] # after the suite's setup
A script can also carry its own: fn setup() runs before the body and leaves its
variables in scope for it, fn teardown() runs after it whatever happened, and
on_teardown(|| ...) registers cleanup at the point the resource is created —
callbacks unwind last-registered-first. Hooks and the test share one scope, and
teardown gets a fresh timeout budget so cleanup survives a timed-out test.
--filter/-k and --exclude/-e match a test's name or id as substrings, or as
regular expressions with --regex. --tag/-t is OR-ed across tags. All three
work identically for run, list, and check.
--jobs/-j N sets the worker count (0 = logical CPUs). Tests marked
parallel_safe = false run first, one at a time; the rest share the pool.
--fail-fast reports the remainder as skipped instead of dropping them,
--retries N re-runs failures and reports the attempt count, --seed N shuffles
deterministically, and --dry-run lists what would run.
A timeout aborts the interpreter and caps the HTTP client, so both a runaway loop and a hung socket end the test.
--format picks the reporter: human, json, jsonl, teamcity (IntelliJ
renders a native test tree), or junit. --report FILE keeps human output on
the terminal and writes the machine report to a file — with --format human
that file gets JSON, since a human report is nothing a tool can read.
Exit codes: 0 all green, 1 tests failed, 2 could not run.
Version 0.1.1. What is documented is what the binary does today. Deliberately
out of scope so far: response schema validation, recorded fixtures and replay,
cookie jars and redirect policy control, and any form of distributed execution.
Licensed under GPL-3.0.
24 commits
Rust
100.0%
A small lightweight runner for HTTP regression tests
2
stars
24
commits
Rust
primary language
Sep 9, 2026
updated
A regression test runner for HTTP APIs. Suites are declared in TOML, assertions are written in Rhai scripts, and results come out in whatever format the consumer speaks — a terminal, a CI report, or an IDE test tree.
It is a single Rust binary. There is no runtime to install next to it, no project scaffolding, and no configuration beyond the suite itself.
Documentation: https://shortyping.github.io/snag/
$ snag demo/suite.toml -t fast -v
running 1 test across 1 suite
PASS assertion helpers, no network [8ms]
| running against local
test result: ok. 1 passed; 0 failed; 0 timed out; 0 skipped; finished in 8ms
cargo install --git https://github.com/ShortyPing/snag.git
Or grab a binary from the latest release:
curl -fsSLO https://github.com/ShortyPing/snag/releases/latest/download/snag-x86_64-unknown-linux-gnu
chmod +x snag-x86_64-unknown-linux-gnu
sudo mv snag-x86_64-unknown-linux-gnu /usr/local/bin/snag
Swap the asset name for snag-aarch64-apple-darwin on Apple Silicon, or
snag-x86_64-pc-windows-msvc.exe on Windows. Once installed, snag update
replaces it in place. Building from a clone works too — cargo build --release,
binary at target/release/snag. See the
installation guide
for shell completions and CI setups.
snag # run every suite found under the current directory
snag demo/suite.toml # run one suite (the `run` verb is implicit)
snag list --long # discover tests without executing them
snag check # parse manifests and compile scripts only
snag init # scaffold a suite.toml plus an example script
snag update # replace the binary with the latest release
A suite is any file named suite.toml, snag.toml, or *.snag.toml. Naming a
file explicitly on the command line bypasses that convention.
title = "Snag Demo Regression Suite"
timeout = "10s" # default for every test in the file
[variables] # visible to every test in the file
base_url = "https://example.com"
[[test]]
id = "example-responds" # unique within the file
name = "example.com answers 200"
tags = ["network", "smoke"]
parallel_safe = true # false pins the test to a serial phase
timeout = "20s" # overrides the file-level default
file = "./example_test.snag"
[test.variables] # override suite variables for this test only
base_url = "https://httpbin.org"
Precedence, loosest to tightest: suite variable → test variable, and file
timeout → test timeout → --timeout.
Variables land in scope as constants, so base_url is just an identifier.
let res = post(`${base_url}/post`)
.header("accept", "application/json")
.json(#{ project: "snag", ok: true })
.send();
assert_ok(res);
assert_eq(field(res.json(), "json.project"), "snag");
Requests — get, post, put, patch, delete, head build a request;
.header(k, v), .bearer(token), .body(text) and .json(value) chain onto
it; .send() performs it.
Responses — res.status, res.ok, res.text, res.duration_ms,
res.header("name"), res.json(), and field(value, "a.b.0") to walk a decoded
body by dotted path (a missing key fails the test rather than returning ()).
Assertions — assert_status, assert_ok, assert_body_contains,
assert_faster_than, assert_eq, assert_contains, assert(cond, msg), fail(msg).
Auth — basic(user, password) returns a ready Basic <base64> header value;
.bearer(token) covers the other common case.
Misc — env("NAME") (fails if unset), env_or("NAME", "default"),
sleep_ms(n), print(...) and print_response(res). Printed output is captured
per test, shown under failures, and under passes with -v.
Full list in the script API reference.
Shared steps live in their own scripts and are wired up per suite or per test. Suite hooks wrap test hooks: setup runs outside-in, teardown unwinds inside-out.
setup = "./login.snag" # every test in the file
teardown = { file = "./reset.snag", always = true } # `always = false` skips it after a failure
[[test]]
id = "cart-create"
file = "./cart_create.snag"
setup = ["./seed.snag"] # after the suite's setup
A script can also carry its own: fn setup() runs before the body and leaves its
variables in scope for it, fn teardown() runs after it whatever happened, and
on_teardown(|| ...) registers cleanup at the point the resource is created —
callbacks unwind last-registered-first. Hooks and the test share one scope, and
teardown gets a fresh timeout budget so cleanup survives a timed-out test.
--filter/-k and --exclude/-e match a test's name or id as substrings, or as
regular expressions with --regex. --tag/-t is OR-ed across tags. All three
work identically for run, list, and check.
--jobs/-j N sets the worker count (0 = logical CPUs). Tests marked
parallel_safe = false run first, one at a time; the rest share the pool.
--fail-fast reports the remainder as skipped instead of dropping them,
--retries N re-runs failures and reports the attempt count, --seed N shuffles
deterministically, and --dry-run lists what would run.
A timeout aborts the interpreter and caps the HTTP client, so both a runaway loop and a hung socket end the test.
--format picks the reporter: human, json, jsonl, teamcity (IntelliJ
renders a native test tree), or junit. --report FILE keeps human output on
the terminal and writes the machine report to a file — with --format human
that file gets JSON, since a human report is nothing a tool can read.
Exit codes: 0 all green, 1 tests failed, 2 could not run.
Version 0.1.1. What is documented is what the binary does today. Deliberately
out of scope so far: response schema validation, recorded fixtures and replay,
cookie jars and redirect policy control, and any form of distributed execution.
Licensed under GPL-3.0.
24 commits
Rust
100.0%