Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.test

import std.test provides assertions, fixtures, and test doubles for the tests that plenty test runs, with one error type, TestError. The module is checked Plenty code. Any program may import it; it depends on the runner only for reading PLENTY_TEST_SEED and for failure injection.

A test file with main passes when it exits with status zero, so a plain program can run assertions:

import std.test
from std.test import TestError

def test_split() -> Result[(), AllocError | TestError]:
    parts = "a,b,,c".split(",")?
    std.test.equal(&len(parts), &4)?
    std.test.equal(&parts[2], &"")?
    std.test.check(parts[0] < parts[1], "parts keep their order")?
    Ok(())

def main() -> Result[(), AllocError | IoError | TestError]:
    test_split()?
    print("test_split passed")?
    Ok(())
test_split passed

Assertions

An assertion that holds returns Ok(()) without allocating. One that fails flushes standard output, writes the position of the call, assertion failed: , and an explanation to standard error, and returns Err(TestError.Assertion). The explanation is written at once, so it survives propagation or handling by the caller. Tests can return TestError or a sum containing it.

parse.plenty:12:5: assertion failed: left == right
  left: 4
 right: 5

The file is relative to its source root, tests/ for a test file, as in trap reports. The position is the user code that called the assertion, even through standard-library helpers: the caller location that trap notes use. A user helper that wraps an assertion reports the helper’s line, not its caller’s.

FunctionPasses whenFailure explanation
fail(message: &str)nevermessage
check(condition: bool, message: &str)conditionmessage
equal[T](left: &T, right: &T)left == rightleft == right, then left: and right: lines
not_equal[T](left: &T, right: &T)left != rightleft != right, then a both: line
less[T](left: &T, right: &T)left < rightleft < right, then left and right lines
close(left: f64, right: f64, tolerance: f64)left == right or abs(left - right) <= tolerancethe three values; NaN on either side fails, equal infinities pass
is_ok[T, E](result: &Result[T, E])result is Okresult is Ok, then an error: line
is_err[T, E](result: &Result[T, E])result is Errresult is Err, then a value: line

Values are shown with str.repr, so T must support equality or ordering as the function needs. A value whose type str.repr rejects, such as a Shared[T] owner or a std.sync.Mutex, is shown as <value cannot be formatted>; is_ok and is_err therefore accept every Result, whatever the branch they do not expect holds. Showing a value allocates; when that fails the line reads <no memory to show the value>. A failure to write to standard error is ignored.

Under plenty test, a test that returns Err(TestError.Assertion), or the Failure.Unspecified that ? makes of it, gets no further error: line: the explanation above is the whole report.

Fixtures

FunctionResultContract
scratch()Result[Scratch, TestError]A new empty directory named plenty-test- plus 32 random hex digits, created with mode 0700 under $TMPDIR when it is an absolute path, otherwise under /tmp
seed()Result[u64, TestError]PLENTY_TEST_SEED as a decimal u64, or 0 when it is unset; any other value writes a message to standard error and returns TestError.InvalidSeed
rng()Result[Rng, TestError]std.random.seeded(seed()?)

plenty test --seed N sets PLENTY_TEST_SEED for every test it runs, so a failure seen under one seed repeats under it.

Scratch owns its directory:

MethodResultContract
path()&PathThe directory’s absolute path
root()&RootA std.fs.Root anchored at the directory
file(name: &str)Result[str, TestError]The absolute path text of name inside the directory, for builtins such as write_text and open
cleanup()Result[(), TestError]Remove the directory and everything in it; later calls do nothing. Takes &mut Scratch

Dropping a Scratch runs cleanup and ignores its error. Removal uses std.fs.remove_tree with budgets of 100000 entries and depth 64; beyond them cleanup reports TestError.Fixture(FsError.Limit) and leaves the rest in place. A process that traps or is killed skips destructors, so its scratch directory remains, except under plenty test, which gives each test its own TMPDIR and removes it when the test ends.

Test doubles

Code that reads the clock or moves bytes can take its source as a protocol bound, so a test passes a double and a program passes the real thing. Calls are resolved at compile time; a double costs nothing in the program that does not use it.

fake_clock(nanoseconds_after_origin: u64) returns a FakeClock, a std.time.Clock whose time moves only when the test moves it. Its instants are on the instant_at timeline.

MethodResultContract
now()InstantThe current fake instant
advance(span: &Duration)Result[(), RangeError]Move forward by span; past the end of the timeline is RangeError.OutOfRange and nothing moves. Takes &mut FakeClock
set(nanoseconds_after_origin: u64)()Move to any instant, including backwards. Takes &mut FakeClock

fake_stream(input: Bytes) returns Result[FakeStream, AllocError], a std.io Reader and Writer over memory. Reads return input from its start; writes append to output. Both fields are public. It injects the partial progress and failures that a file or socket produces only under load:

