Skip to main content

Suite files and discovery

Discovery is shared by run, list, and check: all three call the same code path, so the set of tests snag list prints is exactly the set snag runs.

What counts as a suite file​

While walking a directory, a file is a suite if its name is:

  • suite.toml
  • snag.toml
  • anything ending in .snag.toml (for example checkout.snag.toml)

Naming a file explicitly on the command line bypasses the convention entirely — snag weird-name.toml runs it.

How paths are interpreted​

You passSnag does
nothingWalks the current directory recursively
a directoryWalks it recursively
a fileLoads it as a suite, whatever it is called
a glob (*, ?, [)Expands it; matched directories are walked, matched files are kept if they look like suite files
snag # everything under .
snag tests/ # everything under tests/
snag tests/auth.snag.toml # one suite
snag 'tests/*.snag.toml' # a glob — quote it so the shell keeps its hands off
snag tests/ integration/ # several roots at once

Results are sorted and de-duplicated, so overlapping arguments cannot run a suite twice.

:::tip Quote your globs Unquoted, your shell expands tests/*.snag.toml before Snag sees it. That usually still works, but the two expanders differ (notably on **), and an unquoted pattern that matches nothing produces a confusing error. Quoting keeps behaviour identical everywhere. :::

Directories that are skipped​

While walking, Snag descends into everything except:

  • directories whose name starts with . (.git, .github, .venv by dot rule)
  • target, node_modules, dist, build, .venv

Symlinks are not followed. If your suites live inside one of those directories, name the path explicitly: snag build/generated-suite.toml.

When nothing is found​

$ snag
Error: no suite files found (looked for suite.toml / snag.toml / *.snag.toml under the current directory). Run `snag init` to create one.

That is exit code 2 — a setup problem, not a test failure. A path that does not exist at all is also an error rather than an empty run.

Anatomy of a manifest​

tests/checkout.snag.toml
title = "Checkout API" # shown by reporters; defaults to "Untitled suite"
timeout = "10s" # default per-test timeout for this file

[variables] # visible to every test in this file
base_url = "https://api.example.com"
currency = "EUR"

[[test]]
id = "cart-create" # unique within the file, stable over time
name = "POST /carts creates a cart"
tags = ["smoke", "checkout"]
parallel_safe = true # false pins the test to the serial phase
timeout = "20s" # overrides the file-level default
file = "./checkout/cart_create.snag"

[test.variables] # override suite variables for this test only
currency = "USD"

Unknown fields are rejected: the manifest is parsed with deny_unknown_fields, so a typo like tag = ["smoke"] fails the load with a parse error instead of being silently ignored. Duplicate ids inside one file are rejected too.

See the manifest reference for every field and its default.

Path resolution​

file is resolved relative to the manifest that declares it, never relative to your shell. snag tests/checkout.snag.toml and cd tests && snag checkout.snag.toml load the same scripts.

Paths are normalised lexically for display — ./scripts/./x.snag prints as scripts/x.snag. .. is left alone, because resolving it textually would give the wrong answer through a symlink.

Ids, names, and qualified ids​

FieldUniquenessPurpose
idunique per file (enforced)Filtering, machine reports, stable identity
namefree-formThe label a human reads; defaults to id
qualified idunique per run<suite path>::<id>, e.g. tests/checkout.snag.toml::cart-create

The suite path inside a qualified id is the path as discovered. Running snag from the repository root gives ./suite.toml::cart-create; running snag suite.toml gives suite.toml::cart-create. Pick one invocation for CI so that dashboards keyed on the id stay stable.

Layout patterns​

One suite, flat​

Good for a handful of tests.

suite.toml
get_status.snag
post_echo.snag

One suite per domain​

The pattern that scales. Each manifest owns a directory of scripts.

tests/
├── auth.snag.toml
├── auth/
│ ├── login.snag
│ └── refresh.snag
├── checkout.snag.toml
└── checkout/
├── create_cart.snag
└── pay.snag
snag tests/ # everything
snag tests/auth.snag.toml # one domain
snag tests/ -t smoke # one tier across domains

Suites next to the service they test​

In a monorepo, keep suites beside their service and let discovery find them:

services/
├── billing/
│ ├── src/
│ └── snag.toml
└── search/
├── src/
└── snag.toml

snag services/ picks up both. Because target/, node_modules/, dist/, and build/ are skipped, build output cannot pollute the run.

Environments​

Variables are the seam. Two common approaches:

One suite, environment from the process — the script reads it:

let host = env_or("API_BASE_URL", base_url);
let res = get(`${host}/health`).send();
assert_ok(res);

One manifest per environment — the suites share scripts:

tests/staging.snag.toml
title = "Checkout (staging)"
[variables]
base_url = "https://staging.api.example.com"

[[test]]
id = "cart-create"
file = "./checkout/cart_create.snag"
tests/prod.snag.toml
title = "Checkout (production)"
[variables]
base_url = "https://api.example.com"

[[test]]
id = "cart-create"
file = "./checkout/cart_create.snag"
snag tests/staging.snag.toml

Ids may repeat across files — uniqueness is enforced per file, and the qualified id keeps them apart in reports.

Next​