Variables and secrets
Two ways to get a value into a script:
- Manifest variables — checked into the repository, part of the suite's definition. Hosts, paths, expected values.
- Environment variables — supplied by the process. Tokens, passwords, anything that must not be committed.
Manifest variables
Declared at the suite level, the test level, or both:
[variables] # every test in this file
base_url = "https://api.example.com"
currency = "EUR"
[[test]]
id = "cart-create"
file = "./cart_create.snag"
[test.variables] # this test only
currency = "USD"
Each variable is pushed into the script's scope as a constant before the script runs, so it is a plain identifier:
let res = post(`${base_url}/carts`).json(#{ currency: currency }).send();
assert_status(res, 201);
Because they are constants, a script cannot reassign base_url and silently
retarget the rest of the test:
Cannot modify constant base_url (line 1, position 1)
That check happens when the script runs — snag check compiles without a scope,
so it does not catch it.
Precedence
Test variables override suite variables, key by key. There is no deeper merge — a key is either overridden or inherited.
suite [variables] → test [test.variables]
Both sets go into the same flat scope. A test that defines currency gets its
own value; every other suite variable still arrives untouched.
Everything is a string
TOML types are not preserved: values arrive as strings. Convert in the script.
[variables]
max_latency_ms = "800"
expect_beta = "true"
assert_faster_than(res, max_latency_ms.parse_int());
if expect_beta == "true" {
assert_eq(field(res.json(), "channel"), "beta");
}
parse_int() and parse_float() are Rhai string methods; there is no
parse_bool, so compare against a string.
Environment variables
Two functions, differing only in what happens when the variable is missing:
env("API_TOKEN") // fails the test: "environment variable `API_TOKEN` is not set"
env_or("SNAG_STAGE", "local") // returns "local"
Use env for anything required — a missing credential should fail loudly, not
send an unauthenticated request and produce a confusing 401. Use env_or for
switches with a sane default.
let stage = env_or("SNAG_STAGE", "local");
print(`running against ${stage}`);
let res = get(`${base_url}/me`).bearer(env("API_TOKEN")).send();
assert_ok(res);
The environment is read at call time from the runner's own process, so anything
you export before invoking snag is visible — including whatever your CI
provider injects.
Secrets
Never put a credential in a manifest. Manifests are checked in; the whole
point of env() is that the value arrives from outside the repository.
# Don't
[variables]
api_token = "sk_live_9f2b…"
// Do
let res = get(`${base_url}/me`).bearer(env("API_TOKEN")).send();
What can leak into a report
Be aware of where a secret can end up once a test fails:
| Surface | Risk |
|---|---|
assert_status / assert_ok / assert_body_contains failure | Includes up to 500 characters of the response body |
print_response(res) | Prints all response headers and the first 40 body lines into the captured output |
print(...) | Prints exactly what you pass it |
| JSON / JUnit / TeamCity reports | Carry the same output and message fields |
Request headers are never printed by Snag, so a bearer token does not appear in
a report on its own. A response body containing a session token, though, will
show up in a failure message. Treat published CI artifacts accordingly, and keep
print_response for local debugging rather than committed scripts.
Local development
Since env reads the process environment, any of these work:
API_TOKEN=… snag # one run
set -a; source .env; set +a; snag # a dotenv file, never committed
op run -- snag # a secrets manager wrapper
Add .env to .gitignore. Snag itself does not read dotenv files — it has no
opinion about where the environment comes from.
Choosing between the two
| Value | Where it belongs |
|---|---|
| Base URL of the environment under test | Manifest variable (or env_or with a manifest default) |
| Expected values, fixture ids, budgets | Manifest variable |
| Tokens, passwords, API keys | env() |
| CI-only switches (stage name, feature flag) | env_or() with a default |
A pattern that covers both: a manifest default that the environment may override.
[variables]
base_url = "https://staging.api.example.com"
let host = env_or("API_BASE_URL", base_url);
let res = get(`${host}/health`).send();
assert_ok(res);
Local runs use the staging default; CI exports API_BASE_URL and points the
same suite anywhere.
Multi-environment suites
When environments differ by more than a host — different ids, different expected values — separate manifests are clearer than branching inside scripts:
title = "Checkout (staging)"
[variables]
base_url = "https://staging.api.example.com"
tenant_id = "tenant-test-001"
[[test]]
id = "cart-create"
file = "./checkout/cart_create.snag"
title = "Checkout (production)"
[variables]
base_url = "https://api.example.com"
tenant_id = "tenant-live-118"
[[test]]
id = "cart-create"
file = "./checkout/cart_create.snag"
The scripts stay identical; only the manifest changes. Select with
snag tests/staging.snag.toml.