Odin Language Testing: Writing and Running Tests in Odin

Odin Language Testing: Writing and Running Tests in Odin

Odin is a systems programming language built for performance, clarity, and joy. It sits in a similar space to Zig and C3 — lower level than Go, safer than C, and with a syntax that prioritizes readability over terseness. Odin's testing story is built directly into the compiler: no third-party testing library is required. This guide covers everything you need to write effective tests in Odin, from the basic syntax to CI integration.

Odin's Built-In Test Runner

The odin test command discovers and runs tests without any configuration. Tests are ordinary Odin procedures marked with the @(test) attribute. The runner collects all such procedures in the specified package and executes them, reporting pass/fail per test.

odin test src/

This command compiles and runs all tests in the src/ directory. You do not need a main procedure in your test files — the runner generates one.

Writing Your First Test

Here is a simple module and its tests:

// src/math/math.odin
package math

add :: proc(a, b: int) -> int {
    return a + b
}

clamp :: proc(value, min_val, max_val: int) -> int {
    if value < min_val do return min_val
    if value > max_val do return max_val
    return value
}

safe_divide :: proc(a, b: f64) -> (f64, bool) {
    if b == 0.0 do return 0.0, false
    return a / b, true
}
// src/math/math_test.odin
package math

import "core:testing"

@(test)
test_add :: proc(t: ^testing.T) {
    testing.expect_value(t, add(2, 3), 5)
    testing.expect_value(t, add(-10, 4), -6)
    testing.expect_value(t, add(0, 0), 0)
}

@(test)
test_clamp_within_range :: proc(t: ^testing.T) {
    testing.expect_value(t, clamp(5, 0, 10), 5)
}

@(test)
test_clamp_below_min :: proc(t: ^testing.T) {
    testing.expect_value(t, clamp(-5, 0, 10), 0)
}

@(test)
test_clamp_above_max :: proc(t: ^testing.T) {
    testing.expect_value(t, clamp(15, 0, 10), 10)
}

@(test)
test_safe_divide_success :: proc(t: ^testing.T) {
    result, ok := safe_divide(10.0, 2.0)
    testing.expect(t, ok, "Expected division to succeed")
    testing.expect_value(t, result, 5.0)
}

@(test)
test_safe_divide_by_zero :: proc(t: ^testing.T) {
    _, ok := safe_divide(5.0, 0.0)
    testing.expect(t, !ok, "Expected division by zero to return false")
}

Run with odin test src/math/. Each test function is named, and failures report which procedure failed and what the expected vs actual values were.

The core:testing API

Odin's core:testing package provides a focused set of assertion procedures:

// Assert a boolean condition
testing.expect(t, condition, "optional message")

// Assert exact value equality
testing.expect_value(t, actual, expected)

// Log a message (useful for debugging test failures)
testing.log(t, "some debug info")
testing.logf(t, "value is %d", some_value)

// Explicitly fail the test
testing.fail(t)
testing.fail_now(t)  // stops execution of current test immediately

// Mark test as failed with a message
testing.errorf(t, "expected %v got %v", expected, actual)

The testing.T pointer is passed to every test procedure. You do not construct it yourself — the test runner provides it.

Organizing Tests

Odin tests live in the same package as the code they test. The convention is to suffix test files with _test.odin. The test file declares the same package name and imports core:testing:

src/
  auth/
    auth.odin
    auth_test.odin
  storage/
    storage.odin
    storage_test.odin
  http/
    router.odin
    router_test.odin

To run all tests recursively:

odin test src/... -all-packages

The -all-packages flag tells the runner to discover packages in subdirectories.

Testing with Custom Types

Odin uses structs for custom types. Tests for struct-based logic follow the same patterns:

// src/user/user.odin
package user

Role :: enum {
    Admin,
    Member,
    Guest,
}

User :: struct {
    name: string,
    role: Role,
    active: bool,
}

can_delete :: proc(u: User) -> bool {
    return u.role == .Admin && u.active
}

display_name :: proc(u: User) -> string {
    if len(u.name) == 0 do return "Anonymous"
    return u.name
}
// src/user/user_test.odin
package user

import "core:testing"

@(test)
test_active_admin_can_delete :: proc(t: ^testing.T) {
    u := User{name: "Alice", role: .Admin, active: true}
    testing.expect(t, can_delete(u), "Active admin should be able to delete")
}

@(test)
test_inactive_admin_cannot_delete :: proc(t: ^testing.T) {
    u := User{name: "Bob", role: .Admin, active: false}
    testing.expect(t, !can_delete(u), "Inactive admin should not be able to delete")
}

@(test)
test_member_cannot_delete :: proc(t: ^testing.T) {
    u := User{name: "Carol", role: .Member, active: true}
    testing.expect(t, !can_delete(u), "Member should not be able to delete")
}

@(test)
test_empty_name_returns_anonymous :: proc(t: ^testing.T) {
    u := User{name: "", role: .Guest, active: true}
    testing.expect_value(t, display_name(u), "Anonymous")
}

