Skip to content

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
  ok   Calculator / adds
  FAIL Calculator / fails: ./run.luau:16: kaboom

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