← Back to DevBytes

Testing Strategies for Odin Applications

Introduction to Testing in Odin

Odin is a systems programming language designed for performance, readability, and simplicity. While Odin does not ship with a heavyweight built-in testing framework like some languages, it provides the testing package and a flexible package system that makes it straightforward to build robust testing strategies. This tutorial walks through what testing in Odin looks like, why it matters, and how to structure your projects for maintainable, automated tests.

Why Testing Matters for Odin Applications

Odin is often used for performance-critical systems: game engines, simulations, embedded tooling, and data processing pipelines. In these domains, bugs can be expensive and difficult to reproduce. A solid testing strategy provides several concrete benefits:

Because Odin compiles fast and has no runtime overhead for unused code, there is little cost to keeping a large test suite alongside your source files.

The Odin Testing Package

Odin provides a testing package in the core library. It exposes basic assertion helpers and a test runner that the compiler can invoke directly. The key entry points are:

To run tests, compile your program with the -test flag or use odin test . against a package directory.

Project Layout for Testability

A practical Odin project separates library code from executable entry points. A common layout looks like this:

my_app/
├── math_utils/
│   ├── math_utils.odin
│   └── math_utils_test.odin
├── parser/
│   ├── parser.odin
│   └── parser_test.odin
└── main.odin

Each subpackage contains its implementation file and a sibling *_test.odin file in the same package. Because the test file is part of the same package, it can access unexported procedures and types, which is essential for white-box testing.

Writing Your First Test

Let us start with a simple math utility package. Create math_utils.odin:

package math_utils

import "core:fmt"

// clamp restricts a value to a given range.
clamp :: proc(value, low, high: f64) -> f64 {
    if value < low do return low
    if value > high do return high
    return value
}

// lerp performs linear interpolation between a and b.
lerp :: proc(a, b, t: f64) -> f64 {
    return a + (b - a) * t
}

Now create math_utils_test.odin in the same directory:

package math_utils

import "core:testing"

@(test)
clamp_test :: proc(t: ^testing.T) {
    testing.expectf(t, clamp(5.0, 0.0, 10.0) == 5.0, "expected 5.0")
    testing.expectf(t, clamp(-3.0, 0.0, 10.0) == 0.0, "expected lower bound 0.0")
    testing.expectf(t, clamp(15.0, 0.0, 10.0) == 10.0, "expected upper bound 10.0")
}

@(test)
lerp_test :: proc(t: ^testing.T) {
    testing.expectf(t, lerp(0.0, 100.0, 0.0) == 0.0, "t=0 should return a")
    testing.expectf(t, lerp(0.0, 100.0, 1.0) == 100.0, "t=1 should return b")
    testing.expectf(t, lerp(0.0, 100.0, 0.5) == 50.0, "t=0.5 should return midpoint")
}

Run the tests from the project root:

odin test ./math_utils

The runner discovers every procedure tagged with @(test), executes it, and reports failures. Each test receives a ^testing.T pointer that carries state and allows you to record expectations.

Table-Driven Tests

For functions with many input combinations, table-driven tests keep your suite concise and easy to extend. Here is an example for a string utility:

package str_utils

import "core:strings"
import "core:testing"

slugify :: proc(input: string) -> string {
    builder := strings.builder_make()
    defer strings.builder_destroy(&builder)

    for c in input {
        if c >= 'a' && c <= 'z' {
            strings.write_rune(&builder, c)
        } else if c >= 'A' && c <= 'Z' {
            strings.write_rune(&builder, c - ('A' - 'a'))
        } else if c >= '0' && c <= '9' {
            strings.write_rune(&builder, c)
        } else if c == ' ' || c == '_' {
            strings.write_rune(&builder, '-')
        }
    }
    return strings.builder_to_string(&builder)
}

@(test)
slugify_test :: proc(t: ^testing.T) {
    cases := [?]struct {
        input:    string
        expected: string
    }{
        {"Hello World",   "hello-world"},
        {"Odin Lang 2024", "odin-lang-2024"},
        {"Already_Slug",  "already-slug"},
        {"UPPER CASE",    "upper-case"},
        {"trim  spaces ", "trim-spaces-"},
    }

    for case in cases {
        result := slugify(case.input)
        testing.expectf(
            t,
            result == case.expected,
            "slugify(%q) = %q, want %q",
            case.input, result, case.expected,
        )
    }
}

Adding a new case is a one-line change, and a failure pinpoints exactly which input broke.

Testing Error Paths

Odin commonly uses multiple return values with an ok flag or an error union to signal failure. Your tests should exercise both the success and failure branches. Consider a parser:

package parser

import "core:fmt"
import "core:strings"

ParseError :: enum {
    Empty,
    InvalidFormat,
    OutOfRange,
}

parse_port :: proc(input: string) -> (u16, ParseError) {
    if len(input) == 0 do return 0, .Empty

    value: u64 = 0
    for c in input {
        if c < '0' || c > '9' {
            return 0, .InvalidFormat
        }
        value = value * 10 + u64(c - '0')
        if value > 65535 {
            return 0, .OutOfRange
        }
    }
    return u16(value), .InvalidFormat if false else nil
}

