Skip to main content

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:

suite.toml
[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:

cart_create.snag
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:

SurfaceRisk
assert_status / assert_ok / assert_body_contains failureIncludes 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 reportsCarry 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

ValueWhere it belongs
Base URL of the environment under testManifest variable (or env_or with a manifest default)
Expected values, fixture ids, budgetsManifest variable
Tokens, passwords, API keysenv()
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:

tests/staging.snag.toml
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"
tests/prod.snag.toml
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.

Next