Skip to main content

Testing Snag

Tests live in #[cfg(test)] mod tests at the bottom of each module, next to the code they cover. There is no tests/ directory — every test today is a unit test, and none of them touch the network.

cargo test # everything
cargo test runner:: # one module
cargo test fail_fast # one test by name substring
cargo test -- --nocapture # see println! output

What is covered​

ModuleTests
discoveryFilter semantics — substring, exclude-beats-include, tag OR-ing, regex anchors, bad regex, suite-name recognition
manifestVariable precedence, defaults, manifest-relative script paths, path normalisation, duplicate ids, timeout precedence
reportXML and TeamCity escaping, duration formatting, summary greenness
runnerShuffle determinism, jobs(), the full execute_batch surface, and the hook lifecycle — setup scope sharing, teardown ordering, always = false, on_teardown, cleanup after a timeout
scripting_registrationAssertion messages, field() traversal and its errors, output capture, env_or fallback, truncation

Fixture conventions​

Files on disk go into a process-scoped temp directory, so parallel test binaries cannot collide:

fn write_temp(name: &str, body: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("snag-manifest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}

runner uses the same shape with a snag-runner- prefix, wrapped in a script helper, plus with_script(id, path) and hook(path, always) for tests that need a real file behind a Test or a Hook. File names must be unique per test, because cargo test runs tests in one process, in parallel, and they share the directory.

In-memory values get a builder:

fn dummy(id: &str) -> Test {
Test {
id: id.into(),
name: id.into(),
tags: vec![],
timeout: None,
parallel_safe: true,
script: PathBuf::from("t.snag"),
vars: BTreeMap::new(),
setup: vec![],
teardown: vec![],
suite_title: "s".into(),
suite_path: PathBuf::from("suite.toml"),
}
}

Struct-update syntax keeps variations readable: Test { script: path, ..dummy("t0") }.

Scripts under test are Rhai snippets, never network calls:

let ok = script("pass.snag", "let x = 1;");
let bad = script("throw.snag", r#"throw "boom";"#);

assert_eq(1, 1) and throw "boom" cover pass and fail without a socket, which is what keeps cargo test runnable offline.

Testing execute_batch​

The batch runner is concurrent, so its tests are the ones most likely to go flaky. The pattern that works:

A recording reporter captures events on the main thread, and can be made to fail on demand:

#[derive(Default)]
struct Recorder {
started: Vec<String>,
finished: Vec<Outcome>,
fail_on: Option<String>, // makes test_finished return Err once
}

A helper wires up summary, recorder, and cancel flag:

fn batch(tests: &[Test], args: &RunArgs, workers: usize, cancel: &AtomicBool)
-> (anyhow::Result<()>, Summary, Recorder)

Determinism comes from workers = 1 wherever ordering is asserted. With one worker, tests execute in list order, so a fail-fast test can assert exactly which tests were skipped.

What not to assert​

Cancellation is set on the main thread after an event is processed, while workers race ahead pulling the next test. Two consequences:

  • After --fail-fast with several workers, the number of tests that ran is not fixed.
  • After a reporter error, even a single worker can be several tests ahead before the main thread observes the error and sets the flag.

So the reporter-error test asserts what is invariant — the error is returned, the flag ends up set, and every test produced exactly one outcome — and not how many were skipped:

let err = result.unwrap_err();
assert!(err.to_string().contains("reporter exploded on t0"));
assert!(cancel.load(Ordering::SeqCst));
assert_eq!(summary.total(), 4);
assert_eq!(recorder.finished.len(), 4);

The fail-fast test can assert exact skip counts, because with one worker the cancel flag is observed before the next test starts.

If you add a concurrency test, run it in a loop before trusting it:

for i in $(seq 1 20); do cargo test runner:: || break; done

Writing a new test​

  1. Put it in the module it exercises.

  2. Name it as a sentence about behaviour — exclude_beats_include, fail_fast_skips_the_remaining_tests, a_missing_script_fails_the_test.

  3. Assert on the message, not just that an error occurred. Messages are the user-facing feature:

    assert!(err.contains("no key `nope`"), "{err}");

    Passing the actual value as the panic message makes a failure diagnosable from CI output alone.

  4. No network. If a change genuinely needs one, gate it behind #[ignore] so cargo test stays offline by default.

Manual verification​

Unit tests do not cover the CLI surface or the reporters end to end. The demo suite is the smoke test:

cargo build
./target/debug/snag demo/suite.toml -t fast -v # offline test only
./target/debug/snag demo/suite.toml # includes network tests
./target/debug/snag demo/suite.toml --dry-run
./target/debug/snag list --long demo/suite.toml
./target/debug/snag check -v demo/suite.toml
./target/debug/snag demo/suite.toml -t fast -f json
./target/debug/snag demo/suite.toml -t fast -f junit
./target/debug/snag demo/suite.toml -t fast -f teamcity

For failure paths, a scratch suite is quicker than fixtures:

mkdir /tmp/snagfail && cd /tmp/snagfail
printf 'title = "Fail demo"\n\n[[test]]\nid = "broken"\nfile = "./broken.snag"\n' > suite.toml
printf 'assert_eq(404, 200);\n' > broken.snag
snag --color never; echo "exit=$?"

Before pushing​

cargo fmt
cargo clippy --all-targets
cargo test

All three are expected to be clean. cargo clippy --all-targets includes test code, which is where lints usually appear first — CI runs it as cargo clippy --all-targets -- -D warnings, so a warning left behind fails the build.