| title | Test Infrastructure |
|---|---|
| sidebar_position | 1 |
We use Jest3 via the Nevermore-compatible wrapper @quentystudios/jest-lua. The only difference from upstream Jest is how you access globals — require Jest and access them via Jest.Globals.
Test files must end in .spec.lua and live alongside the code they test (e.g. src/Shared/MyUtils.spec.lua).
--!strict
--[[
@class MyUtils.spec.lua
]]
local require = (require :: any)(
game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent
).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script))
local Jest = require("Jest")
local MyUtils = require("MyUtils")
local describe = Jest.Globals.describe
local expect = Jest.Globals.expect
local it = Jest.Globals.it
describe("MyUtils", function()
it("should do something", function()
expect(MyUtils.doSomething()).toBe(true)
end)
end)Every testable package needs a jest.config.lua in its src/ directory. This tells the test runner to discover .spec files:
return {
testMatch = { "**/*.spec" },
}Legacy tests use the TestEZ pattern (return function() wrapper, .to.equal() matchers). New tests should use the explicit Jest pattern. Key differences:
| TestEZ (old) | Jest (new) |
|---|---|
--!nonstrict |
--!strict |
No Jest require |
local Jest = require("Jest") |
Implicit describe/it/expect globals |
Extract from Jest.Globals |
return function() ... end wrapper |
Top-level describe() (no wrapper) |
expect(x).to.equal(y) |
expect(x).toEqual(y) |
expect(x).to.be.ok() |
expect(x).toBeTruthy() |
expect(x).to.be.near(y, 1e-3) |
expect(x).toBeCloseTo(y, 3) |
expect(fn).to.throw() |
expect(fn).toThrow() |
expect(x).never.to.equal(y) |
expect(x).never.toEqual(y) |
Note: toBeCloseTo(value, numDigits) takes the number of decimal digits of precision (e.g. 3 means 1e-3 tolerance), not an absolute epsilon.
Each testable package needs:
- A
deploy.nevermore.jsonwith atesttarget - A Rojo project file (typically
test/default.project.json) that builds the test place - A script template (typically
test/scripts/Server/ServerMain.server.lua) that boots the package and runs tests
Run nevermore deploy init from inside a package directory. The interactive wizard will walk you through setting up the test target, or use flags for non-interactive setup:
# Interactive (recommended for first time)
nevermore deploy init
# Non-interactive
nevermore deploy init --yes --universe-id 12345 --create-place --project test/default.project.json --script-template test/scripts/Server/ServerMain.server.luaThe resulting config looks like:
{
"targets": {
"test": {
"universeId": 12345,
"placeId": 67890,
"project": "test/default.project.json",
"scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
}
}
}The test project maps the package into ServerScriptService so the test runner can find it:
{
"name": "MyPackageTest",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$properties": {
"LoadStringEnabled": true
},
"mypackage": {
"$path": ".."
},
"Script": {
"$path": "scripts/Server"
}
}
}
}The script template is executed via Open Cloud (or locally via studio-bridge). It bootstraps the loader and delegates to NevermoreTestRunnerUtils:
--!nonstrict
--[[
@class ServerMain
]]
local ServerScriptService = game:GetService("ServerScriptService")
local root = ServerScriptService.mypackage
local loader = root:FindFirstChild("LoaderUtils", true).Parent
local require = require(loader).bootstrapGame(root)
local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
return
endReplace mypackage with the key used in your Rojo project tree.
The @quenty/nevermore-test-runner package provides NevermoreTestRunnerUtils, which handles the test execution lifecycle:
- If a
jest.configis found under the given root, it runs Jest tests - If no
jest.configis found, boot success is the test (smoke test) - Detects Open Cloud vs local execution context and exits appropriately
From a package directory with a deploy.nevermore.json:
# Run locally via studio-bridge (default)
nevermore test
# Run via Roblox Open Cloud
nevermore test --cloud
# Show execution logs
nevermore test --logsOptions:
| Flag | Description |
|---|---|
--cloud |
Run tests via Open Cloud instead of locally |
--logs |
Show execution logs |
--api-key |
Roblox Open Cloud API key (--cloud only) |
--universe-id |
Override universe ID from deploy.nevermore.json |
--place-id |
Override place ID from deploy.nevermore.json |
--script-template |
Override script template path |
--script-text |
Luau code to execute directly instead of the configured template |
Run tests across multiple packages, with automatic change detection:
# Test only packages changed vs origin/main
nevermore batch test
# Test all packages with test targets
nevermore batch test --all
# Run via Open Cloud with concurrency
nevermore batch test --cloud --concurrency 3
# Write JSON results to file
nevermore batch test --output results.jsonOptions:
| Flag | Description |
|---|---|
--cloud |
Run tests via Open Cloud instead of locally |
--all |
Test all packages, not just changed ones |
--base |
Git ref to diff against (default: origin/main) |
--concurrency |
Max parallel tests (default: 1 local, 3 cloud) |
--output |
Write JSON results to a file |
--limit |
Max number of packages to test |
--logs |
Show execution logs for all packages |
The CLI resolves API credentials in this order (first match wins):
--api-keyCLI flagROBLOX_OPEN_CLOUD_API_KEYenvironment variableROBLOX_UNIT_TEST_API_KEYenvironment variable (backwards compat)~/.nevermore/credentials.json(stored vianevermore login)
In interactive mode, if no credentials are found, the CLI prompts inline. In --yes (CI) mode, it errors immediately.
When tests fail in CI, the post-test-results command parses Jest-lua output and emits GitHub Actions annotations pointing to the failing spec files. To map Roblox instance paths (e.g. ServerScriptService.maid.Shared.Maid.spec) back to repo-relative filesystem paths, two strategies are used:
-
Sourcemap resolution (preferred) — If
sourcemap.jsonexists (generated byrojo sourcemap --absolute), theSourcemapResolverbuilds a lookup index from the tree. This is exact and handles all project layouts. The CI workflow runsnpm run build:sourcemapbefore posting results to ensure the file is available. -
Heuristic fallback — When no sourcemap is available, the resolver assumes the standard
src/{slug}/src/layout and rejoins known dotted suffixes (.spec,.story). This works for the common case but can produce wrong paths for non-standard layouts or dotted filenames.
The resolver code lives in tools/nevermore-cli/src/utils/sourcemap/ and is shared with the strip-sourcemap-jest command.
Studio can run headlessly on Linux via Wine, enabling E2E tests in devcontainers and GitHub Actions without a display or GPU. The studio-bridge CLI handles all environment setup:
# One-time setup
studio-bridge linux setup --install-deps
studio-bridge linux inject-credentials # reads $ROBLOSECURITY env var
# Run tests the same as on Windows/macOS
nevermore testPrerequisites (Wine 11, Xvfb, openbox, Mesa llvmpipe) are documented in tools/studio-bridge/src/linux/README.md. The linux setup --install-deps flag installs everything on Debian/Ubuntu but is opt-in — it never runs sudo automatically.
For CI, set ROBLOSECURITY as a repository or Codespace secret. The .github/workflows/studio-linux-e2e.yml workflow demonstrates the full flow.
- Workflows should be thin. All logic lives in
nevermore-clicommands — GitHub Actions workflows just call them. This keeps CI debuggable locally. - Rate limiting is shared across concurrent workers via the
OpenCloudClientinstance. TheRateLimitercaps concurrent Open Cloud API requests (default 4 in-flight via a semaphore), readsx-ratelimit-remaining/x-ratelimit-resetheaders, and retries 429s with jittered back-off. - Post results via CLI:
nevermore tools post-test-results <file>posts or updates a PR comment with test results and writes to the GitHub Actions job summary. RequiresGITHUB_TOKENfor PR comments; job summaries are written automatically whenGITHUB_STEP_SUMMARYis set. - Live comment updates during the run: When
nevermore batch testdetects a CI environment, it also updates the PR comment as packages transition between phases (throttled to ~10s). Thepost-test-resultsstep still writes the final snapshot, but reviewers see progress without waiting for the full run to finish. In--aggregatedmode every package shares one execution, so they move throughuploading→scheduling→executingin lock-step — that's expected, not a bug. - Job summaries: Results are automatically written to the GitHub Actions job summary when running in CI. This makes results visible on the workflow run summary page, complementing the PR comment. The job summary is only written by
post-test-results(not by the live batch run) to avoid duplicate entries on the workflow summary page.