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:
- Compiles the requested sources (file or workspace).
- Builds a temporary test runner binary that links all test blocks.
- Executes the runner and reports results to standard output.
- 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
tryorcatchto 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 testfor a file or the entire workspace. - Use
assert conditionorassert 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.