Skip to main content

Snag

A regression test runner for HTTP APIs. Suites are declared in TOML, assertions are written in Rhai, and results come out in whatever format the consumer speaks.

Single static binary · Rust 2024 edition · exit code 0 / 1 / 2

$ snag
running 3 tests across 1 suite
PASS POST /carts creates a cart [142ms]
PASS GET /carts/:id returns the cart [96ms]
FAIL DELETE /carts/:id is idempotent [88ms]

failures:

  DELETE /carts/:id is idempotent (suite.toml::cart-delete)
    assertion failed: expected status 204, got 500

test result: FAILED. 2 passed; 1 failed; 0 timed out; 0 skipped; finished in 331ms

What Snag gives you

Declarative suites

A suite is a TOML file: variables, tags, timeouts, and one entry per test. No test-harness boilerplate, no build step — the manifest is the contract between your repo and the runner.

Real assertions, not JSON matchers

Assertions are Rhai scripts, so a test can branch, loop, chain requests, and walk a decoded body by dotted path — while still failing with one readable message.

Reports for whoever is watching

The same run renders as a colored terminal report, JSON, JSONL, TeamCity service messages (IntelliJ draws a native test tree), or JUnit XML for the rest of CI.

Parallel by default, serial when it matters

Tests run across a worker pool sized to your CPUs. Mark a test parallel_safe = false and it runs alone, before the pool starts.

Timeouts that actually fire

A timeout caps both the HTTP client and the script interpreter, so a hung socket and a runaway loop both end the test instead of the run.

Built for CI ergonomics

Filter by name, id, or tag; retry flaky tests and see the attempt count; shuffle with a seed for reproducible ordering; exit codes distinguish “tests failed” from “could not run”.

A suite is two files

The manifest says what to run; the script says what must be true. Nothing else is required.

suite.toml
title = "Checkout API"
timeout = "10s"

[variables]
base_url = "https://api.example.com"

[[test]]
id = "cart-create"
name = "POST /carts creates a cart"
tags = ["smoke"]
file = "./cart_create.snag"
cart_create.snag
let res = post(`${base_url}/carts`)
.header("authorization", basic("demo", env("API_PASSWORD")))
.json(#{ currency: "EUR" })
.send();

assert_status(res, 201);
assert_faster_than(res, 800);
assert_eq(field(res.json(), "currency"), "EUR");