Testing

Testing

Sun has builtin unit testing: declare tests with test_function, assert with std.test, and the compiler produces a test runner for you. Tests never appear in production binaries or .moon bundles.

using std;
 
module geometry {
    function addOne(n: i32) i32 {
        return n + 1;
    }
 
    test_function addsOne() {
        try std.test.assert_eq(geometry.addOne(1), 2);
    }
}
 
function main() i32 {
    return 0;
}
 
manifest { libraries: ["stdlib.moon"] }
$ sun test program.sun
PASS geometry.addsOne
1 passed, 0 failed

Declaring tests

A test is declared like a function, with test_function in place of function. Tests take no parameters. A test can explicitly return a result enum whose first variant has no payload and whose remaining variants each own one concrete class implementing IError. Reaching its end or writing return; produces the first variant, regardless of its name. Both sequential and parallel runners report any failure variant using its error message.

/** Groups the failures this test can propagate. */
enum TestOutcome {
    Passed,
    Assertion(std.test.AssertionError),
    System(std.Error)
}
 
/** Uses a custom success name and propagates assertion failures. */
test_function arithmetic() TestOutcome {
    try std.test.assert_eq(2 + 2, 4);
}

Explicit _Result<void, E> remains supported. Its E can be a concrete class implementing IError, or an enum whose immediate payloads implement IError.

Tests default to std.test.AssertionResult<void>. Use try to propagate an assertion failure. An explicit error enum lets a test also propagate concrete library errors without converting them to assertion failures.

Tests are module-level declarations. They cannot appear inside classes, interfaces or function bodies, cannot be public (they are never importable), cannot take type parameters, and cannot be named main.

A test's name is reported qualified by its module (geometry.addsOne), so the same test name in two different modules is fine. Two same-named tests in one module are a duplicate-declaration error.

Where tests live

Tests can sit inline, next to the code they exercise. Privacy is module-scoped, so a test inside module geometry can call the module's private functions directly — no exporting things just to test them.

Larger suites go in test files: .sun files listed under test_files: in the manifest, loaded only when compiling tests. A test file reopens the module it tests to reach its private items:

manifest {
    source_files: ["geometry.sun"]
    test_files: ["geometry_tests.sun"]
    libraries: ["stdlib.moon"]
}

test_files: is also accepted inside per-OS target: blocks.

Production builds strip test functions before analysis, so a test may freely reference helpers that exist only in test_files. Everything else — classes, helper functions, module variables declared outside test_files — stays in the production build, so shared fixtures and helpers that should not ship belong in a test file.

The standard library tests itself this way: each stdlib/<area>_tests.sun sits next to the file it tests and is listed under test_files: in stdlib/stdlib.sun, with shared fixtures in stdlib/fixtures_tests.sun. sun test stdlib/stdlib.sun runs the whole suite from a workspace checkout; none of it ships in stdlib.moon. The tls bundle does the same with tls/https_client_tests.sun, listed in tls/tls.sun.

Running tests

sun test compiles the program with its tests and runs them under the JIT:

sun test program.sun                    # parallel (default)
sun test program.sun --test-sequential  # one after another

The exit code is 0 when every test passes, 1 otherwise.

Compiling with -c also emits a second binary, <output>_test, whenever the program has tests:

$ sun -c -o app program.sun
Successfully compiled to: app
Successfully compiled test binary to: app_test
$ ./app_test --test-sequential

Pass --no-test to skip the test binary. A program with tests but no main — a library — compiles to just the test binary.

Filtering

--test-filter <pattern> picks which tests run. Like --test-sequential it is a runtime argument of the test binary, so no recompile is needed:

sun test program.sun --test-filter geometry.midpoint  # exact dotted name
sun test program.sun --test-filter geometry.*         # everything under geometry
sun test program.sun --test-filter geometry           # same, no star needed
./app_test --test-filter geometry.midpoint            # compiled binary too

The flag repeats: a test runs when it matches any given pattern. A pattern matches a test's dotted name exactly, as a module prefix (geometry selects geometry.midpoint), or as a trailing-star prefix (geo*). The summary counts only the tests that ran, and a filter that selects nothing reports 0 passed, 0 failed and exits 0.

Parallel by default

The runner starts one thread per test, then joins and reports in declaration order, so the output is deterministic. Tests therefore must not share unsynchronized state: a module-level var written by two tests is a data race. Either protect shared state with std.thread.Mutex, or pass --test-sequential (a runtime argument, so no recompile) to run tests one after another in declaration order.

Assertions

Assertions return std.test.AssertionResult<void>. A failure carries code 10 and an owned message. Write try std.test.assert(condition); to return that failure to the runner, which reports it with the test's name.

FunctionMeaning
assert(cond)Fail when cond is false
assert(cond, "why")Same, with an explanation in the output
assert_eq(expected, actual)Fail when the values differ, showing both. Overloaded for every primitive and for String (by content)
assert_ne(a, b)Fail when the values are equal
fail("why")Fail unconditionally

For types without an assert_eq overload, write the comparison yourself: try std.test.assert(a.equals(b), "explanation");.

Handle expected failures with match so a successful call can explicitly fail the test. For unexpected failures, try std.test.expect_ok(operation()) extracts an owned success or converts its error to AssertionError. expect_success checks a _Result<void, E>, and expect_value reads a primitive from a _Result<ref T, E>. An explicit test error enum preserves original error types when using try operation() directly.

Tests require the standard library (stdlib.moon in the manifest, or --moon stdlib.moon); the assertions and the runner live there.

Fixtures

Sun needs no fixture framework: a fixture is an ordinary class whose init does the setup and whose deinit does the teardown.

using std;
 
module config {
    class TempDir {
        public var path: String;
        init() _Result<void, Error> { /* create a unique directory and initialize path */ }
        deinit() { /* remove it and its contents */ }
    }
 
    test_function writesConfigFile() {
        var dir = try std.test.expect_ok(config.TempDir());   // setup
        // ... exercise code that writes into dir.path ...
        try std.test.assert(true);
        // teardown runs here or when try returns an assertion failure
    }
}

Two language guarantees make this complete:

  1. A local is dropped exactly once at the end of its scope, so teardown always runs after a passing test.
  2. Propagating a failed assertion drops every live local before returning, so teardown also runs when the test fails.

A test can hold several fixtures. Fallible setup uses init(...) _Result<void, E>; handle or propagate its result before using the constructed fixture.

In the editor

The Sun VSCode extension shows every test_function in the Test Explorer, grouped by entrypoint and file, with run icons in the gutter. Tests are discovered through the entrypoints list of your sun-config.json (the sun.sun_configs setting names the config files; the workspace root's is found by default), so the whole tree appears without opening a file.

Running a selection passes --test-filter for each chosen test. When the config names a test_binary_name and that binary is newer than every source, the extension runs it directly; otherwise it JIT-compiles via sun test <entrypoint>. A failed assertion's message appears on the test. Point sun.compiler_path at your sun binary if it is not on PATH (the extension also tries the one next to sun-lsp).

Debugging the runner

--debug writes the synthesized runner source next to the other debug artifacts, so you can see exactly what the test binary's main does:

sun test --debug program.sun    # writes program_debug/test_runner.sun
sun -c --debug -o app program.sun   # test build writes app_test_debug/