Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit44fd9e59bd

README.md

test

Test framework — simple unit testing with the describe / it / expect pattern. Used by the engine test suite and any user-authored suites that want a single dependency. Pure Luau; flag-based failure tracking instead of exceptions (works the same on native and WASM).

Exports

Definition:

  • Test.describe(name: string, fn: () -> ()) -> Suite — define a suite. The body declares the suite's cases, so only what it reached is in the run: a body that ends on a raise leaves a <describe body did not finish> case behind, naming the describe and what it raised, and the run ends on that case. A body that stands down leaves the same case marked skipped, carrying the reason.
  • Test.it(name: string, fn: (TestContext) -> ()) -> any? — define a test.
  • Test.skip(name: string, fn: ((TestContext) -> ())?) -> any? — skip a test. Inside a describe with a body it declares a skipped test; inside a running test (its body or one of its hooks) a reason alone stands that test down — the test ends there, is counted under skipped, and the reason reaches the console, the per-test record and the report. A reason alone anywhere a test is not running raises, naming the declaration form.
  • Test.beforeEach(fn: () -> ()) / Test.afterEach(fn: () -> ()) — per-test hooks.
  • Test.expect(value: any) -> any — expectation builder with toBe, toBeTruthy, toBeFalsy, toBeNil, notToBeNil, toBeType, toBeGreaterThan, toBeLessThan, toBeCloseTo, toContain, toMatch, toHaveLength, toThrow, never().
  • Test.captureFailure(fn: () -> ()) -> string? — run a body and return the failure it records instead of recording it, for tests that assert a matcher rejects something. A body that records several returns the headline for them.
  • Test.captureOutcome(fn: () -> ()) -> Outcome — run a body the way the runner runs a test body and return { failed, error, failures, raised }: its failures in the order they happened, with a raise that ended the body as the final entry.
  • Test.captureTestOutcome(spec: TestSpec) -> TestOutcome: run a whole test the way the runner runs one (beforeEach, the body, afterEach, then the cleanups registered while it ran) and return { skipped, reason, failed, error, failures, raised }. The runner reads its verdict from the same place, so what this reports for a captured test is what a sweep reports for a real one. A scene layer the captured test leaves loaded that was not loaded when it started is unloaded afterwards, as the runner unloads one after each test.

What a failing test reports

A matcher records its failure and lets the body run on, so one body can produce several. They are kept in the order they happened:

  • error — the headline, and the one line the console, the engine log and the report's result table carry. It is the failure that came first, plus (+N more) counting the ones after it — (+N more, ended by a raise) when a raise is what stopped the body.
  • failures — the failures in order, on Test.results[], in each per-test JSON file under Test.PER_TEST_RESULTS_DIR, and in the JSON report. The Markdown report lists them under the failing test's heading. A body that records more of them than the list keeps ends it with a line counting the rest and naming the last of them, so one test asserting inside a long loop stays one bounded entry in the report.

The hooks that run around a body record onto the same list it does, so an assertion a beforeEach, an afterEach or a registered cleanup fails — and an error one of them raises — is a failure of the test it ran for and travels to every consumer the body's failures reach. Each names the phase it came from (afterEach: Expected …, cleanup: Runtime error: …); a body's own failures stay unlabelled. A cleanup that raises still leaves the cleanups behind it to run.

Standing a test down

A test whose precondition this engine does not meet has nothing to verify and has earned no pass. Test.skip(reason) from inside the running test says so:

Test.it("renders N copies from one transform buffer", function()
    if engine.gpuCompute ~= true then
        Test.skip("GPU compute reported unavailable (engine.gpuCompute)")
    end
    -- …the test, on an engine that can run it
end)

The test ends at that call. It is counted under skipped, printed as ○ <name> (skipped: <reason>), and the reason travels as the error field of its Result row, into its per-test JSON file, and into both reports. A beforeEach may stand the test down the same way, and then the body never runs.

Whatever the test's hooks record around a stand-down is a failure of the test they ran for and outranks it: a teardown that fails after a body stood down reports the test as failed.

A reason on its own reaches a test that is running. Inside a describe, where Test.skip declares, the call takes a body — a reason alone raises there, naming the declaration form.

Run / inspect:

  • Test.run() -> boolean — execute every registered suite; true when all pass. Call from a yieldable context (the tests toolbox does) so multi-frame tests yield. Discovering + running every suite is the tests toolbox's job (tests.run), not this framework's.
  • Test.clear() — reset suites + stats (re-run from clean state).
  • Test.getResults() -> { Result }, Test.getStats() -> Stats.

