Forest Logo
search
package_2

luau-spec

By @hurreman

Roblox

Mirrored from Wally

luau-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) and t.it(name, fn)

  • t.expect(value) with these matchers:

    • toBe, toEqual (deep), toBeCloseTo(n, eps?)
    • toBeTruthy, toBeFalsy, toBeNil, toBeType(name)
    • toBeGreaterThan, toBeLessThan
    • toContain (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) and expect(x):toBe(y) work.

  • t.fakePlayer(userId, name) and t.fakeInstance(name): tables with attribute storage. t.fakeSignal(): a synchronous signal with Fire and ConnectionCount.

Lune runner only:

  • t.env(extra?): a fresh, isolated module set; see Env. extra globals, 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's Random.

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:

OptionMeaning
rootsFolders searched recursively for spec files.
patternSpec file name pattern. Default %.spec%.luau?$.
onlyRun just these spec names.
filtersRun specs whose name contains one of these; default: the words passed on the command line.
project, aliases, globals, services, stubsPassed to every Env (below).
extrasExtra fields added to t.
shuffle, seed, verbose, quietRun order and output.
exitBy 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) and require(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.json is a nested project, which is how Wally packages under Packages/_Index resolve. As in Rojo, its instance takes the project's name, not the folder's (wally install renames 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/fs call yields, and Roblox code requires modules from inside metamethods (a package's lazy __index loader), where a yield is an error.
  • stubs = { ["ReplicatedStorage.Shared.Net"] = fake } answers a require of that instance path with fake instead of loading the file (a module that only exists to talk to Roblox).

Globals:

  • Roblox datatypes from @lune/roblox (Vector3, CFrame, Color3, UDim2, Enum, Font and others);
  • task, a seeded Random, tick and time;
  • a fake workspace with attributes;
  • game, whose GetService behaves as follows:
    • Workspace returns the fake workspace;
    • HttpService supports JSON encode/decode and GUIDs;
    • inert stand-ins for CollectionService, RunService, Players, TweenService and SoundService;
    • your services option 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.workspace and env.game are the fakes above.

Moving the games over

  • Slime Nebula

    1. Replace ServerScriptService/Tests/TestRunner.lua with return require(<DevPackages>.Spec).Run-style forwarding.
    2. Replace TestKit.lua with Expect = Spec.expect, DeepEqual = Spec.deepEqual, MakeFakePlayer = Spec.fakePlayer.
    3. Have scripts/lune/run-pure-specs.luau call Spec.run({ project = "lobby.project.json", roots = { "src/ServerScriptService/Tests" }, only = DEFAULT_SPECS }).

    Verified: 51/51 pure specs pass unchanged.

  • Magic Neighborhood: tools/unit/run.luau becomes the wrapper above, with aliases = { shared = "ReplicatedStorage.Shared" }. Verified: 378/378 pass unchanged.

  • Steal an Egg: tools/check/compile.luau becomes Spec.compile({ roots = { "src" } }). Verified: same 93 files.

  • roblox-ui-kit: its specs require("../Loader"). Switch them to t.env().load("src/...") to drop tests/Loader.luau and tests/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

check_circle

Safe for commercial use

Automated license review — not legal advice.