Writing tests
The Luau spec DSL, matchers, isolation and the CLI.
Writing tests
MicroStudio ships a test framework for Luau. It is shaped like vitest or jest, with one difference that matters: the assertions are written in Luau, and they run inside the same world as the code under test. A roblox-ts project can write them in TypeScript instead, compiled by the project's own compiler before they run.
-- src/scoreboard.spec.luau
local Scoreboard = require(game.ReplicatedStorage.scoreboard)
describe("Scoreboard", function()
local board
beforeEach(function()
board = Scoreboard.new():track()
end)
it("notices a player joining", function()
addPlayer("Eddie")
expect(board.joined).toBe(1)
end)
it("refuses an unknown player", function()
expect(function()
board:score("Nobody", 1)
end).toThrow("no entry for Nobody")
end)
end)microstudio test examples/testing✓ examples\testing\src\scoreboard.spec.luau (6 tests, 74ms)
✓ starts empty 1ms
✓ notices a player joining 0ms
✓ scores a known player 0ms
✓ refuses an unknown player 0ms
✓ keeps tests in separate worlds 0ms
☐ resets at the end of a round
Test files 1 passed (1)
Tests 5 passed | 1 todo (6)
Assertions 9
Duration 77msSpecs are *.spec.luau / *.test.luau (and the .lua spellings), found by
directory walk. A spec inside a Rojo mount is just as good as one outside it: a
.luau file becomes a ModuleScript, which dev never runs.
Why it is not a JavaScript test runner
A test() that wraps each assertion in an RPC would give up the things that make
a test worth writing:
itcan yield.task.wait(1),part.ChildAdded:Wait()andPlayers.PlayerAdded:Wait()all work inside a test, because a test body is just a chunk on the same scheduler as the game.- A test sees the real world.
require, services, signals, datatypes and the scheduler are the runtime's own, not a mock.expect(part).toBeA("BasePart")is a realIsAon a real instance. - A failure is a value, not a crash. Assertions fail with
error, which the framework catches withxpcall, so the world, the other tests and the run survive it. - Time is virtual by default.
task.wait(0.5)costs microseconds, and the same spec produces the same result every time.
The DSL
Everything is a global, injected before the spec loads.
describe(name, fn) / describe.skip / describe.only | a suite, nestable |
it(name, fn) / test(name, fn) | a test |
it.skip(name, fn) | reported as skipped, body never runs |
it.only(name, fn) | only the marked tests run |
it.todo(name) | reported as a todo, no body |
beforeAll / afterAll / beforeEach / afterEach | hooks for the enclosing suite |
expect(value[, message]) | the matcher chain |
reset() | replace the world with an empty one |
addPlayer([name]) | make a mock player join, firing PlayerAdded |
beforeEach runs inside the test's own world, so a fixture is built before each
test it serves. A setup hook that fails fails the test without running its
body, which keeps a broken fixture from looking like a broken feature.
Matchers
expect(1 + 1).toBe(2) -- identity
expect({ a = 1 }).toEqual({ a = 1 }) -- structural, reports the first key that differs
expect(0.1 + 0.2).toBeCloseTo(0.3) -- 2 decimal places by default
expect(true).toBeTruthy() -- also toBeFalsy, toBeNil
expect(part).toBeA("BasePart") -- IsA, so inherited classes count
expect(#list).toBeType("number") -- typeof
expect(list).toHaveLength(3) -- also toContain, toHaveProperty
expect("concatenated").toMatch("cat") -- Lua pattern, or a plain substring
expect(f).toThrow("nope") -- calls f, expects it to error
expect(3).toBeGreaterThan(2) -- also ...OrEqual, and toBeLessThan[OrEqual]Negation is spelled never, with isNot as an alias:
expect(2).never.toBe(3)
expect(nil).isNot.toBeTruthy()expect(x).not.toBe(y) does not compile: not is a Lua keyword, and a keyword
cannot follow a ..
A second argument to expect names the expectation, which a CI log will thank you
for:
expect(#players, "the mock player must have joined").toBe(1)
-- spec.luau:4: the mock player must have joined: Expected 0 to be 1Failures
A failure names the spec, the line, and what was compared. A call chain is added when there is one worth showing, and anything the test printed comes last:
✗ src\wallet.spec.luau (3 tests, 16ms)
✗ rejects a negative amount 0ms
src\wallet.spec.luau:10: Expected 90 to be 70
✗ spends through a helper 0ms
src\wallet.spec.luau:15: balance is short
at src\wallet.spec.luau:15 function spend
at src\wallet.spec.luau:18 function spendAll
at src\wallet.spec.luau:20
✗ prints while it works 0ms
src\wallet.spec.luau:25: Expected 1 to be 2
checking the ledger
Test files 1 failed (1)
Tests 3 failed (3)
Assertions 2
Duration 20msThe framework's own frames are filtered out, so every line is a line you wrote.
Isolation
Each spec file gets its own runtime and its own world, so one file cannot leak
instances, listeners or require cache entries into another.
Inside a file, the world persists between tests unless you say otherwise — which
is the fastest thing to run, and the reason beforeEach builds fixtures into it.
Three ways to get a clean world:
it("starts from nothing", function()
reset() -- this test: throw the world away
expect(#workspace:GetChildren()).toBe(0)
end)microstudio test --isolate # every test: rebuild the world from the project
microstudio test --watch # re-run the whole suite when a .luau file changes--isolate resets the world and re-materialises the project's instances before
each test, so each test starts from a freshly loaded place rather than an empty
one. beforeAll still runs once per suite: put world-building in beforeEach.
The CLI
microstudio test [dir] [--project <file>] [--include <glob>] [--exclude <glob>]
[--module path=file] [--isolate] [--timeout <ms>] [--json]
[--watch] [--tsconfig <file>] [--no-compile] [--state-dir <path>]| Flag | Meaning |
|---|---|
[dir] | where to look for specs, and the directory a Rojo project is searched from. Defaults to the working directory. |
--include / --exclude | comma-separated globs, replacing the defaults (**/*.spec.luau, **/*.spec.lua, **/*.test.luau, **/*.test.lua, and the .ts/.tsx spellings of those) |
--module | inject a ModuleScript from a file: --module ReplicatedStorage.Counter=src/counter.luau. Repeatable by comma. This is how a box of Luau with no Rojo project gets something to require. |
--project | the Rojo project to read, when it is not found automatically |
--services | comma-separated services to mount when loading the project's instances; defaults to the server-side set |
--isolate | reset the world before every test |
--timeout | per-test milliseconds; 5000 by default, 0 to disable |
--json | the whole run as JSON: { summary, files } |
--watch | re-run everything when a .lua/.luau/.ts file in the tree changes, recompiling TypeScript specs |
--tsconfig | the config a TypeScript spec is compiled with; tsconfig.json by default |
--state-dir | where simulated services keep their JSON; a project defaults to <project>/.microstudio, and a run with no project keeps it in memory |
--no-compile | do not run the project's rbxtsc: run the output of a build you ran yourself |
A spec shares that state directory with the project, so a test can seed a data store, a badge or an asset and then assert on it — see Simulated services.
The exit code is 0 when every test passed (or was skipped, or is a todo) and 1
otherwise, so it drops straight into CI. No spec files found is also 1; a
compile or configuration failure is 2.
TypeScript specs in a roblox-ts project
A spec in TypeScript is an ordinary module, so it lives next to the code it tests:
// src/scoreboard.spec.ts
import { Scoreboard } from "./scoreboard";
describe("Scoreboard", () => {
it("counts a goal", () => {
const board = new Scoreboard();
board.score("Eddie", 1);
expect(board.entries.get("Eddie")).toBe(1);
});
});microstudio test compiles it with the project's own compiler — roblox-ts is
never replaced or bundled — then runs the result:
- the project's
tsconfig.jsonis read; .microstudio/tsconfig.test.jsonis written, extending it and adding the framework's global declarations toinclude, so the DSL typechecks without the project configuring anything;rbxtscruns with that config, the project file and therbxts_includefolder, writing Luau into the project's ownoutDir;- each compiled spec is run from its instance in the loaded project.
Three things are worth knowing:
- A failure points at the compiled line. roblox-ts 3 emits no source maps, so
the reported line is in the compiled Luau; the suite is named after the
.tsfile you edited, and the traceback names the instance. never, notnot. Luau cannot have a.notfield —notis a keyword — so the negated chain isnever, withisNotas an alias, in TypeScript too.scriptis bound while the spec loads. roblox-ts's runtime library keys its module cache byscript, so the spec is run as its own instance and test bodies run afterwards wherescriptis nil. Import at the top of the spec, which is what the compiler emits anyway.
--no-compile runs what a separate build already wrote, and --tsconfig <file>
reads a config other than tsconfig.json. A spec has to live inside the project's
rootDir to be compiled at all — the same rule as any other module.
From TypeScript
Two entry points from @microstudio/test, for when the test itself is a
TypeScript test:
npm install --save-dev @microstudio/test # or: microstudio, for the cli as wellluaTest runs a Luau snippet as one test:
import { test } from "node:test";
import { luaTest } from "@microstudio/test";
test("the counter starts at zero", async () => {
await luaTest("starts at zero", "expect(Counter.new().value).toBe(0)", {
modules: { "ReplicatedStorage.Counter": counterSource },
});
});The snippet is the body of the test, so it can expect, task.wait and call
anything the world provides. A failure throws, which is how the surrounding
runner reports it.
runSuite runs a whole spec from a string:
import { runSuite, summarize, formatSummary, formatSuite } from "@microstudio/test";
const result = await runSuite({
source: specSource,
file: "counter.spec.luau",
place: placeEntries, // optional: instances to materialise first
modules: { "ReplicatedStorage.Counter": counterSource },
isolate: false,
timeoutMs: 5000,
});
for (const line of formatSuite(result)) console.log(line);
console.log(formatSummary(summarize([result]), { color: false }).join("\n"));| Option | Meaning |
|---|---|
source | the spec, as Luau |
file | how the spec is named in failures; defaults to [string] |
place | PlaceEntry[] materialised before the spec loads |
modules | ModuleScripts created from source, keyed by path (ReplicatedStorage.Counter or ReplicatedStorage/Counter) |
isolate / timeoutMs | as the CLI flags |
stateDir | where simulated services keep their JSON, for a spec that seeds its own |
runtimeFactory | how to boot the runtime, for a caller that configures the sidecar |
onResult | called as each test finishes, for streaming progress |
runSpecFiles(files, options) runs a list of files the same way, and
summarize, formatSuite, formatSummary and renderJsonReport turn the
results into the CLI's output.
How it works
Worth knowing when something behaves oddly:
- The framework is Luau. The runner injects it as a chunk named
[framework]and loads the spec as another chunk named after the file, which is why failure locations read like paths. - The runner drives one test per request.
__microstudio_test_plan()lists the tests;__microstudio_test_run(index)runs exactly one, including its hooks. Between two calls the runner can reset the world, which is what makes--isolatepossible. - Bookkeeping lives in the Lua registry, not the world, so
reset()cannot lose the list of tests still to run. - A timeout replaces the runtime. Luau cannot be interrupted from outside, so a test that never finishes is detected, not stopped: the runtime is disposed, a new one is booted, the framework and spec are reloaded, and the run continues with the next test. Hooks re-run from scratch after that, which is the honest trade.
- A background failure is a test failure. An error raised in a thread the test spawned (or a connection it made) is picked up from the runtime's error stream and fails the test that caused it.
printis captured, not lost. Output is attached to the test that produced it and shown under a failure. A failing assertion never reaches the error stream, so a failing run does not look like a crashing one.