Advanced Usage¶
Everything on this page uses Wisp's lower-level objects. The default
Wisp.suite / Wisp() pair is all most projects need, but the classes underneath are
plain modules and can be used directly.
One runner per process¶
Wisp keeps a single default runner, created lazily on first use and cached in the module
table. Every Wisp.suite(name) call registers into it, and Wisp() runs it:
local Wisp = require("wisp")
print(Wisp.suite("A")) -- registers into the default runner
Wisp() -- runs it
This has three consequences worth knowing.
Suite names are global
Because the runner is shared, two suites with the same name across different spec
files collide — the second is dropped. Prefix names with the module they cover, as
Wisp's own specs do (Assertion:equalsTo, Suite:action, Runner:pass).
Module caching makes multiple requires safe. require caches, so several spec files
can each require Wisp and still register into one runner.
Calling Wisp() twice re-runs everything. The runner does not reset its statistics
between runs, so each action is printed twice and the tallies are doubled. Call it once.
Building your own runner¶
Runner is a plain module whose metatable forwards to its methods. You can construct
one, hand it suites, and run it:
local Runner = require("wisp/src/classes/Runner")
local Suite = require("wisp/src/classes/Suite")
local runner = Runner.new()
local suite = Suite.new("Calculator", runner) -- registers itself
suite:action("adds", function()
Runner_print(1 + 1)
end)
runner:run() -- prints Wisp's standard report
Runner:run prints, and also returns the tallies — passed = { passedSuites,
passedTests } and total = { totalSuites, totalTests }, the same values the summary lines
show. If you want the built-in summary for a subset of suites, build a separate runner
rather than reusing the default one.
Custom reporters¶
Suite:run does not require a Runner — it requires anything that responds to pass
and fail with method syntax. That is the extension point for your own output format,
integrations, or structured results.
local Suite = require("wisp/src/classes/Suite")
local suites = {}
-- The only thing Suite.new needs from a runner is registerSuite.
local registry = {
registerSuite = function(_, suite)
table.insert(suites, suite)
end,
}
local reporter = {
pass = function(_, suiteName, actionName)
print(` ok {suiteName} / {actionName}`)
end,
fail = function(_, suiteName, actionName, message)
print(` FAIL {suiteName} / {actionName}: {message}`)
end,
}
local suite = Suite.new("Calculator", registry)
suite:action("adds", function()
assert(1 + 1 == 2)
end)
suite:action("fails", function()
error("kaboom")
end)
for _, entry in suites do
entry:run(reporter)
end
The reporter is called as methods, so self is the reporter table — store state on it if
you need to.
Reporter errors are not caught
Wisp wraps each action in pcall, but a reporter that raises propagates straight
out of Suite:run and aborts the remaining actions. Keep reporter code total.
Reading results programmatically¶
A Runner accumulates results in _stats, keyed by suite name:
local Runner = require("wisp/src/classes/Runner")
local Suite = require("wisp/src/classes/Suite")
local runner = Runner.new()
local suite = Suite.new("Calculator", runner)
suite:action("adds", function() end)
suite:action("subtracts", function()
error("nope")
end)
runner:run()
for suiteName, stats in runner._stats do
print(suiteName, #stats.passed, #stats.failed)
-- Calculator 1 1
end
for _, failure in runner._stats["Calculator"].failed do
print(failure.name, failure.message)
-- subtracts ./run.luau:12: nope
end
| Field | Type | Contents |
|---|---|---|
stats.passed |
{ string } |
Passing action names, in run order. |
stats.failed |
{ { name: string, message: string } } |
Failures, in run order. |
runner._suites |
{ Suite } |
Registered suites, in registration order. |
runner._stats |
{ [string]: SuiteStats } |
One entry per registered suite name. |
These are underscore-prefixed, so they are internal by convention. They are what Wisp's own tests assert against, and they are stable enough to read — but there is no stability guarantee across versions. If you rely on them, say so in an issue and it can be promoted to a supported accessor.
No hooks: per-action setup¶
Wisp has no beforeEach. If every action needs fresh state, do the setup inside the
action, or hide it in a local factory:
local function newCart()
return { items = {}, total = 0 }
end
Wisp.suite("Cart")
:action("starts empty", function()
local cart = newCart()
Wisp.assume(#cart.items):equalsTo(0)
end)
:action("adds an item", function()
local cart = newCart()
table.insert(cart.items, "apple")
Wisp.assume(#cart.items):equalsTo(1)
end)
The alternative — building the fixture once at module scope — shares mutable state across actions, and any action that mutates it changes what the next action sees.
Running in Roblox¶
Wisp reports through print, which in Studio lands in the Output window. To make a
failing run noticeable while developing, wrap the runner:
local Wisp = require(game:GetService("ReplicatedStorage").Packages.wisp)
Wisp.suite("Inventory")
:action("starts empty", function()
Wisp.assume(#{}):equalsTo(0)
end)
Wisp()
Because a failing run still exits 0 and Studio does not gate publishing on printed
output, treat the Output window as the source of truth during development, and use the
Runner stats approach above if you need to raise a warn for failures.
Next¶
- API reference — every signature in one place.
- Contributing — running Wisp's own test suite.