Testing
Unit tests, property tests, contract-generated tests, snapshots, and deterministic handlers in Sakura Lang.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
Tests are part of the language. test and property are top-level items, so they live next to the code they test, use the same handlers as production code, and run with sakura test. Assertions come from std/test, whose names are in the prelude (design §10.6).
Unit tests
A test has a name and a block. It behaves like a function fn() -> Unit ! {Test, Fail<E>} for any error union E. The Test effect provides the assertions; an assertion that does not hold records a failure and lets the test continue, and the runner fails the test at the end.
fn greet(name: String) -> String { "Hello, {name}" }
test "greets by name" {
expect_eq(greet("Ada"), "Hello, Ada")
}| Assertion | Passes when |
|---|---|
expect(cond) | cond is true. |
expect_eq(actual, expected) | The values are equal. Both must be Eq and Show. |
expect_fail(fn() { … }, err) | The body fails with exactly err. |
expect_fails_with(fn() { … }, pred) | The body fails with an error for which pred returns true. |
expect_defect(fn() { … }) | The body defects. It runs in a child task. |
expect_output(lines) | The test's captured Console and Log output so far is exactly lines. |
expect_snapshot(name, value) | value renders the same as the stored snapshot. |
Testing typed errors and defects
Expected failures are values in the error union, so tests can compare them directly. try without catch turns a failing call into a Result:
error ParseError(input: String)
fn parse_age(s: String) -> Int ! {Fail<ParseError>} {
match String.parse_int(s) {
Some(n) if n >= 0 => n,
_ => fail ParseError { input: s },
}
}
test "bad age is a typed failure" {
let r = try { parse_age("x") }
expect_eq(r, Err(ParseError { input: "x" }))
}
test "same check with expect_fail" {
expect_fail(fn() { parse_age("x") }, ParseError { input: "x" })
}
test "a typed failure that escapes fails the test" {
let age = parse_age("41") // a ParseError here fails the test with its Show
expect_eq(age, 41)
}Because a test's row includes Fail<E>, a test can call failing functions directly, and postfix ? works on a Result as it does anywhere else. A failure that escapes the body fails the test and reports failed with <Show of the error>, for example failed with ParseError { input: "x" }. expect_fail remains the way to assert that a failure happens.
Typed failures in tests
A typed failure may escape a test body. The runner reports it as a failed test; V3020 is for other unhandled effects. Use expect_fail when the failure is the expected result.
Contract violations are defects, not failures, so try cannot catch them. Use expect_defect:
test "precondition is a caller defect" {
expect_defect(fn() { clamp(1, 5, 0) })
}Tests swap handlers
Tests run under deterministic default handlers (design §5.8):
| Effect | Default handler in tests |
|---|---|
Clock | Clock.fixed(Instant.from_unix_millis(0)) |
Random | Random.seeded(seed), seed from --seed |
Env | Env.of(Map.empty()) |
Console | Console.captured() |
Log | Log.captured() |
Console.captured() and Log.captured() send each line to the test's output, which Test.output_lines() returns and expect_output checks. A Log.captured() line looks like Warn message key=value:
fn announce(name: String) -> Unit ! {Console} {
Console.print("hello, {name}")
}
test "prints a greeting" {
announce("Sakura Lang")
expect_output(["hello, Sakura Lang"])
}Any other effect the test body performs must be handled inside the test with with. Otherwise the compiler reports V3020 (for example, "test uses Sql; install a handler with with"). A unit test cannot reach a real database or network by accident; the runner also denies network access.
No mocking framework is needed, because an effect is already an interface. Here a user-defined effect gets a stateful in-memory handler:
effect Counter {
fn next() -> Int
fn reset() -> Unit
}
fn memory_counter(start: Int) -> Handler<Counter> {
handler Counter {
var n: Int = start
fn next() { n = n + 1; n }
fn reset() { n = start }
}
}
fn label_all(items: List<String>) -> List<String> ! {Counter} {
List.map(items, fn(item) { "{Counter.next()}. {item}" })
}
test "labels count from the handler's start" {
let out = with memory_counter(0) { label_all(["a", "b"]) }
expect_eq(out, ["1. a", "2. b"])
}
test "inner handler shadows outer" {
let out = with memory_counter(0) {
with memory_counter(100) { Counter.next() }
}
expect_eq(out, 101)
}The standard library ships fakes for its own effects:
| Fake | Replaces |
|---|---|
Sql.memory() | A database. |
Http.scripted(script) | The HTTP client, answering from a list of request matchers and responses. |
Fs.memory(files) | The filesystem, from a list of (path, bytes) pairs. |
Llm.scripted(responses) | A model provider. Llm.replay(path) replays a recording. |
Clock.virtual() | The clock. Time advances only when told to. |
Task.deterministic(seed) | The task scheduler. Interleavings are reproducible. |
See Handlers for how with and handler state work.
Deterministic simulation
Every source of nondeterminism in Sakura Lang is an effect: time, randomness, scheduling, network. Installing deterministic handlers for all of them makes a concurrent test reproducible from its seeds. There is no separate simulator; with is the mechanism (design §8.7).
test "deterministic under virtual time" {
with Http.scripted([(Any, Response.status(200, "x"))]), Clock.virtual(), Random.seeded(1), Task.deterministic(1) {
expect_eq(fetch_both(Url.from("https://a"), Url.from("https://b")), ("x", "x"))
}
}fetch_both spawns two tasks, retries with backoff, and wraps everything in a timeout (design §15.10). Under these handlers, sleeps and timeouts use virtual time and task interleavings follow the seed. See Concurrency.
Property tests
A property takes typed parameters. The runner generates values for them and runs the body many times. Parameter types must be written out.
fn normalize(xs: List<Int>) -> List<Int> { List.unique(List.sort(xs)) }
property "normalize is idempotent"(xs: List<Int>) {
expect_eq(normalize(normalize(xs)), normalize(xs))
}
property "normalize preserves membership"(xs: List<Int>, x: Int)
requires List.contains(xs, x)
{ expect(List.contains(normalize(xs), x)) }A requires clause on a property restricts the generated inputs. Like every contract, it must be in the contract subset. property parameters always use generators derived from their types. When that is not enough, write a test that drives explicit generators from std/gen:
import std/gen
test "normalize output is strictly ascending" {
gen.for_all(gen.list(gen.int(-5, 5), 20), 200, fn(xs) {
let ys = normalize(xs)
expect(List.zip(ys, List.slice(ys, 1, List.len(ys))).all(fn(p) { p.0 < p.1 }))
})
}Runs are seeded. sakura test --json reports one record per test with its seed, and sakura test --seed N --filter "normalize" reruns a case.
Property tests from contracts (planned)
The planned sakura test --contracts will write property tests you did not write (design §7.4). For every pub function with at least one refined parameter or requires clause, it:
- Generates inputs jointly from the parameter refinements and
requires. It uses constructive generators for ranges, lengths, enums, and non-empty lists, and falls back to filtering. - Runs the function under the test default handlers.
- Checks every
ensuresclause and the result's refinements.
Failures are shrunk and stored under tests/regressions/<fn>.json. If filtering discards more than 90% of candidates, the runner warns with W7001. Functions that use effects other than Fail, Clock, and Random are skipped unless handlers are supplied with a @contract_test(handlers: [Sql.memory(), Log.captured()]) attribute.
Given clamp from Contracts, The planned sakura test --contracts will generate (x, lo, hi) with lo <= hi and checks lo <= result && result <= hi on each run.
Snapshot tests
expect_snapshot compares a value's Show output with a stored file at tests/__snapshots__/<module>/<name>.snap. Here the stored snapshot is [1, 2, 3]:
test "rendering is stable" {
expect_snapshot("normalize_doc", normalize([3, 1, 3, 2]))
}The Show format is the same on every backend, so snapshots do not depend on the target. Secret<T> is not Show, so a secret cannot end up in a snapshot. After a reviewed change, sakura test --update-snapshots rewrites the stored files.
Running tests
sakura test # all tests and properties
sakura test --filter "normalize" # tests whose name matches
sakura test --seed 42 # fix the seed
# Planned M3 completion: sakura test --contracts
sakura test --json # JSON Lines records for agents
sakura test --update-snapshots # rewrite snapshot filesThe runner applies per-test time and memory limits and denies network access. sakura test exits with 3 when tests fail and 1 when the program has compile errors. A passing test gives its claims the tested status in the guarantee model, never proved.
What the compiler and runner check
- Tests and properties are type and effect checked like any other code.
- Every effect a test uses, other than
Fail, is handled by a default or by awithin the test (V3020). - Generated arguments satisfy parameter types, refinements, and
requires. - Test and property bodies are part of the frozen spec during agent repair (
V8020), so an agent cannot edit a test to make it pass.
Next
Capabilities and security: how handlers become authority.
Contracts and refinements
Refinement types, requires and ensures clauses, and the honest guarantee model behind them.
Capabilities and security
How Sakura Lang separates effects from authority, limits root handlers with sakura.toml, narrows authority for dependencies, and blocks injection and secret leaks.