Odin · core:testing

A test proc, four levels deep

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:


    

What the runner gives you free

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.

What's stable, what isn't

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.

tangled — math lives inside the frame loop

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
}
nothing to assert — no value comes back out

pure — the math is a proc you can call

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
}
a ball in, a ball out — assertable in isolation

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:

odin test . — the step package (2 tests)

    
@(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:


      
runner 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.

AssertionFormOn failure it prints
expect(t, cond, msg)a bool + your messageyour message — use it when the check is naturally one bool (is_sorted(xs), err == nil)
expect_value(t, got, want)typed equalityboth sides (expected X, got Y) — prefer it whenever you compare two values
expectf(t, cond, fmt, …)a bool + a format stringthe formatted message — for a loop over table rows, so it says which row failed
fail_now(t, msg)abort this test nowbails 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.

@(test), not @test The parentheses are required — the bare form @test does not register the proc as a test at all. And the proc must take exactly t: ^testing.T and return nothing; an extra parameter or a return value is the same signature error you saw above. The attribute is the only thing that distinguishes a test from any other proc, so getting it exactly right is the one piece of ceremony the runner asks of you.

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.

the runner outputs & the L4 error on this page are real odin test output (claims/lessons/17-testing-with-core-testing) · timestamps, seeds, thread counts & durations vary every run