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:
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:
vyi test ✓ 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:
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 oftoBe; 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
vyi test # every *.tests.vyi under the current directory
vyi test src/lexer # ...or under a directory you nameDiscovery 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:
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:
❯ 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:
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:
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:
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.