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:
- Regression protection: Tests catch unintended behavior changes when you refactor or optimize hot paths.
- Design feedback: Writing tests first forces you to design APIs that are callable in isolation, which usually means better modularity.
- Documentation: Well-named test functions serve as executable examples of how your code is meant to be used.
- Confidence in releases: A green test suite gives you a safety net before shipping changes to production.
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:
testing.current_test— a context variable holding the current test state.testing.expectandtesting.expectf— assertion helpers that record failures without aborting the program.testing.error— records an error for the current test.- The
@(test)tag — marks a procedure as a test so the runner can discover it.
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
- Keep tests fast. Unit tests should run in milliseconds. Move slow I/O-bound tests into a separate suite.
- One assertion concept per test. Group related expectations, but avoid testing unrelated behavior in a single procedure.
- Use descriptive failure messages.
testing.expectfwith formatted output makes failures self-explanatory. - Test behavior, not implementation. Prefer asserting on outputs and observable state rather than internal structure.
- Isolate side effects. Inject allocators and use context variables so tests do not mutate global state.
- Run tests in CI. Add
odin test .to your continuous integration pipeline so regressions are caught automatically. - Name tests clearly. Use
function_scenario_expectednaming to make test output readable. - Avoid shared mutable fixtures. Construct fresh inputs inside each test to prevent ordering dependencies.
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.