Test Suite (asset type)
Test suites are the engine's regression net. Every Luau-facing system —
FFI bindings, components, the renderer, the VFS, multiplayer, the input
stack — ships its coverage as one or more .testSuite assets. Because a
suite is an ordinary registrable asset, a new system or package adds its
own tests by dropping a .testSuite folder next to its code; nothing
central (the runner, the shell script, CI) has to be edited.
When to use one
- You added or changed a Luau-facing capability and want a permanent regression guard for it.
- You fixed a bug — add a test that reproduces it so it can't silently
come back (the
bug_regressionsuite is the canonical home for one-test-per-issue guards). - A package or system ships behavior that should be validated on every CI run.
If you just want to manually exercise something once, use execute
directly — don't author a throwaway suite.
Where it lives
- Built-in engine suites:
src/lua/lib/tests/suites/<name>.testSuite/(VFS:/zero/source/libs/@builtin/tests/suites/<name>.testSuite/). - Package / system suites: anywhere under the package, e.g.
…/voxelEngine.package/tests/voxel_grid.testSuite/. - Identity: the folder stem (
strip_suffix: true), e.g.vfs. Suite names must be unique across the whole tree — the runner and CI key on the stem.
Folder shape
<name>.testSuite/
├── init.luau # registers the suite via Test.describe / Test.it
├── README.md # what this suite validates + how to run it
└── .metadata # agent-editable tags + free-form fields
Suites may also ship fixture files (helper .module folders, sample
data) alongside init.luau — allow_unlisted: true permits them.
How to construct a new suite
-
Create
<name>.testSuite/init.luau. Reach the framework the type ships, then declare the suite withTest.describe(name, fn)and each test withTest.it(name, fn). Documentation is authored as doc-comments the engine's doc parser extracts statically: a top-of-file--!descfor the suite, and a--!desc(what the test checks) plus--!pass(the condition that must hold) directly above eachTest.it.--!desc Spawn / despawn / exists / find over the entity API. local Test = asset.containing(__FILE__).modules.shared Test.describe("Entity Lifecycle", function() --!desc entity.spawn(name) creates an entity and returns its proxy. --!pass The returned id is a non-empty string and entity.exists(id) is true. Test.it("spawn returns a stable string id", function() local id = entity.spawn("probe").id Test.expect(type(id)).toBe("string") Test.expect(entity.exists(id)).toBe(true) end) end) return true -
Create
<name>.testSuite/README.mddescribing the suite (what system it covers, notable edge cases, how to run just this suite). -
That's it. No registration step, no runner edit. The next test run discovers the suite via
asset.list("testSuite").
See template/ for a copy-ready skeleton (the templater fills in the
[name] markers).
How to run tests
- All suites (writes
/source/tmp/test_results.md+.json; refused in play mode). A barerun()covers the world-authored suites;"*"adds the baked@builtinlibrary:return tools.use("tests", "run", "*") - One suite directly (instance-direct — runs only this suite):
return asset.resolve("vfs", "testSuite"):run() - From the shell / CI:
./scripts/run_luau_tests.sh(all) or./scripts/run_luau_tests.sh <name>(one).--listprints every discovered suite. - From inside the engine (agent loop): the
teststoolbox —tools.use("tests", "list"),tools.use("tests", "run", { suite = "vfs" }),tools.use("tests", "compare", before, after)to diff two runs and surface NEW failures. See the toolbox README.
Discovery commands
asset.list("testSuite") -- every registered suite, as AssetRef handles
asset.inspect("vfs") -- summary for one suite
asset.resolve("vfs", "testSuite") -- handle { guid, identity, path, type }
tools.use("tests", "list") -- { count, suites = { { name, identity, path, origin, testCount? } } }
Authoring conventions
- One suite per asset. Keep a suite focused on one system; large
systems split into multiple
.testSuiteassets (e.g.zinput_state,zinput_events,zinput_actions). - No
await()insideTest.it(). The framework runs synchronously; for multi-frame behavior use the per-test contextt.waitFrames(n)(see the framework README). - Clean up. Entities the runner can't attribute are despawned
automatically, but prefer
Test.afterEachfor deterministic cleanup. - Write fixtures via
Test.tmpPath. Any file a test writes to the VFS must go under its per-test sandbox:Test.tmpPath("Foo.component/init.luau")→/zero/source/tmp/tests/<suite>/<test>/.... That root is under/source/tmp/(excluded from world saves + multiplayer sync, so it never clogs the synced VFS) and is torn down per-test and again at end-of-run, regardless of pass/fail/crash. Call it INSIDE the test body. For a fixture shared across tests in one block, write underTest.TMP_ROOTdirectly (survives per-test cleanup, still swept at end-of-run). Never hand-roll a bare/source/<name>path — it persists and syncs. - Idempotent. A suite must produce the same result run twice and must not depend on ordering between suites.
- Never silence the LSP. No
--!nocheck/---@diagnostic disablein a suite — fix the underlying type/global instead.
Common pitfalls
- Duplicate stem. Two
.testSuitefolders with the same stem collide; the runner warns and skips the duplicate, and--listfails. Pick a unique name. - Forgetting
return true. Harmless but conventional — every suite ends withreturn true. - Deferred mutations.
despawn/setParent/ scene clears apply next frame; verify their effects witht.waitFramesor a follow-up query, not in the same frame.
Related types
.module— reusable Luau library (a suite often requires the system module under test)..tool— agent-callable function; theteststoolbox tools are.toolassets..component— script component; component behavior is frequently what a suite exercises.
In the Inspector
Tests: how many tests the suite declares and their names, parsed without running it, and Run, which runs this suite alone in edit mode and reports its passed, failed, skipped and quarantined counts.