Guides · 16

Testing

Tests are plain Vyi. The @testing package gives you the familiar describe / it / expect vocabulary, test files are ordinary programs named *.tests.vyi, and vyi test finds them, runs them, and renders a report. There is no test-specific language machinery — it is a function, a test body is a closure, and an assertion is a method call.

The @testing package

A minimal test file:

vyi
import { it, expect } from "@testing"

// There is no `fn main` here — `vyi test` synthesizes one that runs
// these top-level calls and exits with the failed-assertion count.
it("adds", () => {
    expect(1 + 2).toBe(3)
})

it("compares", () => {
    expect(2 > 1).toBeTrue()
})

Save it as math.tests.vyi and run the suite:

sh
vyi test
text
 ✓ math.tests.vyi (2 tests)
 ✓ adds
 ✓ compares

 Test Files  1 passed (1)
      Tests  2 passed (2)

it(name, body) runs one test. expect(actual) opens an assertion on a value — expect is generic, inferring its type from the argument — and a matcher method finishes it:

vyi
import { it, expect } from "@testing"

// (also a complete test file — the `fn main` comes from `vyi test`)
it("matchers", () => {
    expect(6 * 7).toBe(42)
    expect(6 * 7).toEqual(42)
    expect(1 < 2).toBeTrue()
    expect(2 < 1).toBeFalse()
})

The matcher surface today:

  • toBe(expected) — equality via ==. Because == resolves through the operator-method system (guide 13), this works for any type with equality — integers, bool, char, your own types with an @eq.
  • toEqual(expected) — currently an alias of toBe; deep structural equality is (planned).
  • toBeTrue() / toBeFalse() — shorthands for boolean expectations.

A test may hold any number of assertions; the it passes only if all of them held. A failed assertion doesn't stop the test body — it's tallied, and the report points at the failing line. Failures are counted, not thrown, so there's no hidden control flow in a test body.

Test files and vyi test

sh
vyi test            # every *.tests.vyi under the current directory
vyi test src/lexer  # ...or under a directory you name

Discovery is recursive: any file ending in .tests.vyi under the root is a test file, wherever it sits — the convention is to keep tests next to the code they cover. Each file is compiled and run as its own standalone program.

What makes a test file special is only the synthesized entry point. Your file holds top-level calls (it(...), describe(...)); vyi test collects them, in order, into a main it writes for you. The math.tests.vyi above is compiled as if you had written:

vyi
import { it, expect, failures } from "@testing"

fn main() !u8 {
    it("adds", () => {
        expect(1 + 2).toBe(3)
    })
    it("compares", () => {
        expect(2 > 1).toBeTrue()
    })
    return Ok(u8(failures()))
}

failures() is the running count of failed assertions, so a test file's exit code is its failure count — 0 means green (the real synthesized main clamps at 255 so a large count can't wrap to a clean-looking zero). A file that already declares main is honored as written, synthesis skipped. The remaining examples in this guide are shown in this expanded form so each is a complete program; in a real .tests.vyi you'd drop main and write the describe(...) call at the top level, as in the files above.

When a test fails, the report names it and points at the failing assertion's source position:

text
 ❯ math.tests.vyi (2 tests | 1 failed)
 ✓ adds
 × multiplies

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  math.tests.vyi > multiplies
 ❯ math.tests.vyi:8:5

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

vyi test itself exits with the number of files that failed, so it slots straight into CI. A file that fails to compile, or traps mid-run, counts as a failed file and says so in the report. Tests default to the wasm target; pass --target x86-64-linux or --target arm64-linux for a native run.

Patterns

Grouping with describe

describe(name, body) groups related tests, and nests — the report indents to match. The group path (describe > … > test) names each failure:

vyi
import { describe, it, expect, failures } from "@testing"

fn main() !u8 {
    describe("Vec", () => {
        it("starts empty", () => {
            expect(0).toBe(0)
        })
        describe("push", () => {
            it("grows len", () => {
                expect(1).toBe(1)
            })
        })
    })
    return Ok(u8(failures()))
}

Fixtures with beforeEach / afterEach

beforeEach(body) and afterEach(body) register a closure to run around every subsequent it in the enclosing describe. Test bodies are closures, so shared state lives in module-level mut bindings the hooks reset:

vyi
import { describe, it, expect, beforeEach, failures } from "@testing"

mut counter: i32 = 0

fn main() !u8 {
    describe("counter", () => {
        beforeEach(() => { counter = 0 })

        it("increments from zero", () => {
            counter = counter + 1
            expect(counter).toBe(1)
        })

        it("gets a fresh start", () => {
            expect(counter).toBe(0)    // beforeEach reset it
        })
    })
    return Ok(u8(failures()))
}

Hooks are scoped to their describe: when the block ends, the hooks that were in force before it are restored, so a sibling describe doesn't inherit them.

Testing error paths

Fallible functions return error unions (guide 07 — Optionals and errors), and both of their outcomes are plain values — so both directions test the same way. Use catch with a sentinel when only success matters, or switch the result when the test should name the exact error:

vyi
import { describe, it, expect, failures } from "@testing"

enum ParseError { Empty, BadDigit }

fn parseDigit(s: [*]u8) ParseError!i32 {
    if s.len == 0 { return Err(Empty) }
    con c = s[0]
    if c < u8('0') || c > u8('9') { return Err(BadDigit) }
    return Ok(i32(c) - 48)
}

fn main() !u8 {
    describe("parseDigit", () => {
        it("parses a digit", () => {
            con n = parseDigit("7") catch { -1 }
            expect(n).toBe(7)
        })

        it("rejects empty input", () => {
            mut sawEmpty = false
            switch parseDigit("") {
                Ok(_):         {},
                Err(Empty):    { sawEmpty = true },
                Err(BadDigit): {},
            }
            expect(sawEmpty).toBeTrue()
        })
    })
    return Ok(u8(failures()))
}

See also: guides/17 — Tooling for the rest of the vyi CLI.