Skip to content

Testing & QA

Five layers of confidence, cheapest first: specs check your logic, benches check your costs, the fuzzer checks your input handling, the audit checks your configuration, and the simulation modes check the whole kernel under load. All of them run inside Studio with no external tooling.

Create MyThing.spec ModuleScripts next to what they test — shared specs in ChloeKernel/Tests, server-only specs in ChloeKernelServer/Tests (those never replicate to clients):

-- Wallet.spec (ModuleScript — the name must end in ".spec")
local Wallet = require(script.Parent.Parent.Wallet)
return function(T)
local describe, it, expect = T.describe, T.it, T.expect
describe("Wallet", function()
it("starts empty", function()
expect(Wallet.new():balance()).toBe(0)
end)
it("rejects negative deposits", function()
expect(function()
Wallet.new():deposit(-5)
end).toThrow("negative")
end)
end)
end

They run automatically on every Studio play-test boot (Main.server.luau calls TestKit.runSpecs on both containers before the kernel boots) and print one summary line — [TestKit] PASS — 259/259 tests across 23 specs — or a warning per failure with the full describe path and traceback. For a hard gate, assert on the returned results:

local TestKit = require(ReplicatedStorage.ChloeKernel.TestKit)
local Results = TestKit.runSpecs(ReplicatedStorage.ChloeKernel.Tests)
assert(Results.Failed == 0, `{Results.Failed} spec failure(s)`)

Why it works: the runner collects every ModuleScript whose name ends in .spec, requires it, and calls the returned function(T) — each it body runs under xpcall, so one crashing test (or even a crashing describe body or a spec that fails to require) is recorded as a failure instead of killing the run. Matchers: toBe, toEqual (deep), toBeTruthy/Falsy/Nil, toBeType, toBeCloseTo, toBeGreaterThan/LessThan, toContain, toHaveLength, toThrow(pattern?) — all negatable with .never.

Full runner internals, the complete matcher reference, and the kernel-double pattern for testing game services: TestKit & Bench.

Drop a <Thing>.bench ModuleScript into ChloeKernel/Benches:

-- Inventory.bench
local Inventory = require(game:GetService("ReplicatedStorage").Game.Inventory)
return function(bench)
local Items = Inventory.new()
bench("Inventory:add", function(index)
Items:add(index % 50, 1) -- vary inputs so one warm branch isn't all you measure
end)
end

Benches run on demand, not at boot — from the command bar during a play test:

local Bench = require(game.ReplicatedStorage.ChloeKernel.TestKit.Bench)
Bench.runAll(game.ReplicatedStorage.ChloeKernel.Benches)
-- [Bench] Inventory:add 4.20M ops/s p50/op 231ns p99/op 407ns

Why it works: Bench.run executes a 1,000-call warmup batch first (cold-cache effects stay out of the numbers), then times 1,000-call batches until the deadline (default 0.25s). p50/p99 are percentiles over the batch-averaged per-op latency, and ops/sec is total operations over elapsed time — multiply p50 by your per-frame call count to get a frame cost. runAll yields a frame between benches so they don’t contend.

Methodology details and how to read the tail numbers: TestKit & Bench. The framework’s own measured hot-path numbers are on Benchmarks.

The audit (next recipe) reads configuration; the fuzzer exercises handling. Fuzz.run builds hostile argument sets per schema type — empty and 10 KB strings, format specifiers, control bytes, 0, -1, type maxima, NaN, infinities, NaN vector components, nil and nested tables for Any — and drives every registered intent and request through the real server pipeline: hook chain first, handler on pass.