Each variant of the logic gets its own test procedure. This granularity makes failures easy to diagnose — you know exactly which case broke.

Testing Memory and Allocators

Odin's explicit allocator model is one of its defining features. When testing code that allocates memory, use a tracking allocator to detect leaks and double-frees:

@(test)
test_no_memory_leaks :: proc(t: ^testing.T) {
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, context.allocator)
    defer mem.tracking_allocator_destroy(&track)

    context.allocator = mem.tracking_allocator(&track)

    // Run the code under test
    result := build_large_string()
    delete(result)

    // Check for leaks
    if len(track.allocation_map) > 0 {
        testing.errorf(t, "Memory leak: %d allocations not freed", len(track.allocation_map))
        for _, entry in track.allocation_map {
            testing.logf(t, "  Leaked %v bytes at %v", entry.size, entry.location)
        }
    }

    if len(track.bad_free_array) > 0 {
        testing.errorf(t, "Bad frees detected: %d", len(track.bad_free_array))
    }
}

This pattern is especially valuable when testing parsers, data structures, and any code that builds strings or slices. Memory bugs are invisible without tracking, and catching them in tests is far cheaper than catching them in production.

Benchmark Tests

Odin's test runner also supports benchmarks with the @(benchmark) attribute:

@(benchmark)
benchmark_add :: proc(options: ^testing.Benchmark_Options, allocator := context.allocator) -> bool {
    n := options.count
    sum := 0
    for i in 0..<n {
        sum += add(i, i+1)
    }
    options.processed = n
    return true
}

Run benchmarks with:

odin test src/ -bench

Benchmarks report nanoseconds per operation. Use them to verify that performance-critical paths stay within bounds after refactoring.

Testing Error Returns

Odin uses multiple return values for error handling rather than exceptions. Testing error paths means testing the second (or last) return value:

// src/parser/parser.odin
package parser

Parse_Error :: enum {
    None,
    Invalid_Format,
    Out_Of_Range,
    Empty_Input,
}

parse_port :: proc(input: string) -> (port: int, err: Parse_Error) {
    if len(input) == 0 do return 0, .Empty_Input
    val, ok := strconv.parse_int(input)
    if !ok do return 0, .Invalid_Format
    if val < 1 || val > 65535 do return 0, .Out_Of_Range
    return val, .None
}
@(test)
test_parse_valid_port :: proc(t: ^testing.T) {
    port, err := parse_port("8080")
    testing.expect_value(t, err, Parse_Error.None)
    testing.expect_value(t, port, 8080)
}

@(test)
test_parse_empty_input :: proc(t: ^testing.T) {
    _, err := parse_port("")
    testing.expect_value(t, err, Parse_Error.Empty_Input)
}

@(test)
test_parse_out_of_range :: proc(t: ^testing.T) {
    _, err := parse_port("99999")
    testing.expect_value(t, err, Parse_Error.Out_Of_Range)
}

@(test)
test_parse_invalid_format :: proc(t: ^testing.T) {
    _, err := parse_port("abc")
    testing.expect_value(t, err, Parse_Error.Invalid_Format)
}

Every error enum variant gets its own test. When you add a new error case, add a test before writing the implementation.

CI Integration

name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Odin
        run: |
          git clone https://github.com/odin-lang/Odin.git /opt/odin
          cd /opt/odin && make release
          echo "/opt/odin" >> $GITHUB_PATH

      - name: Install LLVM (required by Odin)
        run: sudo apt-get install -y llvm-14 clang-14

      - name: Run tests
        run: odin test src/ -all-packages

      - name: Run benchmarks
        run: odin test src/ -bench -all-packages

The Odin build step compiles the compiler from source, which takes a few minutes. Cache the /opt/odin directory with actions/cache keyed on the Odin commit SHA to avoid rebuilding it on every run.

What Is Not Covered

Odin's built-in test runner does not include:

  • Mocking — Odin's explicit, low-level nature means you typically inject dependencies as procedure pointers or interface-like structs rather than using a mocking framework
  • Property-based testing — no QuickCheck-style library exists yet; write parameterized tests by hand
  • Snapshot testing — serialize your expected values explicitly in assertions

For end-to-end and integration testing of applications built with Odin (such as web servers or CLI tools), pairing the built-in unit tests with a cloud testing platform like HelpMeTest gives you coverage at every layer. The Pro plan at $100/month lets you run automated browser and API tests against your deployed application without writing browser automation code.

Summary

Odin's test runner is minimal and opinionated: mark procedures with @(test), import core:testing, use testing.expect and testing.expect_value. There is nothing to install and nothing to configure. The tracking allocator pattern makes memory safety testing a first-class activity, not an afterthought. Write one test per behavior, cover every error enum variant, and set up CI from the first commit. The simplicity of Odin's testing tools is a feature — it removes every excuse not to test.

Read more