TestKit
TestKit is the framework’s built-in spec runner: *.spec ModuleScripts register describe/it blocks, every Studio play-test boot runs them, and runSpecs() returns a Results table so a CI-style gate can assert on the run instead of eyeballing output. The defining decision: specs live inside the place and run against the real modules, so the specs in Tests/ double as executable documentation of every kernel behavior claim.
Mental model
Section titled “Mental model”TestKit.runSpecs(container) walks the container’s descendants and collects every ModuleScript whose name ends in .spec, sorted alphabetically. Each spec module must return a function(T); the runner calls it with a context exposing T.describe, T.it, and T.expect.
Everything is bookkeeping around xpcall:
it(name, body)incrementsTotal, runsbodyunderxpcallwithdebug.traceback, and records a pass or a failure. A failure stores the spec module name, the full path ("Outer > Inner > test name"from the describe stack), and the error with traceback.describe(name, body)pushesnameonto a path stack and runs the body — nesting is just the stack, joined with" > ". A describe body that throws is itself counted as one failure ("... (describe body)"), so a crash while registering tests can never silently skip them.- A spec that fails to
require, or returns something other than a function, is recorded as a failure too ("(require)"/"(load)"). Broken spec files fail the run; they do not vanish from it.
The runner prints one summary line — [TestKit] PASS — 259/259 tests across 23 specs in 41.3ms — or warns per failure, and returns:
export type Results = { Total: number, Passed: number, Failed: number, Failures: { Failure }, -- { Spec, Test, Error } DurationSeconds: number,}Nothing asserts for you. If you want a hard gate, assert on Results yourself (see the boot gate).
Spec file anatomy
Section titled “Spec file anatomy”A spec is a ModuleScript named <Thing>.spec that returns a registration function. Shared specs live in ChloeKernel/Tests (they replicate); server-only specs live in ChloeKernelServer/Tests and never reach clients:
-- TokenBucket.spec — a real spec from ChloeKernel/Tests (excerpted)local TokenBucket = require(script.Parent.Parent.Net.TokenBucket)
return function(T) local describe, it, expect = T.describe, T.it, T.expect
describe("TokenBucket", function() it("allows up to burst immediately, then blocks", function() local Bucket = TokenBucket.new(10, 3, 0) expect(Bucket:take(0)).toBeTruthy() expect(Bucket:take(0)).toBeTruthy() expect(Bucket:take(0)).toBeTruthy() expect(Bucket:take(0)).toBeFalsy() end)
it("refills continuously at the configured rate", function() local Bucket = TokenBucket.new(10, 1, 0) expect(Bucket:take(0)).toBeTruthy() expect(Bucket:take(0.05)).toBeFalsy() -- 0.5 tokens accrued expect(Bucket:take(0.1)).toBeTruthy() -- 1.0 token accrued end)
it("never accumulates past the burst cap", function() local Bucket = TokenBucket.new(100, 2, 0) expect(Bucket:take(60)).toBeTruthy() -- a minute idle still yields only 2 expect(Bucket:take(60)).toBeTruthy() expect(Bucket:take(60)).toBeFalsy() end) end)endNote what this spec does not do: it never calls task.wait. TokenBucket:take(dt) accepts elapsed time as a parameter, so the spec injects a minute of idle time in zero real milliseconds. That is the house style — design time as an input, and the suite stays fast.
The framework’s own specs open with --!nonstrict and a one-line reason: spec doubles are partial, and strict mode would demand fully typed mocks. The framework itself stays --!strict.
The matcher surface
Section titled “The matcher surface”T.expect(value) returns an expectation object. This is the complete surface of TestKit/Expect.luau:
| Matcher | Passes when |
|---|---|
toBe(expected) |
value == expected (raw equality, no coercion) |
toEqual(expected) |
deep equality — key-wise both directions, with a cycle guard |
toBeTruthy() |
value is truthy |
toBeFalsy() |
value is falsy |
toBeNil() |
value == nil |
toBeType(name) |
typeof(value) == name |
toBeCloseTo(expected, epsilon?) |
number within epsilon (default 1e-6) of expected |
toBeGreaterThan(expected) |
number > expected |
toBeLessThan(expected) |
number < expected |
toContain(expected) |
string contains the substring (plain find), or array contains the value (table.find) |
toHaveLength(expected) |
#value == expected for strings and arrays (anything else reads as -1) |
toThrow(pattern?) |
value must be a function; it errors, and the error matches pattern if given |
.never |
negates any matcher: expect(x).never.toBe(y) |
Failure messages stringify both sides (tables preview up to 8 entries); .never rewrites expected to expected NOT in the message. Details worth knowing:
toEqualguards cycles: a pair of tables already being compared counts as equal instead of recursing forever. Kernel objects hold back-references (Process.Scheduler), and deep-comparing them must terminate.toThrow(pattern)matches withstring.findin Lua pattern mode — escape magic characters (%-,%(…) when asserting on punctuation.toContainon strings uses a plain find — no patterns there.
Yielding and async work
Section titled “Yielding and async work”it bodies run under xpcall, which in Luau tolerates yields — a body that calls task.wait does not error, it stalls. The whole suite runs serially on the calling thread, and on Studio boot that thread is the server’s boot path, so every second a spec sleeps is a second added to your play-test startup. Results.DurationSeconds is wall clock and will tell on you.
The framework’s answer is the TokenBucket pattern above: pass elapsed time, timestamps, and random seeds as parameters so behavior is testable without waiting. For code driven by the Scheduler, call step() directly in the spec rather than waiting for real frames.
How suites run on Studio boot
Section titled “How suites run on Studio boot”Main.server.luau runs both containers before the kernel boots, in Studio only, and hard-fails the play test on any failure — the same gate the security audit and fuzzer apply to their own findings:
if RunService:IsStudio() then local TestKit = require(ReplicatedStorage.ChloeKernel.TestKit) local Shared = TestKit.runSpecs(ReplicatedStorage.ChloeKernel.Tests) local Server = TestKit.runSpecs(script.Parent.ChloeKernelServer.Tests) assert(Shared.Failed == 0, `{Shared.Failed} shared spec failure(s) — see the TestKit report above`) assert(Server.Failed == 0, `{Server.Failed} server spec failure(s) — see the TestKit report above`)endBoth containers execute in the server VM — shared specs live in ReplicatedStorage but run at server boot alongside the server-only ones. Production servers never run specs; the gate is RunService:IsStudio().
runSpecs itself never asserts — the hard gate is Main.server’s two assert lines on the returned Results, so a CI script or one-off run that wants to warn-and-continue just calls runSpecs directly and reads Results.Failed itself.
The same boot window also runs the security audit and channel fuzzer in Studio — specs check your logic, the audit checks your configuration, the fuzzer checks your input handling.
Writing specs for game services
Section titled “Writing specs for game services”Game services touch the kernel — hooks, bus, sessions, net channels. The pattern the framework’s own server specs use is a partial kernel double: real HookRegistry and Bus (they are pure Luau, cheap, and you want their real semantics), fake everything else. This is how NetDriver.spec.luau builds its world:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local HookRegistry = require(ReplicatedStorage.ChloeKernel.Hooks.HookRegistry)local Bus = require(ReplicatedStorage.ChloeKernel.IPC.Bus)
local function makeFakeKernel() local FakePlayer = { Name = "TestPlayer" } :: any local Kernel = { Hooks = HookRegistry.new({ WarnOnError = false }), Bus = Bus.new(), Sessions = {}, } function Kernel.getSession(self: any, player) return self.Sessions[player] end Kernel.Sessions[FakePlayer] = { Player = FakePlayer, Data = {} } return Kernel, FakePlayerendWith that double, a spec drives the real validation pipeline end to end — here is a real test from the same file, exercising an intent through its fail-closed hook chain:
it("rejected intents never reach the handler", function() local Driver, Kernel, Player, Created = makeDriver() local HandlerRan = false Driver:defineIntent("Buy", {}, { Handler = function() HandlerRan = true end, }) Kernel.Hooks:on("Intent.Buy", function() return false end) local Rejected = nil Kernel.Bus:subscribe("Net.IntentRejected", function(_, _player, channel) Rejected = channel end)
Created.CKI_Buy.SimulateServerEvent(Player) expect(HandlerRan).toBeFalsy() expect(Rejected).toBe("Buy") expect(Driver.Stats.IntentsRejected).toBe(1)end)WarnOnError = false on the HookRegistry matters: specs deliberately feed validators inputs that throw, and the registry would otherwise warn on every one. The PacketFactory seam (NetDriver.new(Kernel, { PacketFactory = Factory, SkipFloodWiring = true })) swaps real remotes for fakes with a SimulateServerEvent method — no wire, no yielding, hostile payloads on demand.
Apply the same recipe to your own services: construct the service against the double, fire its hook points and bus topics directly, and assert on the observable outcomes — handler calls, bus publishes, session data mutations. The Tests/ directories hold thirty-plus worked examples; Registry.spec.luau, Session.spec.luau, and Kits.spec.luau are good next reads.
API reference
Section titled “API reference”| Member | Description |
|---|---|
TestKit.runSpecs(container: Instance): Results |
Runs every *.spec ModuleScript descendant (sorted by name); prints a summary; returns Results. |
T.describe(name: string, body: () -> ()) |
Groups tests; nests via an internal path stack. A throwing body counts as one failure. |
T.it(name: string, body: () -> ()) |
One test. Pass/fail recorded under the full describe path. |
T.expect(value: any) |
Builds an expectation — see the matcher surface. |
Results |
{ Total, Passed, Failed, Failures = { { Spec, Test, Error } }, DurationSeconds } |
Gotchas
Section titled “Gotchas”For performance benchmarking (not correctness testing), see Bench.