Skip to content
Noodle
InstallLearnPlayground
GitHub

Tests

Tests are part of the language basics because they use ordinary functions, bindings, control flow, and error propagation. A test declaration has a name, no parameters, and a Unit body:

func add(left : Int, right : Int) -> Int do
left + right
end
test "adds two integers" do
Testing.assert_equal(add(20, 22), 42)!;
end

Run the tests declared by a package directory with:

Terminal window
noodle test .

The test command checks the package, builds an isolated test runner, and runs the target package’s tests in module order. Tests in dependency packages are not copied into the target package’s test plan. A test can refer to private declarations in its own module, which makes it useful for checking implementation details without exporting them.

The standard assertion helpers are:

  • Testing.assert(condition)! for a boolean condition;
  • Testing.assert_equal(actual, expected)! for a value comparison; and
  • Testing.fail(message)! for an unconditional failure.

The postfix ! propagates the assertion’s Result failure to the test runner. A test does not declare throws; the compiler infers the error values that can leave the test body. You can use the same form when checking another fallible function:

errortype ParseError
InvalidInput
end
func parse_count(text : String) throws ParseError -> Int do
_ = text;
42
end
test "accepts a count" do
Testing.assert_equal(parse_count("42")!, 42)!;
end

Assertion failures include rendered actual and expected values where applicable, together with source locations autofilled from the expressions in the assertion. The Testing API and its debug rendering are covered in Output, debugging, and tests.

Prefix a test with async when its body needs to await a task. The test runner waits for the returned task before deciding whether the test passed. Assertions and propagated business errors use the same failure reporting as synchronous tests:

async func load_count() -> Int do
42
end
async test "awaits a task" do
count = load_count().await()!;
Testing.assert_equal(count, 42)!;
end

An ordinary test cannot await a task; use async test even when the awaited function has no business errors. Async tests still run serially in the package test plan and use the ordinary or @expensive timeout selected by the runner.

Use the argument-free @expensive attribute only when a test is inherently expected to need a larger time budget, such as a broad integration test or a large generated fixture:

func build_fixture() -> Array[Int] do
[1, 2, 3]
end
@expensive
test "checks the complete generated fixture" do
expected = [1, 2, 3];
Testing.assert_equal(build_fixture(), expected)!;
end

@expensive does not skip the test and does not change its semantics. It tells the current runner to use its separate expensive-test timeout; ordinary tests default to 200 milliseconds and expensive tests default to 15,000 milliseconds. The CLI can override those budgets with --timeout and --expensive-timeout.

For a shorter feedback loop, quick mode still checks and compiles every test but skips the execution of tests marked @expensive:

Terminal window
noodle test --quick .

The final summary reports how many tests were skipped.

Do not add @expensive merely to hide a hang or a performance regression. When a passing @expensive test finishes below min(100ms, --timeout / 2), the runner warns that the test may not need the larger budget. The warning is advisory: it does not fail the test or change the command’s exit status. Failed, timed-out, skipped, and exactly-at-threshold tests do not produce this warning.

Tests may live at the end of the primary .nl file. For a large module, put them in an explicitly included *.tests.part file so they share the module’s private scope without creating a second module. See Modules and packages for source parts and their last-resort role in source organization.

Next: Modules and packages.