A test is a normal proc with a marker on it. You stamp @(test) above a proc, give it the signature proc(t: ^testing.T), and run odin test . from the package's directory. The runner finds every marked proc, runs each one, and tallies what passed. No framework to pick, no config, no class to put it in.
Inside, you assert with a handful of procs from core:testing. The handle t is how each assertion records which check failed and where — you thread it through to every call:
import "core:testing" @(test) clamp_in_range_returns_value_unchanged :: proc(t: ^testing.T) { testing.expect_value(t, clamp_to_range(5, 0, 10), 5) }
Now run it. The toggle below switches between the four @(test) procs in this lesson's package and the step bounce test from Level 2 — different test counts, same shape of output. The header lines are the runner announcing its setup; the last line is the verdict:
A thread per test (the header says how many). A random seed printed every run, so a flaky test can be replayed with -define:ODIN_TEST_RANDOM_SEED=n. A per-test memory tracker that flags anything you allocate and don't free. You write the proc; the runner brings the scaffolding.
Only the final All tests were successful. line is stable run to run. The timestamps, the seed, the thread count, and the duration all change every invocation — so a test asserts on your code's behavior, never on the runner's bookkeeping.
Level 1 was what it is. Level 2 is why it earns its keep: a test can only assert on logic it can call in isolation. The move that unlocks testing is pulling pure logic out of side-effecting code.
Take a bouncing ball. The per-frame update is tangled into the loop that also opens a window, reads input, and draws pixels. You cannot assert "the ball bounced off the right wall" against that — there's no value to inspect, only a window that flickers. So you cut the math out into a pure proc: a ball and the box size in, a new ball out, nothing else touched.
for running { poll_events() // physics buried among I/O... ball.x += ball.vx if ball.x > w { ball.vx = -ball.vx } draw(ball) // only output is pixels }
step :: proc(b: Ball, w, h: f32) -> Ball { nb := b nb.x += nb.vx if nb.x > w { nb.vx = -nb.vx } return nb // a new ball, fully inspectable }
The payoff, made concrete: put a ball at the right wall moving right, call step once, and assert its horizontal velocity is now negative — it turned around. Add one that checks a ball mid-box keeps its velocity. Two procs, run them:
@(test) ball_bounces_off_right_wall :: proc(t: ^testing.T) { b := Ball{x = 99, y = 50, vx = 5, vy = 0} after := step(b, 100, 100) testing.expect(t, after.vx < 0, "ball at the right wall should reverse vx") }
The counterfactual: if step stayed welded to the window, the only way to "test" it would be to launch the game, watch the ball, and trust your eyes — slow, manual, and silent the day someone breaks the math. The proc is worth extracting because a pure function of its inputs is a thing you can pin an assertion to. No pure proc, no test.
Level 2 gave you something to assert. Level 3 is what makes the assertion load-bearing: when a check is false, the runner prints exactly what broke and the run exits nonzero. A green test you can't make go red proves nothing. Flip the assertion and watch the verdict turn over.
Two assertion procs, two failure styles. expect_value(t, got, want) is the typed-equality form — on failure it names both sides for you. expect(t, cond, "msg") is the boolean form — on failure it prints your message. Toggle a passing vs failing version of each and read the runner's real verdict:
Why the order matters: the convention is expect_value(t, got, want) — the value you computed first, the value you expected second. The failure message reads expected clamp_to_range(99, 0, 10) to be 10, got 99: the want after "to be", the got after "got". Swap the two arguments and the equality still holds, so the test still passes — but the day it breaks, the message labels are reversed and lying to you. Keep got first.
| Assertion | Form | On failure it prints |
|---|---|---|
| expect(t, cond, msg) | a bool + your message | your message — use it when the check is naturally one bool (is_sorted(xs), err == nil) |
| expect_value(t, got, want) | typed equality | both sides (expected X, got Y) — prefer it whenever you compare two values |
| expectf(t, cond, fmt, …) | a bool + a format string | the formatted message — for a loop over table rows, so it says which row failed |
| fail_now(t, msg) | abort this test now | bails immediately; fail(t) flags failure but keeps running |
The last level is the property that emerges from how the runner finds tests: @(test) is not a passive label the runner trusts. The compiler verifies the signature when it builds the test binary — so a malformed test is a build error, not a silent no-op.
Here's the mechanism behind it. odin build . and odin test . compile the same source files into two different binaries. A normal build's entry point is your main, and it strips out every @(test) proc — so shipping tests next to production code costs nothing at runtime. A test build's entry point is the runner from core:testing, which gathers the marked procs and calls each one. Because the test build is a real compile, the marker is real syntax the compiler must make sense of.
So mark a proc that has the wrong shape — say clamp_to_range, which is proc(int, int, int) -> int, not proc(^testing.T). A plain odin build . succeeds (it just strips the proc). But odin test . stops cold:
@(test) clamp_to_range :: proc(value, lo, hi: int) -> int { /* ... */ } Error: Testing procedures must have a signature type of proc(^testing.T), got proc(int, int, int) -> int
The emergent payoff: the marker isn't a sticky note the runner hopes you got right — it's a contract the type system enforces. You cannot accidentally register a proc that doesn't match the test shape, because the test build won't compile until it does. A test that "didn't run" because its signature was subtly wrong is a whole class of silent failure that simply can't happen here.
That's the arc: L1 a test is a marked proc that odin test . finds and runs → L2 you reach for it by pulling pure logic out so there's a value to assert on → L3 a false check prints what broke and exits nonzero, so the test is load-bearing → L4 the marker is signature-checked at compile time, so a malformed test fails the build instead of silently never running.