Builtin Library
- ..
- .metadata17 B
- README.md10.7 KB
- init.luau348 KB
- valueText.module
- worldReadings.module
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 adescribewith 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 underskipped, 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 withtoBe,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, onTest.results[], in each per-test JSON file underTest.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;truewhen all pass. Call from a yieldable context (theteststoolbox does) so multi-frame tests yield. Discovering + running every suite is theteststoolbox'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 leastnengine frames elapse.Test.tmpPath(relativePath: string?) -> string— sandboxed path underTest.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 undertemplatefired 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.scenefixture 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, solayers.activeand the scene-scoped surfaces behind it answer inside a test. Returns the loaded root, loading a stand-in scene fromTest.BASELINE_SCENEwhen 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 rowerroris the reason the test stood downTestContext = { 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 renderingTest.describeValuegives 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;cleanedUpIdsmakes the snapshot tolerate deferred cleanup). - After each test the runner defensively returns the scene to
editmode if a body or itsafterEachleakedplay. This stops a single mode-leaking test from cascading silently into every downstreamvfs.write-using suite. - Multi-frame
t.waitFramesneeds a yieldable call chain. A non-yieldable C-frame aboveTest.run(e.g. driving it from inside arequire) makeswaitFramesraise 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. Theteststoolbox (tests.run→AssetRef:run()) runs the suite in a plain coroutine, so the real wait path exercises. toThrowcalls its subject underpcalland fails when the call returns normally.toThrow("substring")additionally requires the rendered raised value to contain that substring.never().toThrow()asserts the call completes.