ShortyPing/snag

A small lightweight runner for HTTP regression tests

2

stars

24

commits

Rust

primary language

Sep 9, 2026

updated

shortyping.github.io/snag/

README

snag

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

Install

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.

Commands

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

Suite files

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.

Scripts

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");

Requestsget, post, put, patch, delete, head build a request; .header(k, v), .bearer(token), .body(text) and .json(value) chain onto it; .send() performs it.

Responsesres.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 ()).

Assertionsassert_status, assert_ok, assert_body_contains, assert_faster_than, assert_eq, assert_contains, assert(cond, msg), fail(msg).

Authbasic(user, password) returns a ready Basic <base64> header value; .bearer(token) covers the other common case.

Miscenv("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.

Setup and teardown

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.

Selecting tests

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

Running

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

Output

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

Status

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.

Contributors

ShortyPing

24 commits

ShortyPing/snag

A small lightweight runner for HTTP regression tests

2

stars

24

commits

Rust

primary language

Sep 9, 2026

updated

shortyping.github.io/snag/

README

snag

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

Install

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.

Commands

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

Suite files

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.

Scripts

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");

Requestsget, post, put, patch, delete, head build a request; .header(k, v), .bearer(token), .body(text) and .json(value) chain onto it; .send() performs it.

Responsesres.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 ()).

Assertionsassert_status, assert_ok, assert_body_contains, assert_faster_than, assert_eq, assert_contains, assert(cond, msg), fail(msg).

Authbasic(user, password) returns a ready Basic <base64> header value; .bearer(token) covers the other common case.

Miscenv("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.

Setup and teardown

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.

Selecting tests

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

Running

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

Output

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

Status

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.

Contributors

ShortyPing

24 commits

Languages

Rust

100.0%