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.