Start typing to search packages!
luau-spec
By @hurreman
Roblox
Mirrored from Wallyluau-spec
One spec runner for hurreman's Roblox games. The same spec files run:
- inside Roblox, in Studio or a test place (
Spec.Run); - offline under Lune, with no Studio (
lune/).
The offline side loads your real modules with Roblox-style requires resolved through your Rojo project file. It provides real Roblox datatypes, and fakes for the rest.
# wally.toml
[dev-dependencies]
Spec = "hurreman/luau-spec@0.1.0"
Writing specs
A spec is a ModuleScript or file named <Name>.spec.luau (or .spec.lua). It may return a function in either of two styles.
Suite style. Each t.it is a test:
return function(t)
local Format = t.env().shared("Format") -- offline only; see below
t.describe("Format.number", function()
t.it("abbreviates thousands", function()
t.expect(Format.number(1234)).toBe("1.23K")
end)
end)
end
Single-test style. The whole function is one test, which fails if it raises. This is Slime Nebula's existing format:
return function()
local Model = require(game:GetService("ReplicatedStorage").ClientModules.UI.ActionHintModel)
assert(Model.Build(nil) == nil)
end
What t provides
Both runners:
-
t.describe(name, fn)andt.it(name, fn) -
t.expect(value)with these matchers:toBe,toEqual(deep),toBeCloseTo(n, eps?)toBeTruthy,toBeFalsy,toBeNil,toBeType(name)toBeGreaterThan,toBeLessThantoContain(a substring, or a deep-equal item of a list)toThrow(text?)(plain text first, then as a Lua pattern)
Both
expect(x).toBe(y)andexpect(x):toBe(y)work. -
t.fakePlayer(userId, name)andt.fakeInstance(name): tables with attribute storage.t.fakeSignal(): a synchronous signal withFireandConnectionCount.
Lune runner only:
t.env(extra?): a fresh, isolated module set; see Env.extraglobals, services and aliases merge over the runner's (e.g.t.env({ services = { PhysicsService = recorder } })).t.utc(y, m, d, hour?): Unix time of UTC midnight on that date.t.Random: a seeded stand-in for Roblox'sRandom.
Your own fields: anything you pass as extras also appears on t.
In Studio
-- ServerScriptService/Tests/RunAllTests.server.lua
local RunService = game:GetService("RunService")
if not RunService:IsStudio() then
return
end
local Spec = require(game:GetService("ReplicatedStorage").DevPackages.Spec)
task.delay(0.1, function()
Spec.Run({ script.Parent }, {
pattern = "%.spec$",
excludeTags = { "slow" }, -- CollectionService tags, or a comma-separated TestTags / Tags attribute
shuffle = false, seed = nil, verbose = false,
-- reporter = { onStart, onTestPass, onTestFail, onFinish }
})
end)
Map DevPackages somewhere in your project, for example ReplicatedStorage.DevPackages. Spec.Run returns { passed, failed, results }.
Offline under Lune
Each game keeps a small wrapper script that finds the installed package and passes the game's settings:
-- tools/spec.luau run from the repo root: lune run tools/spec [NameFilter ...] [--shuffle] [--seed=N] [--verbose]
local fs = require("@lune/fs")
local function package(): string
for _, root in { "DevPackages", "Packages" } do
local index = root .. "/_Index"
if fs.isDir(index) then
for _, name in fs.readDir(index) do
if string.match(name, "^hurreman_luau%-spec@") then
return "../" .. index .. "/" .. name .. "/luau-spec/lune"
end
end
end
end
return "../../luau-spec/lune" -- a checkout next to this repo
end
local Spec = (require :: any)(package())
Spec.run({
project = "default.project.json",
roots = { "tools/unit/specs" },
aliases = { shared = "ReplicatedStorage.Shared" }, -- t.env().shared("Config/Economy")
-- only = { "CurrencyReducer", ... }, -- run just these spec names
})
Spec.run options:
| Option | Meaning |
|---|---|
roots | Folders searched recursively for spec files. |
pattern | Spec file name pattern. Default %.spec%.luau?$. |
only | Run just these spec names. |
filters | Run specs whose name contains one of these; default: the words passed on the command line. |
project, aliases, globals, services, stubs | Passed to every Env (below). |
extras | Extra fields added to t. |
shuffle, seed, verbose, quiet | Run order and output. |
exit | By default the process ends when the run does (code 1 on a failure, else 0), so loops a spec started can't keep Lune alive. false returns (passed, failed) instead, to run more afterwards. |
Compile check
Spec.compile({ roots = { "src" } })
This compiles every .luau and .lua file under the roots, skipping Packages, DevPackages, ServerPackages and node_modules. It reports syntax errors without needing Studio.
Env
Spec.Env.new(options) is the module set each spec file loads in. t.env() returns another fresh one.
How requires resolve:
require(script.Parent.X)andrequire(game:GetService("ReplicatedStorage").Shared.X)resolve through the Rojo project file.- Several
$paths may share one instance path, as in Slime Nebula's per-place overlays. - A folder holding a
default.project.jsonis a nested project, which is how Wally packages underPackages/_Indexresolve. As in Rojo, its instance takes the project'sname, not the folder's (wally installrenames projects to match their folders). - Anything below a file (
X.luau/default.project.json) is known missing without asking the file system. - Files outside the project, such as
tools/unit/specs, resolve by folder. require("@lune/...")passes through to Lune, for offline-only specs.- Modules load from an in-memory copy of the files (
lune/Files.luau), read ahead when the run starts and when an Env maps folders outside the working directory. Every@lune/fscall yields, and Roblox code requires modules from inside metamethods (a package's lazy__indexloader), where a yield is an error. stubs = { ["ReplicatedStorage.Shared.Net"] = fake }answers a require of that instance path withfakeinstead of loading the file (a module that only exists to talk to Roblox).
Globals:
- Roblox datatypes from
@lune/roblox(Vector3,CFrame,Color3,UDim2,Enum,Fontand others); task, a seededRandom,tickandtime;- a fake
workspacewith attributes; game, whoseGetServicebehaves as follows:Workspacereturns the fake workspace;HttpServicesupports JSON encode/decode and GUIDs;- inert stand-ins for CollectionService, RunService, Players, TweenService and SoundService;
- your
servicesoption is used as given; - any other name returns a path. Requiring through it works; calling it fails loudly.
Methods on an env:
env.load("src/Theme/Resolve")loads by file path, without the extension.env.file(path)loads an exact file.env.require("ReplicatedStorage.Shared.Format")loads by instance path.env.<alias>(name)loads by alias.env.workspaceandenv.gameare the fakes above.
Moving the games over
-
Slime Nebula
- Replace
ServerScriptService/Tests/TestRunner.luawithreturn require(<DevPackages>.Spec).Run-style forwarding. - Replace
TestKit.luawithExpect = Spec.expect,DeepEqual = Spec.deepEqual,MakeFakePlayer = Spec.fakePlayer. - Have
scripts/lune/run-pure-specs.luaucallSpec.run({ project = "lobby.project.json", roots = { "src/ServerScriptService/Tests" }, only = DEFAULT_SPECS }).
Verified: 51/51 pure specs pass unchanged.
- Replace
-
Magic Neighborhood:
tools/unit/run.luaubecomes the wrapper above, withaliases = { shared = "ReplicatedStorage.Shared" }. Verified: 378/378 pass unchanged. -
Steal an Egg:
tools/check/compile.luaubecomesSpec.compile({ roots = { "src" } }). Verified: same 93 files. -
roblox-ui-kit: its specs
require("../Loader"). Switch them tot.env().load("src/...")to droptests/Loader.luauandtests/run.luau.
Developing
lune run tests/run # this package's own specs (tests/specs, fixtures in tests/fixtures)
Use the Lune 0.10.5 exe under ~/.aftman/tool-storage/lune-org/lune/0.10.5/. In a Lune init.luau, ./ means the folder's parent; use @self/ for siblings.
Package Details
Install command (Click to copy)
Version
0.1.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