The corresponding test file covers each branch:

package parser

import "core:testing"

@(test)
parse_port_valid :: proc(t: ^testing.T) {
    port, err := parse_port("8080")
    testing.expectf(t, err == nil, "expected no error, got %v", err)
    testing.expectf(t, port == 8080, "expected 8080, got %v", port)
}

@(test)
parse_port_empty :: proc(t: ^testing.T) {
    _, err := parse_port("")
    testing.expectf(t, err == .Empty, "expected .Empty, got %v", err)
}

@(test)
parse_port_invalid :: proc(t: ^testing.T) {
    _, err := parse_port("80a0")
    testing.expectf(t, err == .InvalidFormat, "expected .InvalidFormat, got %v", err)
}

@(test)
parse_port_out_of_range :: proc(t: ^testing.T) {
    _, err := parse_port("70000")
    testing.expectf(t, err == .OutOfRange, "expected .OutOfRange, got %v", err)
}

One test per branch keeps failures localized and the intent obvious.

Testing with Allocators

Odin's context.allocator system makes memory testing powerful. You can swap in a tracking allocator to detect leaks or use a temporary allocator for scratch memory. Here is an example that verifies a procedure does not leak:

package buffer_utils

import "core:mem"
import "core:testing"
import "core:fmt"

// repeat builds a string containing the input repeated n times.
repeat :: proc(input: string, n: int) -> string {
    if n <= 0 do return ""
    result := make([]u8, len(input) * n, context.allocator)
    for i in 0 < n {
        copy(result[i * len(input):], input)
    }
    return string(result)
}

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

    context.allocator = mem.tracking_allocator(&tracking)

    result := repeat("abc", 4)
    testing.expectf(t, result == "abcabcabcabc", "unexpected result %q", result)

    // Free the result and verify no outstanding allocations remain.
    delete([]u8(result), context.allocator)

    leaks := mem.tracking_allocator_check(&tracking)
    testing.expectf(t, leaks == 0, "detected %d leaked allocations", leaks)
}

This pattern is invaluable for libraries that allocate dynamically. A leak detected here is a bug caught before it reaches production.

Subtests and Setup Helpers

For tests that share expensive setup, helper procedures keep code DRY. You can also split a single @(test) procedure into logical subtests by recording expectations with descriptive messages:

package config

import "core:encoding/json"
import "core:testing"

Config :: struct {
    host:    string,
    port:    u16,
    debug:   bool,
}

parse_config :: proc(raw: []u8) -> (Config, bool) {
    cfg: Config
    ok := json.unmarshal(raw, &cfg)
    return cfg, ok
}

@(test)
parse_config_test :: proc(t: ^testing.T) {
    valid := `{"host":"localhost","port":5432,"debug":true}`
    cfg, ok := parse_config(valid)
    testing.expectf(t, ok, "valid JSON should parse")
    testing.expectf(t, cfg.host == "localhost", "host mismatch: %q", cfg.host)
    testing.expectf(t, cfg.port == 5432, "port mismatch: %v", cfg.port)
    testing.expectf(t, cfg.debug == true, "debug mismatch: %v", cfg.debug)

    _, ok = parse_config(`{not valid json}`)
    testing.expectf(t, !ok, "malformed JSON should fail to parse")
}

Integration Tests

Unit tests verify individual procedures; integration tests verify that multiple components work together. In Odin, you can place integration tests in a separate package that imports the modules under test. For example, a test that exercises a parser feeding into an evaluator:

package integration_test

import "core:testing"
import "../parser"
import "../evaluator"

@(test)
parse_then_eval :: proc(t: ^testing.T) {
    expr, err := parser.parse("2 + 3 * 4")
    testing.expectf(t, err == nil, "parse failed: %v", err)

    result, ok := evaluator.eval(expr)
    testing.expectf(t, ok, "eval failed")
    testing.expectf(t, result == 14, "expected 14, got %v", result)
}

Keep integration tests in their own directory so you can run them separately from fast unit tests during tight development loops.

Best Practices

Running and Filtering Tests

The Odin test runner accepts filters so you can focus on a specific test during development:

# Run all tests in the current package
odin test .

# Run tests matching a name pattern
odin test . -test-name:clamp

# Run tests in a specific subpackage
odin test ./parser

Use these flags to iterate quickly without waiting for the full suite.

Conclusion

Testing in Odin leverages the language's simplicity and fast compilation to deliver a low-friction developer experience. By combining the testing package with thoughtful project layout, table-driven patterns, allocator tracking, and clear naming conventions, you can build a suite that catches regressions early, documents your APIs, and gives you confidence to refactor aggressively. Start with unit tests for pure functions, add integration tests for component boundaries, and wire everything into CI — the investment pays off every time you ship.

— Ad —

Google AdSense will appear here after approval

← Back to all articles