-- Server Bootstrap: fuzz once, as soon as a real session exists
local ServerScriptService = game:GetService("ServerScriptService")
local Fuzz = require(ServerScriptService.ChloeKernelServer.Fuzz)
return function(kernel)
local Fuzzed = false
kernel:onSession(function(session)
if Fuzzed then
return
end
Fuzzed = true
task.defer(function()
local Report = Fuzz.run(kernel, { Session = session, CasesPerChannel = 64 })
assert(#Report.HandlerErrors == 0, `[Fuzz] {#Report.HandlerErrors} handler error(s)`)
end)
end)
end

Output on a clean run: [Fuzz] 12 channels, 768 cases: 741 rejected, 27 passed, 0 handler error(s). Every payload that passed validation and then crashed the handler lands in Report.HandlerErrors with the channel, arguments, and error — a bug in either the validator (let it through) or the handler (couldn’t take it).

Why it works: validators that throw count as rejects — the fail-closed gate holds under fuzzing — so the report isolates exactly the dangerous case: hostile input the gate approved. Runs are deterministic per Seed, filterable with Channels = { "Buy", "Trade" }, and IncludeRequests = false narrows to intents. Pass a real session — a fabricated one makes handlers that read Player.Character report errors no real client could cause. The wire’s typed serialization already bounds what real clients can send; the fuzzer sends past those bounds on purpose (defense in depth). The stock Main.server.luau already wires this exact gate into every Studio play test, so a play test fails loudly the moment any channel mishandles garbage.

Deep dive on the pools, the pipeline, and pairing it with the audit: Fuzz & Audit.

Kernel:securityAudit()
-- [High] UnguardedChannel — intent Grant: has a handler but no validator ...
-- [High] FailOpenGate — hook Intent.Buy: guards a channel but is FailOpen ...
-- [Medium] OpenChannel — intent ReadyUp: any client payload reaches the handler ...
-- [Info] DefaultRateLimit — intent Chat: rides the default rate limit (30/s) ...

For a CI-style gate, take the findings silently and assert. The stock Studio boot blocks on High findings:

task.defer(function() -- post-boot: after every service has registered its channels
for _, Finding in Kernel:securityAudit({ Silent = true }) do
assert(Finding.Severity ~= "High", `security audit: {Finding.Name} — {Finding.Detail}`)
end
end)

Why it works: one sweep over every assumption the security model makes, sorted most severe first. UnguardedChannel (High) — a handler-bearing channel with no validator and no Open flag rejects every payload, which is fail-closed but almost certainly not what you meant. FailOpenGate (High) — a gate hook point flipped FailOpen means a crashing validator passes the payload; gates must stay fail-closed. OpenChannel (Medium) — Open = true is a deliberate escape hatch, but every one must survive hostile input, so the audit makes you look at each. DefaultRateLimit (Info) — the channel never chose a rate limit; set one that matches what the gameplay actually needs. The audit reads configuration only — pair it with the fuzzer above, which exercises the same channels at runtime.

Finding kinds and remediation for each: Fuzz & Audit.

Stress-test the kernel with simulation modes

Section titled “Stress-test the kernel with simulation modes”

The built-in load harness drives real kernel codepaths at game-shaped intensities, so the debug panel and hot-task profiler show where things strain before your game does. Set one attribute and press Play:

-- command bar (or the Properties panel on ReplicatedStorage):
game.ReplicatedStorage:SetAttribute("ChloeKernelSimulation", "HighIntensity")
Mode What it is
HighIntensity Busy shooter-scale server. A modern server clears it green — that is the point.
SmallGame The same rigs at ~1/10 intensity — a baseline to compare against.
Overload Deliberate torture: demands more than a frame, pegs the budget, forces Deferred > 0 and the red panel states. Not a model of anything real.
Showcase Narrated feature tour — six acts cycling through processes, effects, replicas, and Serde, narrated into the panel log tail.

Change the attribute mid-game to hot-swap modes — the old mode tears down completely (processes killed, recurring tasks cancelled, parts destroyed) before the new one starts. An empty string stops. Then open the panel (F8) while it runs: hot tasks name the simulation’s own origins, the budget bar and Deferred grading react, and a 10-second heartbeat logs achieved rates plus GC cost so you can confirm the load is real.

Prefer clicking to typing? The SIM CONTROL window switches server modes live over a validated kernel intent and runs client-side loads on your machine independently, so you can stress both sides at once — see Debug Panels.

Why it works: every rig runs the genuine subsystem — scheduler, processes, hook chains, bus fan-out, replica interest scans, pools — under genuine load; only the wire is faked (simulated channels use fake packet factories on purpose, so a connected client’s real Packet stream can’t be corrupted). It is Studio-only unless ChloeKernelDebug is flagged, and the SimControl intent honors the debug allowlist, so flagging a live server does not hand load controls to every player.

Mode-by-mode intensities, the rig internals, and verification status: Simulation.