tldr
Write tests as .test.mld files and run them with mlld test. Import assertion helpers from @mlld/test and bind each check to a @test_<name> variable. Under test mode an ambient @test object adds value introspection (@test.mx), call tracking (@test.events), and filesystem snapshots (@test.vfs).
import { @assertEq, @assertIncludes } from @mlld/test
const @test_math = @assertEq(2 + 2, 4)
const @test_text = @assertIncludes("hello world", "world")
mlld test "tests/**/*.test.mld"
Test files
One .test.mld file is one logical test. The file passes if it runs to completion and fails if any assertion throws.
Each const @test_<name> = ... is a reported check. The runner strips the test_ prefix and turns underscores into spaces, so @test_array_length reports as array length. Variables without the prefix are ordinary variables, not checks.
import { @assert, @assertEq } from @mlld/test
const @numbers = [1, 2, 3, 4, 5]
const @test_length = @assertEq(@numbers.length, 5)
const @test_first_positive = @assert(@numbers.0 > 0)
Assertions return true on success and throw on failure, so binding one to a @test_ variable both runs it and records the result.
Test mode
@test exists only in test mode; the @mlld/test assertion helpers work in any script. Test mode is on when a file is run by mlld test, or by the SDK with { testMode: true }. (MLLD_TEST_AMBIENT=1 turns test mode on for SDK embeds only — the CLI does not read that environment variable.) Reading @test outside test mode is an error. (MLLD_TEST=1 is a separate, unrelated flag and does not enable test mode.)
Running
mlld test "tests/test-system/*.test.mld" >> a glob of files
mlld test path/to/one.test.mld >> a single file
mlld test "tests/**/*.test.mld" --shard 1/2 >> a deterministic 1-of-2 slice
--shard N/M selects a stable subset of the matched files. --inline, --watch, --junit, and --replay are reserved and rejected with a not-implemented message.
See assertions for the helper vocabulary, the @test ambient for introspection and snapshots, and mocks for stubbing commands and file reads.
Assertions
Value assertions
@assert(condition, message?)— passes whenconditionis truthy.@assertEq(actual, expected, message?)— deep structural equality. Objects compare independent of key order; arrays compare by value.@assertNeq(actual, expected, message?)— passes when the two differ structurally.@assertIncludes(actual, expected, message?)— for an array,actualmust containexpected(deep); for a string,expectedmust be a substring.
import { @assertEq, @assertNeq, @assertIncludes } from @mlld/test
const @result = { count: 3 }
const @status = "ok"
const @message = "all done"
const @tags = ["urgent", "billing"]
const @test_eq = @assertEq(@result.count, 3)
const @test_neq = @assertNeq(@status, "error")
const @test_sub = @assertIncludes(@message, "done")
const @test_arr = @assertIncludes(@tags, "urgent", "expected an urgent tag")
Metadata assertions
These take a snapshot from @test.mx(@value), not the value itself, and check that the snapshot contains every entry you list.
@assertLabels(mx, labels, message?)—mx.labelsincludes every label inlabels.@assertTaint(mx, taint, message?)—mx.taintincludes every entry intaint.
import { @assertLabels, @assertTaint } from @mlld/test
const .secret @key = "sk-123"
const .untrusted @userInput = "user-provided text"
const @test_label = @assertLabels(@test.mx(@key), [".secret"])
const @test_taint = @assertTaint(@test.mx(@userInput), [".untrusted"])
Call assertions
@assertCalled(calls, message?) passes when calls is a non-empty array. Pair it with @test.events.calls(name), which records a before and an after entry per call.
import { @assert, @assertCalled } from @mlld/test
exe @fetchData() = js { return "data" }
const @out = @fetchData()
const @again = @fetchData()
const @calls = @test.events.calls("fetchData")
const @test_called = @assertCalled(@calls)
const @test_called_twice = @assert(@calls.length >= 2)
Expectations
For outcomes captured with try — a denied or errored operation, or a warning buffer.
@expectDenied(value)— passes whenvalueis truthy. Use it with theerrorof atryover a policy-denied operation.@expectError(value)— passes whenvalueis truthy.@expectWarning(warnings, code?)— passes whenwarningsis non-empty; ifcodeis given, an entry must have thatcode.
import { @expectDenied } from @mlld/test
policy @noSecrets = { .secret { deny: [.op:cmd] } }
exe @sendSecret(value) = cmd { echo "$value" }
const .secret @key = "sk-123"
const @attempt = try @sendSecret(@key)
const @test_blocked = @expectDenied(@attempt.error)
@expectWarning reads a warning buffer such as @mx.warnings, which records the runtime warnings a run prints. Passing a .secret value into a js block prints one:
import { @expectWarning } from @mlld/test
const .secret @key = "sk-123"
exe @peek(v) = js { return v.length }
const @n = @peek(@key)
const @test_warned = @expectWarning(@mx.warnings, "NON_IFC_RUNTIME_LABELED_DATA")
Running outside mlld test
The @assert* helpers also work in a plain mlld <file> script: the first failing assertion throws and the script exits 1. Checks that pass @test.mx(…), the usual way to call @assertLabels and @assertTaint, need test mode, because @test exists only there. Soft assertions that collect results without stopping (@check, @report) are not available in mlld 2.2.0.
Markers
@suite(name) and @it(name) label a test; each returns { name, status: "pass" }. One logical test per file, so use a single @it near the top to name it.
import { @it, @assertEq } from @mlld/test
const @test_name = @it("addition works")
const @test_sum = @assertEq(1 + 1, 2)
The @test ambient
@test.mx(value)
Returns a read-only snapshot of a value's security metadata:
{ labels: [...], taint: [...], tokens: [], handle }
labels and taint are string arrays; handle is the value's handle or null. tokens is always empty. Feed the snapshot to @assertLabels / @assertTaint. A trust label such as .untrusted appears in taint but not in labels, so check it with @assertTaint; a label like .secret appears in both.
import { @assertLabels, @assertTaint, @assertEq } from @mlld/test
const .untrusted @userInput = "user-provided text"
const @snap = @test.mx(@userInput)
const @test_taint = @assertTaint(@snap, [".untrusted"])
const @test_no_handle = @assertEq(@snap.handle, null)
show @snap.taint.0
@test.events.calls(name)
Returns the recorded calls for the named exe/operation, in order. Each call records two entries — a before and an after:
[ { name, phase: "before", timestamp }, { name, phase: "after", timestamp }, ... ]
timestamp is a sequence number (0, 1, 2, …) that gives the order of the entries, not a clock time.
import { @assert, @assertCalled } from @mlld/test
exe @double(n) = js { return Number(n) * 2; }
const @r = @double(3)
const @calls = @test.events.calls("double")
const @test_recorded = @assertCalled(@calls)
const @test_once = @assert(@calls.length == 2)
show @r
@test.vfs.snapshot()
Returns { path, content } for each file the test wrote, sorted by path. Under mlld test that is only what the test itself wrote (for example with @test.mock.fs or output); files already on disk are not included. Useful for asserting what a run wrote.
import { @assertEq } from @mlld/test
const @path = `${@root}/snapshot.txt`
const @mock = @test.mock.fs(@path, "snapshot-content")
const @files = @test.vfs.snapshot()
const @test_one_file = @assertEq(@files.length, 1)
const @test_content = @assertEq(@files.0.content, "snapshot-content")
show @files.length
@test.reset()
Clears recorded calls and registered mocks; returns true. It also removes files created by @test.mock.fs when the file still contains the mock value, while preserving later writes made by the test — a file your test overwrote after mocking survives the reset, and the discard only applies on an in-memory/virtual filesystem. Call it between independent phases within a file.
const @reset = @test.reset()
show @reset
Mocks
@test.mock.shell(command, value)
When a command body matches command exactly (compared after trimming), it returns value without executing. This covers run cmd { ... }, run sh { ... }, cmd exe bodies, and sh/bash exe bodies.
import { @assertEq } from @mlld/test
const @m = @test.mock.shell("git status --short", "")
const @status = cmd {git status --short}
const @test_clean = @assertEq(@status, "")
@test.mock.fs(path, value)
Writes value to the test VirtualFS overlay at the resolved path and records the mock entry. Loading the same path returns value without writing to the working tree.
import { @assertIncludes } from @mlld/test
const @cfgPath = `${@root}/mock-config.txt`
const @m = @test.mock.fs(@cfgPath, "env=test")
const @test_cfg = @assertIncludes(<@cfgPath>, "env=test")
A .json path is parsed as JSON on load, so its value must be valid JSON.
A mock does not lift a filesystem policy. Loading a mocked path still passes the read gate, and the gate resolves the path's REAL location — through symlinks, including links that point outside the project. If that location is denied, the load refuses with Filesystem read denied by policy and the mock is not served: a mock must not become a way to read a location the policy forbids. A link that points outside the project refuses even when the policy allows the read — that refusal is the project-root containment's own (Project-root path escaped the project root), not the policy-denied one. Unmocked paths under a deny are refused the same way — the refusal message is the policy gate's own and does not mention mocks (a mock is not part of the security verdict).
The VirtualFS overlay covers mlld filesystem-service writes only. Unmocked shell redirections inside run cmd { ... }, run sh { ... }, or sh exe bodies still write through the host shell.
@test.mock.mcp(target, value)
When an imported MCP tool is called in test mode, a matching mock makes the tool return value (as a string) instead of contacting the MCP server. target matches the tool by the name you call it by (its mlld name) or by its raw MCP name; the call arguments are not matched. Returns the mock entry { kind, target, value }.
import { @assertEq } from @mlld/test
const @m = @test.mock.mcp("github.search", "stubbed result")
const @test_mcp = @assertEq(@m.value, "stubbed result")
With a matching MCP import in scope, calling the tool then returns "stubbed result" without a real MCP round-trip.
Clearing stubs
@test.reset() clears all registered mocks, along with recorded calls and warnings.
const @reset = @test.reset()
Removing a single stub (@unstub) is not available in mlld 2.2.0; use @test.reset().