Multi-frame / sandboxing:

  • Test.waitFrames(n: number?) — yield until at least n engine frames elapse.
  • Test.tmpPath(relativePath: string?) -> string — sandboxed path under Test.TMP_ROOT.
  • Test.prepareTmp() / Test.cleanupTmp() — recreate / remove the tmp root.

Notices:

  • Test.noticeMark(template: string) -> NoticeMark / Test.noticesSince(mark) -> number: how many times a notice posted under template fired after the mark. A drain seals the row it returns and the next emission starts a new row, so this counts across every row of the template.
  • Test.latestNotice(template: string) -> any?: the stored row of that template the most recent emission landed in.

Scene layers:

  • Test.useScene(opts?) -> SceneProxy — mint an isolated scene for the current test from a .scene fixture and load it (root, or an additive overlay with { additive = true }). Unloaded and removed at teardown.
  • Test.useSceneEach(opts?) — suite-level sugar: one freshly minted scene per test.
  • Test.ensureRootLayer() -> SceneProxy — take a root scene layer for the current test, so layers.active and the scene-scoped surfaces behind it answer inside a test. Returns the loaded root, loading a stand-in scene from Test.BASELINE_SCENE when none is; the layer is unloaded when the test ends.
  • Test.releaseRootLayer() — unload the stand-in and remove its source folder. The runner calls it when a run ends.
  • Test.beginWorldBaseline() -> WorldBaseline / Test.restoreWorldBaseline(baseline) — bracket a whole sweep: unload the layers the engine booted with (so no suite depends on ambient content) and put them back afterwards.

Public state:

  • Test.suites, Test.currentSuite, Test.currentTest, Test.stats, Test.results, Test.TMP_ROOT (/zero/source/tmp/tests).

Types:

  • Stats = { total, passed, failed, skipped: number }
  • Result = { suite, test: string, passed, skipped: boolean, error: string? } — on a skipped row error is the reason the test stood down
  • TestContext = { waitFrames, expect, fail, suiteName, testName }
  • Suite = { name, tests, beforeEach?, afterEach? }
  • Outcome = { failed, raised: boolean, error: string?, failures: { string } }
  • TestOutcome = Outcome & { skipped: boolean, reason: string? }
  • TestSpec = { body: (TestContext) -> (), beforeEach?, afterEach?: () -> (), name: string? }
  • RunAllOpts = { path: string? }
  • NoticeMark = { template: string, counts: { [string]: number } }: each stored row's count at the mark, by row

Usage

local Test = asset.containing(__FILE__).modules.shared

Test.describe("My Suite", function()
    Test.it("does basic things", function()
        Test.expect(1 + 1).toBe(2)
        Test.expect("hello").toContain("ell")
    end)

    Test.it("cascade despawn cleans children across frames", function(t)
        local parent = entity.spawn("parent")
        local child  = entity.spawn("child")
        child.setParent(parent.id)
        t.waitFrames(1)              -- let setParent settle

        entity.despawn(parent.id)
        t.waitFrames(2)              -- let cascade + ECS despawn run

        Test.expect(entity.exists(parent.id)).toBe(false)
        Test.expect(entity.exists(child.id)).toBe(false)
    end)
end)

Test.run()

Parts

The module is a folder of parts, each a module of its own that init.luau requires:

  • Test.valueText: the structural, bounded rendering Test.describeValue gives a table.
  • Test.worldReadings: the readings of engine-wide state a teardown settle and the scene-rect hand-back compare.

Notes

  • Failure is flag-based, not exception-based — every matcher records the failure but allows the body to keep executing. Avoids WASM C++ exception handling cost.
  • The runner records the loaded scene layers and the entity set before each test. Afterwards it unloads every scene layer the test left loaded that was not loaded before it, newest first, so the entities that layer's load built leave with the layer and the scene's source is what its next load builds from. It then despawns any other newly-spawned entity (mutations apply after the execute() batch returns; cleanedUpIds makes the snapshot tolerate deferred cleanup).
  • After each test the runner defensively returns the scene to edit mode if a body or its afterEach leaked play. This stops a single mode-leaking test from cascading silently into every downstream vfs.write-using suite.
  • Multi-frame t.waitFrames needs a yieldable call chain. A non-yieldable C-frame above Test.run (e.g. driving it from inside a require) makes waitFrames raise a sentinel and the test is marked SKIPPED — the body had nothing to verify, so what it recorded is dropped and what its setup recorded stands. A hook that fails around such a body still fails the test. The tests toolbox (tests.run → AssetRef:run()) runs the suite in a plain coroutine, so the real wait path exercises.
  • toThrow calls its subject under pcall and fails when the call returns normally. toThrow("substring") additionally requires the rendered raised value to contain that substring. never().toThrow() asserts the call completes.