A zero-setup, AI-first, Jest-style unit testing framework for Roblox Luau. The framework is a single folder of plain Luau with no dependencies, and the same core runs inside Roblox Studio and under Lune on CI. Alongside the human docs, the repo ships an agent skill so that a coding agent can write and run specs correctly from the start.
local TasteTest = require(game.ReplicatedStorage.TasteTest.Start)
local describe, it, expect = TasteTest.describe, TasteTest.it, TasteTest.expect
describe("math", function()
it("adds", function()
expect(1 + 1).toBe(2)
end)
end)Setup is the main difference. Copy the folder into ReplicatedStorage, write a ModuleScript named Something.spec, and call runAll; there is no project file or package manager involved. The AI-first part is SKILL.md, an agent-facing guide in the Agent Skills format, so a coding agent can write specs, run suites, and debug failures without reverse-engineering the API from source.
How it compares with the alternatives:
| TasteTest | Jest Lua (jest-roblox) | TestEZ | busted / LuaUnit | |
|---|---|---|---|---|
| Install | copy one folder | Wally/Rojo package tree | Wally/Rojo or model | LuaRocks |
| Dependencies | none | many (Jest 27 port) | none | LuaRocks ecosystem |
| Runs in plain Studio, no toolchain | yes | no | possible but awkward | no (no Roblox API) |
| CI without Roblox Studio | yes (Lune) | no (needs run-in-roblox) | no | yes (plain Lua) |
| Jest API surface | the practical subset | nearly all of Jest 27 | older expect style | xUnit style |
| Yield-safe tests with timeouts | yes | yes | partial | n/a |
| Built-in warning gate (fail on unexpected warn) | yes | no | no | no |
| Bundled AI agent skill | yes | no | no | no |
| Battle-tested at scale | runs the whole test suite of a Roblox game in active development | all of Roblox's internal apps | legacy, maintenance mode | huge, outside Roblox |
Use Jest Lua instead if you need snapshot testing, module mocking (jest.mock), or fake timers, or if your team wants to standardize on Roblox's internal tooling. TasteTest leaves those features out so that it can stay a drop-in folder.
Because you copy the folder in, the copy in your place is yours. Nothing upstream can break your tests, and the modules are short enough to fix yourself if you ever need to.
Two ways, depending on how you work:
- Studio only: download
TasteTest.rbxmfrom the latest release and drag it intoReplicatedStorage. It contains the framework and its own test suite. - Filesystem (Script Sync, Rojo): copy the
TasteTestfolder into the directory that maps toReplicatedStorage.
Nothing else needs to be installed or configured.
local TasteTest = require(game.ReplicatedStorage.TasteTest.Start)Note on naming: the entry module is called Start instead of the conventional init because Studio Script Sync does not honor the Rojo init.luau folder-collapse convention. A sibling module keeps the folder layout working in both worlds.
A spec is a ModuleScript whose name ends in .spec. It returns a single function that registers tests:
-- MyThing.spec (ModuleScript)
return function()
local TasteTest = require(game.ReplicatedStorage.TasteTest.Start)
local describe, it, expect = TasteTest.describe, TasteTest.it, TasteTest.expect
local MyThing = require(script.Parent.MyThing)
describe("MyThing.doStuff", function()
it("returns 1 for empty input", function()
expect(MyThing.doStuff({})).toBe(1)
end)
it("rejects nil", function()
expect(function() MyThing.doStuff(nil) end).toThrow()
end)
end)
endSpecs do not need to be registered anywhere. Inside Roblox the runner walks a root Instance and picks up every descendant ModuleScript named *.spec.
Inside Roblox (command bar, a runner Script, or StudioTestService):
local TasteTest = require(game.ReplicatedStorage.TasteTest.Start)
local verdict = TasteTest.runAll(game.ReplicatedStorage.MyModules)
print(verdict.ok, verdict.passed, verdict.failed)Outside Roblox (Lune, CI), pass the specs as an explicit list plus a scheduler:
local task = require("@lune/task")
local TasteTest = require("./TasteTest/Start")
local verdict = TasteTest.runAll({
{ name = "MyThing.spec", fn = require("./MyThing.spec") },
}, { wait = task.wait })This repository tests itself that way on every push (see .github/workflows/ci.yml and Tests/RunInLune.luau).
| Field | Meaning |
|---|---|
ok |
true when no test failed, no beforeAll/afterAll hook threw and at least one test ran |
focused |
true when a .only was registered |
passed, failed, skipped, totalTests |
counts |
durationSec |
suite duration (CPU time via os.clock; yield-heavy suites under-report) |
failures |
list of { kind, path, file, error } with composed describe > it paths; kind is test, placeholder, beforeAll, noTests, focus or warningGate |
beforeAllErrors, afterAllErrors |
hook errors (they make ok false without failing individual tests) |
warnings |
registration-time warnings, for example a bad it.each name template |
capturedWarnings, expectedWarnings, unexpectedWarnings, warningsClean |
the warning gate (below) |
runAll(root, options) takes these options, all optional:
| Option | What it does |
|---|---|
wait |
The function used to wait while a test yields. Inside Roblox it defaults to task.wait. |
timeout |
How many seconds a test, beforeAll or afterAll may take. Default 5. |
expectedWarnings, strictWarnings |
The warning gate, see below. |
capturedWarnings |
Warnings collected by the caller, for runs outside Roblox. |
passWithNoTests |
Allow a run with no tests. Without it, an empty run fails. |
forbidOnly |
Fail the run if any test or block uses .only. Useful in CI. |
filter |
A function that gets each test's describe > it path and returns true to run it. |
Only one runAll can run at a time.
Each entry in verdict.failures has the full describe > it path and a message that says what went wrong. An assertion failure starts with the spec line that asserted and ends with a --TRACE-- block. Deep-equality failures name the first differing path inside the compared tables, a hung test reports its timeout instead of freezing the suite, and the warning gate reports what it caught:
Mining.calculateDrop > applies the luck bonus
ReplicatedStorage.Mining.spec:14: expected deep-equal: at drops.bonus: expected 2, got 1 (actual={base=1, bonus=1} expected={base=1, bonus=2})
--TRACE--
ReplicatedStorage.TasteTest.matchers:296
ReplicatedStorage.Mining.spec:14
SellSystem > pays out after the remote round-trip
timed out after 5.0s
(warning gate)
1 unexpected warning(s):
Infinite yield possible on 'ReplicatedStorage:WaitForChild("Remotes")'
Warnings often point at real problems that a green test count hides, so TasteTest counts them as part of the result. Every warning printed during the run is compared with your list of expected warnings (plain text matching, with an optional reason). With strictWarnings = true, any warning that is not on the list fails the run with a (warning gate) entry in failures.
local verdict = TasteTest.runAll(root, {
strictWarnings = true,
expectedWarnings = {
{ pattern = "DataStore budget", reason = "throttling is expected in Studio" },
},
})Inside Roblox this happens by itself through LogService, so engine warnings such as a bad asset id are caught too. In Lune, your entry script replaces warn with a function that collects the messages and passes the list as capturedWarnings; Tests/RunInLune.luau shows how.
| API | Behavior |
|---|---|
describe(name, fn) |
Group block. Names compose into outer > inner > test paths in failure output. |
describe.skip(name, fn) |
Skip every test inside the block. |
describe.only(name, fn) |
Run only this block (and other .only blocks). Everything else is reported as skipped. |
describe.each(rows)(nameFmt, fn) |
One describe per row; .skip and .only variants. |
it(name, fn, opts?) |
Register a test. opts = { timeout = seconds } raises the per-test timeout for yielding tests. The name must be a string. |
it.skip(name, fn) |
Skip this test. |
it.only(name, fn) |
Run only this test (and other .only tests). |
it.failing(name, fn) |
A known bug kept visible: passes while the body fails, fails once the body passes; a failing cleanup still fails it. |
onTestFinished(fn) |
Called inside a test: fn runs after the test and its afterEach hooks, in reverse registration order, even when the test failed. Register a restore right where you patch. |
spyOn(target, key, impl?) |
Called inside a test or per-test hook: replaces target[key] with a recording spy (calling impl, or the original when omitted) and restores the original when the test finishes. |
it.each(rows)(nameFmt, fn) |
One test per row; .skip, .only and .failing variants. |
beforeAll(fn) / afterAll(fn) |
Run once per enclosing describe, under the suite timeout. afterAll always runs, even when beforeAll failed. A block whose tests are all excluded by focus, skip or filter skips both. |
beforeEach(fn) / afterEach(fn) |
Run around every test in the enclosing describe and its descendants. |
Hook ordering for nested describes:
outer.beforeAll > inner.beforeAll > outer.beforeEach > inner.beforeEach
> test body > inner.afterEach > outer.afterEach > inner.afterAll > outer.afterAll
Tests and nested describes run in declaration order. If a beforeAll throws, the tests in its scope are marked failed (not skipped) with the message beforeAll failed: <error>. afterEach always runs, even when the test failed or timed out, and an afterEach failure fails the test without masking the test's own error. Registration closes when the run starts: calling it, describe or a hook from inside a test raises.
Each test and each hook runs in its own coroutine with a timeout. A test that yields (task.wait, WaitForChild, a remote round-trip) is checked once per frame, and if it never finishes it fails with timed out after Ns instead of freezing the run. A loop that never yields cannot be stopped this way. An error thrown after a yield still counts against the right test, and a check that fails on another thread (a signal handler or task.spawn) fails the test that is running.
it("waits for the round-trip", function()
local result = thing.requestAndWait()
expect(result.ok).toBeTruthy()
end, { timeout = 30 })expect(actual).toBe(expected) -- strict equality (==)
expect(actual).toEqual(expected) -- deep equality by keys/values (metatables ignored), diff in the failure message
expect(actual).toMatchObject(subset) -- partial equality, extra fields in actual are fine
expect(actual).toBeCloseTo(expected, numDigits) -- float comparison, numDigits (whole decimal places) defaults to 10
expect(actual).toBeTruthy()
expect(actual).toBeFalsy()
expect(actual).toBeNil() -- actual == nil, the failure shows the value
expect(actual).toBeDefined() -- actual ~= nil (false counts as defined)
expect(instance).toHaveAttribute(name, value) -- GetAttribute(name) == value; omit value to check presence
expect(actual).toBeGreaterThan(n)
expect(actual).toBeGreaterThanOrEqual(n)
expect(actual).toBeLessThan(n)
expect(actual).toBeLessThanOrEqual(n)
expect(list).toContain(item) -- array membership via deep equality (tables only)
expect(fn).toThrow(optionalLuaPattern)
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledTimes(n)
expect(spy).toHaveBeenCalledWith(...) -- deep equality on args, nil-aware by position
expect(actual).never.toBe(forbidden) -- .never negates any matcher
expect.soft(actual).toBe(expected) -- records a failure and keeps going; the test fails at the end
expect.assertions(n) -- the test fails unless exactly n assertions ran
expect.hasAssertions() -- the test fails if no assertion ran (callbacks that never fired)toEqual and toMatchObject failures name the path of the first difference, for example at a.b.c: expected 2, got 1 or at b: missing, expected 2.
TasteTest.addMatcher("toBeEven", function(actual)
if type(actual) ~= "number" then return false, "expected a number" end
if actual % 2 ~= 0 then return false, string.format("expected %d to be even", actual) end
return true, "is even"
end)
expect(4).toBeEven()A matcher gets (actual, ...args) and returns ok, a message (a string, or a function that builds the string), and optionally a third value. Every matcher works with .never automatically. Return true as the third value when the matcher was used the wrong way, for example with the wrong type of value: the check then fails both with and without .never, instead of .never turning the mistake into a pass. To replace a built-in matcher, call addMatcher(name, fn, { override = true }). TasteTest.matcherUtils gives you equals, diff, format and formatError, the same helpers the built-in matchers use.
-- Array rows, positional access via $1, $2, ...
it.each({
{ 1, 1, 2 },
{ 2, 3, 5 },
})("$1 + $2 = $3", function(a, b, sum)
expect(a + b).toBe(sum)
end)
-- Dict rows, named access via $key
it.each({
{ case = "happy", input = "x" },
{ case = "edge", input = "" },
})("scenario: $case", function(row)
expect(transform(row.input)).toBeTruthy()
end)Modifiers propagate to all rows: it.each(rows).skip(...), .only(...), .failing(...).
TasteTest.spy(impl?) returns two values: a callable table with a .calls record, and a plain function bound to the same record. Use the plain function where consuming code does type(x) == "function" checks; use the table with the spy matchers.
local spy, spyFn = TasteTest.spy()
spy("a", 1)
spyFn("b", 2)
expect(spy).toHaveBeenCalledTimes(2)
expect(spy).toHaveBeenCalledWith("a", 1)Each entry in .calls is a table.pack result ({ n = 2, "a", 1 }), so explicit nil arguments are positional and countable: spy.calls[1].n is the argument count. toHaveBeenCalledWith accounts for this; manual asserts should index positionally (spy.calls[1][1]).
For per-call return sequences, pass an impl closure that closes over an index. There is intentionally no mockReturnValueOnce chain API.
TasteTest.spyOn(module, "fn", impl?) swaps module.fn for a spy during the running test and returns the spy. Without impl the spy calls the original, so behavior stays the same and the calls are only recorded. The original comes back when the test ends, whether it passed or failed. spyOn refuses frozen tables, fields that are not functions, and calls made outside a test or a per-test hook.
local notify = TasteTest.spyOn(NotificationHub, "notify", function() end)
banner:show(item)
expect(notify).toHaveBeenCalledTimes(1)TasteTest.strictTable(name, knownKeys, factory) makes a fixture factory. Reading a key that is not in knownKeys raises an error, and so does passing an unknown key as an override. A typo in a test fixture then fails right where it is, instead of causing a confusing failure somewhere else.
local makePlayerData = TasteTest.strictTable("playerData", {
coins = true, inventory = true, level = true,
}, function(overrides)
return {
coins = overrides.coins or 100,
inventory = overrides.inventory or {},
level = overrides.level or 1,
}
end)
local d = makePlayerData({ coins = 500 })
print(d.coins) -- 500
print(d.unknown) -- error, names the factory and the bad key
makePlayerData({ coinz = 1 }) -- error at construction, unknown override keyEscape hatches: raw assignment and pairs() iteration bypass the strict read check.
Which entry point an agent needs depends on where it lives:
Studio's built-in Assistant (or any AI working inside the place) cannot read repository files, so the install includes the guide as a ModuleScript: ReplicatedStorage.TasteTest.GUIDE. Start a session with a prompt like:
Read ReplicatedStorage.TasteTest.GUIDE and follow it. Then write TasteTest specs for ReplicatedStorage.MyModules.Inventory and run them.
The Assistant reads the spec recipe, the matcher list, and the known mistakes from the module, writes the .spec files next to your code, and runs the suite from the command bar.
Agents that work with files (Claude Code, Cursor and similar) get skills/tastetest, a guide in the Agent Skills format. It shows how to write a spec, how to run it in Roblox and in Lune, how to read the verdict, and the most common mistakes. The folder stands on its own, so you can copy it into an agent's skills folder (for Claude Code, .claude/skills/tastetest/) without the framework code. Its references/ folder has the detailed API pages (matchers, spies and fixtures, debugging), which are also the reference docs for people.
The Tests/ folder contains TasteTest's own spec suite. Inside Roblox, point runAll at the folder Instance; outside, lune run Tests/RunInLune.luau does it (this is what CI runs).
TasteTest/
Start.luau public API entry, require this
runner.luau runAll and the verdict
registry.luau describe/it/hooks and spec collection
executor.luau running tests and hooks, watchdog timeouts
capture.luau warning and error capture, warning classification
context.luau per-test assertion state and thread ownership
matchers.luau expect(), matcher registry, failure diffs
format.luau value and error formatting
spy.luau call recorder
spyOn.luau swaps a module function for a spy during one test
strictTable.luau strict fixture factory
GUIDE.luau in-place agent guide for Studio assistants
skills/tastetest/ agent-facing guide (Agent Skills format) and references
Tests/ the framework's own specs, SelfCheck, Suite and the RunInLune.luau entry
Tools/ release build and verification
See CONTRIBUTING.md for the scope rules, development setup, and test requirements for changes.
MIT, see LICENSE.