MethodResultContract
limit(bytes: i64)()Each later read or write moves at most bytes bytes; 0 or less removes the limit
fail_after(after: i64, error: StreamError)Result[(), OverflowError[i64]]The call after calls from now (0 is the next) returns Err(error) and moves nothing. One failure is pending at a time; a later request replaces it
calls()i64read, write, and flush calls made so far, including failed ones
read, write, flushas std.ioThe stream protocol methods

A range outside its buffer traps with read: start/stop outside buffer before the call is counted. flush does nothing but count and fail when told. Writing allocates as output grows; that failure is StreamError.Allocation.

fail_after checks the absolute call index before replacing the pending fault. On overflow it returns the wrapped OverflowError[i64] and changes neither schedule nor counter; callers must handle its result. read, write and flush return StreamError.OutOfRange without changing data, counter or pending fault if the call counter cannot advance. No caller arithmetic mode changes these boundaries. Existing signed offsets are retained: a representable past index does not match a future call.

from std.time import Clock, Instant

def overdue[C: Clock](clock: &C, since: &Instant) -> bool:
    clock.now().saturating_duration_since(since).as_seconds() > 30

def test_overdue() -> Result[(), std.test.TestError | RangeError]:
    mut clock = std.test.fake_clock(0u64)
    since = clock.now()
    std.test.check(not overdue(&clock, &since), "fresh")?
    clock.advance(&std.time.seconds(31u32))?
    std.test.check(overdue(&clock, &since), "after 31s")?
    Ok(())

A program calls the same function with std.time.monotonic_clock().

Failure injection

Executables that plenty test builds, or that are compiled with --test-hooks, can make the C library’s allocation and file calls fail, so a test reaches the error paths of code that allocates or does file I/O. Injection belongs to the calling thread.

FunctionEffect
fail_allocations_after(count: u32)After count more allocations succeed, each fails until restored: the operation reports AllocError.OutOfMemory
restore_allocations()Allocations succeed again
fail_files_after(count: u32, code: i32)After count more file calls succeed, each fails with errno code until restored: the operation reports IoError.System(code)
restore_files()File calls succeed again

All four return Result[(), TestError]. A later call replaces the budget. Counted file calls are opening any path, and reading, writing, syncing, or truncating a regular file; standard streams, pipes, and sockets never fail, so a failing test can still report. Other filesystem calls, such as renaming or listing a directory, are not counted. How many calls an operation makes is not part of its contract, so a test should fail from the first call (count 0) or check only that some call failed. A failure that ? propagates leaves the budget in place; restore it before an assertion that must allocate to show its values.

def test_save_reports_a_full_disk() -> Result[(), std.test.TestError]:
    scratch = std.test.scratch()?
    name = scratch.file("state.txt")?
    std.test.fail_files_after(0u32, 28i32)?
    saved = write_text(&name, "state")
    std.test.restore_files()?
    full: Result[i64, IoError] = Err(IoError.System(28i32))
    std.test.equal(&saved, &full)?
    Ok(())

Use plenty --compile server.plenty --test-hooks -o server to exercise a real program’s refusal paths from an external client. The program can read its own test configuration and arm a budget immediately before admission, then restore it before reporting the result. plenty build --test-hooks and plenty run --test-hooks enable the same controls for project executables. This also enables file failure injection. The flag does not arm any failures by itself. Keep it out of production builds.

Without this opt-in or plenty test, these functions write error: failure injection needs plenty test or --test-hooks to standard error and return Err(TestError.Unavailable). Default executables contain no interposition code. Project and compiler caches distinguish instrumented builds from default builds.

Memory-limited integration runs

On Linux, playpen can bound the memory of the real executable and its descendants through the systemd user manager:

playpen --memory-limit 128M --memory-swap-max 0 --private-tmp false -- ./server

Build before launching, so the limit measures the program rather than the compiler. Disabling the private temporary directory lets a test driver share temporary fixtures with the program. Use a suitable limit for the workload and check both the exit status and the application’s expected response.

The cgroup limit is an integration resource ceiling: exhausting it can kill the process instead of returning AllocError. Use --test-hooks and fail_allocations_after for deterministic error-path checks, including under playpen. The budget is a count of allocation attempts, not bytes or a live-heap limit; it cannot measure peak memory or prove a program stays below a byte budget. Use an external memory profiler for those measurements.

Errors

VariantMeaning
AssertionAn assertion failed; its explanation is already on standard error
Fixture(FsError)Creating or removing a scratch directory failed, or a name or path was invalid
Allocation(AllocError)Memory for a fixture ran out
InvalidSeedPLENTY_TEST_SEED is set but is not a decimal u64
UnavailableFailure injection was requested without plenty test or --test-hooks

Limits

  • Injected failures cover allocation and the file calls listed above, on the calling thread only. Allocation on other threads, such as executor workers, and network calls are not injected; use FakeStream at the stream boundary.
  • Calls the C library makes internally, such as fopen inside another C function, are not intercepted; only the program’s and runtime’s own calls are.