Skip to main content

Testing

Zynx includes a built-in, lightweight testing framework. Tests live alongside your code and are compiled and run with the same toolchain as your application.

This page covers:

  • How to declare and run tests
  • How tests interact with modules and visibility
  • Patterns for testing iterators, error handling, and async code
  • Recommended best practices

Declaring Tests

A test is defined with the test keyword followed by a descriptive string name and a block.

test "addition works" {
assert 1 + 1 == 2;
}

Characteristics:

  • The string should describe the behavior being validated, not the implementation detail.
  • Tests can be placed anywhere at module scope.
  • Multiple tests per file are supported and encouraged.

Access to Module Internals

Tests are compiled as part of their defining module:

  • They can access private functions, types, and fields.
  • They run in the same namespace as the code under test.

This allows you to test internal logic without exposing it as part of your public API.

fn parse_internal(x: str) -> i32 {
// not exported
return x.length as i32;
}

test "parse_internal counts length" {
let v = parse_internal("abc");
assert v == 3, "expected length of 3";
}

Running Tests

Use the test command to compile and run tests.

# Run all tests in the current workspace
zynx test

# Run tests in a single file
zynx test src/math.zx

# Run tests and show verbose output (if supported)
zynx test --verbose

Execution Flow

When you run zynx test, the tool:

  1. Compiles the requested sources (file or workspace).
  2. Builds a temporary test runner binary that links all test blocks.
  3. Executes the runner and reports results to standard output.
  4. Cleans up temporary artifacts.

The exact runner wiring is an implementation detail; from the user's perspective, tests behave like normal Zynx code executed in a controlled environment.


Expectations and Assertions

Use the assert statement to check conditions inside test blocks.

assert

test "division" {
assert 10 / 2 == 5, "10 / 2 should be 5";
}

The optional second argument is a message shown when the assertion fails. Without a message, the failed expression itself is reported.

test "basic assert" {
assert 1 + 1 == 2;
}

Typical behavior:

  • When the condition is true, execution continues.
  • When the condition is false, the test is reported as failed and the runner includes the supplied message.

Use assertions to check:

  • return values
  • invariants on internal state
  • error codes and branches

Testing Patterns

1. Table-Driven Tests

Group related inputs and expected outputs into a small table for concise coverage.

struct Case {
input: i32,
expect: i32,
}

fn double(x: i32) -> i32 {
return x * 2;
}

test "double handles basic cases" {
let cases = [
Case { input: 0, expect: 0 },
Case { input: 1, expect: 2 },
Case { input: -2, expect: -4 },
];

for c in cases {
let got = double(c.input);
assert got == c.expect, "unexpected result for {c.input}";
}
}

This keeps individual tests small and focused while still covering multiple scenarios.


2. Testing Iteration and Interfaces

You can test interface implementations (for example, custom iterables) directly.

struct MyRange {
curr: i32,
end: i32,
}

// Assume MyRange implements Iterable<i32>
test "MyRange iterates from 0 to 2" {
let range = MyRange { curr: 0, end: 3 };
let i = 0;

for val in range {
assert val == i, "expected {i}, got {val}";
i += 1;
}

assert i == 3, "expected exactly three iterations";
}

Patterns:

  • Use small ranges to make failures easy to reason about.
  • Assert both values and iteration count when relevant.

3. Testing Error Handling

Zynx uses error sets and error unions (T!). Tests should explicitly exercise both success and error paths.

error ParseError {
Empty,
InvalidDigit,
}

fn parse_digit(s: str) -> i32! {
if s.length == 0 {
return ParseError.Empty;
}
if s == "0" { return 0; }
if s == "1" { return 1; }
return ParseError.InvalidDigit;
}

test "parse_digit success" {
let v = parse_digit("1") catch -1;
assert v == 1, "expected 1";
}

test "parse_digit reports Empty" {
let res: i32! = parse_digit("");
if res {
assert false, "expected error, got success";
} else {
// res is an error here
assert res == ParseError.Empty, "expected Empty error";
}
}

Guidelines:

  • For success flows, use try or catch to keep the code clear.
  • For error flows, match on specific error variants so regressions are visible.

4. Testing Async Code

Async testing follows the same model; tests can be async and can await futures.

async fn delayed_add(a: i32, b: i32) -> i32 {
// imagine some await here in real code
return a + b;
}

test "delayed_add returns sum" {
async fn body() {
let v = await delayed_add(2, 3);
assert v == 5, "expected 5";
}

// Implementation detail: depending on the current async tooling,
// `body` may be invoked via an async test harness.
body();
}

Because async support and test harness details may evolve, keep async tests focused on:

  • Observed behavior of async APIs
  • Explicit error handling with try / catch
  • Predictable ordering for operations that depend on scheduling

Performance and Compilation Behavior

  • Test blocks are not included in normal release builds; they are compiled only for test runs.
  • The compiler may perform the same optimizations for test code as for regular code, which helps catch issues that only appear in optimized builds.
  • For performance-sensitive libraries, consider adding both:
    • fast, small tests that run on every developer machine
    • heavier integration tests that can be run in CI

Best Practices

1. Treat Tests as First-Class Code

  • Keep test code readable and idiomatic.
  • Avoid unnecessary cleverness in test logic; debugging tests should be straightforward.

2. Name Tests by Behavior

Good names describe what is guaranteed, not how:

  • test "parser accepts valid input" (good)
  • test "parse_internal_1" (weak - implementation detail and not descriptive)

3. One Concept per Test

Each test should validate a single behavior or closely related behaviors. If a test starts branching heavily, it may be time to split it:

  • Prefer several small tests over a single large monolithic one.
  • Use table-driven testing to avoid repetition without combining unrelated cases.

4. Test Edge Cases

Explicitly cover:

  • empty collections
  • minimum and maximum values
  • boundary conditions for ranges and indices
  • error returns and exceptional control flow

5. Keep Tests Deterministic

Avoid:

  • wall-clock time checks
  • global shared state without reset
  • non-deterministic external inputs

If randomness is needed, seed it explicitly and keep the seed fixed in tests.


Summary

  • Use test "name" { ... } blocks to define unit tests inline with your code.
  • Run tests via zynx test for a file or the entire workspace.
  • Use assert condition or assert condition, "message" to check expectations.
  • Test success and error paths, iterables, and async flows explicitly.
  • Keep tests small, deterministic, and behavior-focused.

With tests integrated directly into the language and tooling, Zynx encourages you to verify correctness early and maintain a tight feedback loop as your code evolves.