std.test

import std.test

test.case("splits on commas", fn() {
    test.eq(split("a,b", ","), ["a", "b"])
})

test.case("rejects an empty name", fn() {
    test.err(fn() validate(""))
})

Run with ecko test.

Assertions

callpasses when
test.eq(actual, expected)the two are equal
test.ok(value)the value is truthy
test.err(fn)calling fn raises
test.fail(message)never - an explicit failure
assert(cond, message?) (a builtin)the condition holds
test.group(name, fn)none of the cases inside it failed (only the cases are counted)

test.err takes a function, not an expression, because the expression would raise before the assertion could catch it.

A failed test.eq quotes strings, so a stray space or a trailing newline is visible: expected "a b", got "a b". Escapes such as \n keep the message on one line. The caught error has kind assert and carries expected and actual. When both values are lists, or both are strings and one has a newline in it, it also carries diff, a list of lines: " " for a line in both, "- " for a line only in the expected value, "+ " for one only in the actual one. ecko test prints it under the failure:

  FAIL renders the header - expected "a\nB\nc", got "a\nb\nc"
      at tests/render_test.ecko:8
        a
      - B
      + b
        c

Past 500 lines on either side there is no diff (the message still says what differs), and a diff longer than 40 lines shows the first 40 and how many more there are.

Under ecko test, what a case prints is shown under its failure and dropped when it passes.

A failed assert(cond) names the condition and the values it read: assertion failed: total == 10 (total = 9). The caught error has kind assert, plus condition and values (a map from each name to its value). With a message, assert(cond, message) raises that message instead and keeps the same fields; the message is only evaluated when the assertion fails. This holds when you call assert by its own name and your program does not use assert as a name of its own anywhere. Through another variable, or in a program that does, it is the plain builtin, which says assertion failed.

Skipping a case

test.case("restores file owners", fn() {
    if os.env("USER") != "root" { test.skip("needs root") }
    restore_and_check_owners()
})

test.skip(reason) ends the case as skipped, printed as SKIP <name> - <reason> and counted apart in the summary. A skip is not a failure, so the run still exits 0. Use it for a case that cannot run where it is running - it needs root, a network, a tool that is not installed - instead of returning early, which reports the case ok when nothing was checked.

The reason is required. test.skip works by throwing an error with kind: "skip", so nothing after it in the case runs. If something catches that error and the case then fails, the failure is what gets reported. Called outside a test.case, it is an error.

Discovery

tests/*.ecko and *_test.ecko anywhere in the tree. Both conventions work; see ecko test for why packages sometimes use one over the other.

ai in tests

ecko test forces mock mode and ignores your API key. So a test over an AI pipeline is deterministic, offline and free:

type Sentiment = Positive | Negative | Neutral
fn classify(text) = ai[Sentiment] "How does this review feel? {text}"

test.case("classifies sentiment", fn() {
    test.eq(classify("great service"), Positive)
})

ai[Sentiment] returns the first variant offline, so this asserts the plumbing - that the call is wired up, the type coerces, and the branch is reachable. It does not assert that the model is any good, which no offline test can.

For output quality, use contracts: boolean contracts are real checks and they run in tests identically to production.

Determinism

Because assertions compare output, the usual rules apply: sort before comparing where order is not guaranteed, and avoid wall-clock values in expectations. Maps already iterate and encode in sorted key order, so map comparisons are stable.

Testing code with side effects

Keep the clock, randomness, files and the network at the edge, where a test can hand the code what it needs.

import std.test
import std.random
import std.fs

fn greeting(hour) {
    if hour < 12 { return "good morning" }
    return "good afternoon"
}

fn roll_three() = [random.int(1, 6), random.int(1, 6), random.int(1, 6)]

fn save_report(path, rows) {
    fs.write(path, join(rows, "\n"))
}

fn handle(req) {
    if req.method != "GET" { return { status: 405, body: "" } }
    return { status: 200, body: "hello {req.query.name}" }
}

test.case("greets by the hour it is given", fn() {
    test.eq(greeting(9), "good morning")
    test.eq(greeting(15), "good afternoon")
})

test.case("rolls the same dice from the same seed", fn() {
    random.seed(7)
    let first = roll_three()
    random.seed(7)
    test.eq(roll_three(), first)
})

test.case("writes the report", fn() {
    let path = fs.temp_file(".txt")
    save_report(path, ["a", "b"])
    test.eq(fs.read(path), "a\nb")
    fs.remove(path)
})

test.case("answers a GET", fn() {
    let res = handle({ method: "GET", path: "/", query: { name: "ada" } })
    test.eq(res.status, 200)
    test.eq(res.body, "hello ada")
})
  • The clock: pass the time in as a parameter instead of calling time.now() inside the function. The test picks the hour; production passes time.now().
  • Randomness: call random.seed(n) at the start of the case, and the sequence after it is the same on every run. The seed is process-wide, and each test file runs in its own process, so one file's seed never reaches another.
  • Files: write to fs.temp_file() and remove it at the end of the case. Nothing cleans up temporary files for you.
  • HTTP: a handler is a function from a request map to a response map, so call it directly instead of starting a server and making a request.