Roblox Unit Testing With Jest Lua: Running Automated Tests and CI on Your Game Code

How much of your Roblox game code has actually been tested outside a live playtest? If the honest answer is "whatever I clicked through in Studio last night," you are in the same position as most Roblox teams — and that position gets more expensive with every system you add.
Playtesting catches what you look at, while unit tests catch what you forgot to look at. This guide walks through the full setup we keep open while building: Jest Lua installed through Wally, a Rojo project that places tests next to the code, service mocking that holds up in real modules, and a GitHub Actions job that fails the pull request when a spec fails.
What is Jest Lua? Jest Lua is a Luau port of JavaScript's Jest, maintained under the jsdotlua organization. It gives Roblox code describe/it blocks, expect matchers, mock functions, module mocking, and snapshot tests.
Why Roblox Code Needs Unit Tests At All
Roblox games tend to grow from a handful of scripts into dozens of interdependent ModuleScripts — inventory, currency, matchmaking, data persistence, and anti-exploit checks all calling into one another. Once that happens, a change to one module can quietly break a system you have not opened in weeks.
That said, the systems most worth testing are the ones where a bug costs real players real progress. Some examples of logic that benefits most from automated coverage include:
- Economy and currency math. Price calculations, discounts, and payout formulas are pure functions, which makes them the easiest and most valuable place to start. A rounding error here duplicates or deletes currency at scale.
- Inventory and trading rules. Stack limits, slot validation, and trade-lock checks are exactly the kind of logic covered in our Roblox inventory systems guide. Each rule is a small, testable decision.
- Server-side validation. The remote-argument checks from our Roblox anti-exploit guide should reject malformed input every time. A test proves that they still do after a refactor.
- Data migration and serialization. Save-format upgrades run once per player and cannot be rolled back easily. Testing them against fixture data before release is far cheaper than a support queue full of wiped profiles.
All of these share one property — they are deterministic logic wrapped in engine calls. Once you separate the logic from the engine, testing it becomes straightforward.
What You Need Before You Start
This setup assumes you already sync code from the filesystem rather than editing scripts inside Studio. If you have not made that move yet, start with our Rojo and Git workflow guide, because everything below depends on your code living in a repository.
Here is the toolchain the rest of this guide uses:
| Tool | Role | Where It Runs |
|---|---|---|
| Rokit (or Aftman) | Pins exact versions of Rojo, Wally, and other CLI tools per repository | Local and CI |
| Wally | Installs Jest Lua and JestGlobals as dev dependencies | Local and CI |
| Rojo | Builds a test place file (.rbxl) from your source tree | Local and CI |
| Jest Lua | Discovers and runs .spec files inside the Roblox engine | Studio or Open Cloud |
| Open Cloud Luau Execution | Runs the test script headlessly against an uploaded place version | Roblox servers, triggered from CI |
Keep in mind that Jest Lua runs inside the Roblox engine, not in a standalone Lua interpreter. This is why the CI step needs either a Studio install or the Open Cloud execution API — a plain Lua binary on a Linux runner will not load it.
Installing Jest Lua With Wally
First, add Jest Lua and JestGlobals to the dev-dependencies section of your wally.toml. Dev dependencies install into a separate DevPackages folder, so the test framework never ships inside your published place.
[dev-dependencies]
Jest = "jsdotlua/jest@3.10.0"
JestGlobals = "jsdotlua/jest-globals@3.10.0"
Pin the current 3.x release rather than copying our version number blindly, and pin Jest and JestGlobals to the same version. Mismatched versions between the two packages produce confusing "expect is not a function" style failures that look like test bugs.
Then run wally install. You should see a new DevPackages folder alongside Packages at the root of your repository.
Where should Jest Lua be installed? Install it under [dev-dependencies] in wally.toml so it lands in DevPackages. Map DevPackages only in your test Rojo project, which keeps the framework out of production builds.
Structuring A Rojo Test Project
We recommend keeping two Rojo project files: default.project.json for the shippable game and test.project.json for the test place. The test project maps the same source folders plus DevPackages and a runner script, so production and test builds share one source of truth.
A minimal test.project.json looks like this:
{
"name": "game-tests",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Shared": { "$path": "src/shared" },
"Packages": { "$path": "Packages" },
"DevPackages": { "$path": "DevPackages" }
},
"ServerScriptService": {
"Server": { "$path": "src/server" },
"TestRunner": { "$path": "tests/run.server.luau" }
}
}
}
Next, place each spec file beside the module it tests, using the .spec.luau suffix. For instance, src/shared/Currency.luau gets src/shared/Currency.spec.luau, and Rojo syncs it as a ModuleScript named Currency.spec.
Finally, add a jest.config.luau at the root of each folder you want Jest to scan. At minimum, it tells Jest which ModuleScripts count as tests:
return {
testMatch = { "**/*.spec" },
}
Writing Your First Spec
Jest Lua does not inject globals the way JavaScript Jest does. Instead, you require JestGlobals and pull out describe, it, expect, and jest explicitly at the top of every spec file.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local JestGlobals = require(ReplicatedStorage.DevPackages.JestGlobals)
local describe = JestGlobals.describe
local it = JestGlobals.it
local expect = JestGlobals.expect
local Currency = require(script.Parent.Currency)
describe("Currency.applyDiscount", function()
it("rounds down to whole coins", function()
expect(Currency.applyDiscount(99, 0.15)).toBe(84)
end)
it("never returns a negative price", function()
expect(Currency.applyDiscount(10, 1.5)).toBe(0)
end)
end)
Note that matchers use a dot rather than a colon — expect(x).toBe(y), not expect(x):toBe(y). This is the single most common syntax error teams hit in their first week with Jest Lua.
Why are my Jest Lua globals nil? Jest Lua does not inject describe, it, or expect automatically. Require JestGlobals from DevPackages at the top of each spec and assign each function to a local.
The Test Runner Script
The runner is a server Script that calls Jest's runCLI function, waits for the result, and reports pass or fail. Here is the version we use, adapted from the pattern in the Jest Lua documentation:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local runCLI = require(ReplicatedStorage.DevPackages.Jest).runCLI
local projects = { ReplicatedStorage.Shared, ServerScriptService.Server }
local status, result = runCLI(ReplicatedStorage, {
verbose = false,
ci = true,
}, projects):awaitStatus()
local passed = status == "Resolved"
and result.results.numFailedTestSuites == 0
and result.results.numFailedTests == 0
if not passed then
error("Jest Lua: test run failed")
end
print("Jest Lua: all tests passed")
Raising an error on failure matters more than it looks. In CI, the only signal the pipeline receives is whether the script completed or errored, so a runner that merely prints failures will report green on a broken build.
Be aware that Jest Lua relies on debug.loadmodule to re-require modules in isolation between test files. To run tests locally in Studio, add the FFlagEnableLoadModule flag set to true in the ClientAppSettings.json file inside your Studio installation's ClientSettings folder, then restart Studio.
Mocking Roblox Services
This is where most Roblox testing guides stop, and it is the part that matters most. Real modules call DataStoreService, MarketplaceService, HttpService, and Players — and you do not want a unit test hitting a live DataStore or prompting a real purchase.
Dependency Injection Beats Global Patching
The most reliable approach is to pass services into your module rather than fetching them at require time. A module that calls game:GetService("DataStoreService") on line one is hard to test, while a module that accepts a store object in a constructor is trivial to test.
local ProfileStore = {}
ProfileStore.__index = ProfileStore
function ProfileStore.new(dataStore)
return setmetatable({ _store = dataStore }, ProfileStore)
end
function ProfileStore:load(userId)
local ok, data = pcall(function()
return self._store:GetAsync("player_" .. userId)
end)
if not ok then
return nil, "load_failed"
end
return data or { coins = 0, version = 2 }
end
return ProfileStore
In production, the server bootstrap passes in the real DataStore. In tests, you pass in a plain table built from mock functions.
Using jest.fn For Service Stand-Ins
In Jest Lua, jest.fn() returns two values: the mock object you assert against and a callable function you hand to the code under test. Keep both, because mixing them up is the second most common first-week error.
local jest = JestGlobals.jest
local ProfileStore = require(script.Parent.ProfileStore)
describe("ProfileStore:load", function()
it("returns defaults for a new player", function()
local getMock, getFn = jest.fn()
getMock.mockReturnValue(nil)
local fakeStore = { GetAsync = function(_, key) return getFn(key) end }
local data = ProfileStore.new(fakeStore):load(123)
expect(data.coins).toBe(0)
expect(getMock).toHaveBeenCalledWith("player_123")
end)
it("reports failure when the DataStore throws", function()
local fakeStore = { GetAsync = function() error("503") end }
local data, err = ProfileStore.new(fakeStore):load(123)
expect(data).toBeNil()
expect(err).toBe("load_failed")
end)
end)
The second test covers the failure mode that matters most in production — a throttled or unavailable DataStore. Playtesting almost never exercises that path, yet it is exactly the path that decides whether a player loses their save.
Mocking Whole Modules
When you cannot refactor a dependency yet, jest.mock replaces a ModuleScript for the duration of a test file. You pass the ModuleScript instance and a factory function, and any require of that instance returns the factory's result instead.
jest.mock(ReplicatedStorage.Shared.Analytics, function()
return { track = jest.fn() }
end)
Call jest.mock before requiring the module under test, since the mock only applies to requires that happen after it is registered. What's more, module mocks reset between test files, so one spec's mocks never leak into another.
How do you mock DataStoreService in Jest Lua? Inject the DataStore into your module instead of calling GetService inside it. In tests, pass a table whose GetAsync and SetAsync methods wrap jest.fn mocks.
What not to unit test. Physics, rendering, replication timing, and client input feel are integration concerns. Cover those with playtests and the patterns in our Roblox replication guide, and keep unit tests focused on logic you can call directly.
Running Tests In GitHub Actions
Running tests locally is useful, but the payoff comes when every pull request runs them automatically. The challenge is that GitHub's Linux runners do not have Roblox Studio, so the pipeline needs somewhere to execute the test place.
There are two workable options:
- Open Cloud Luau Execution. CI builds the test place with Rojo, uploads it as a new place version to a dedicated test experience, then asks Roblox to run a script against that version and returns the logs. No Studio install is required, and it runs on a standard ubuntu-latest runner.
- Self-hosted Windows or macOS runner. A machine with Studio installed runs the place through a tool such as run-in-roblox. This gives full local parity but means maintaining a runner and keeping Studio logged in and updated.
For most teams, Open Cloud is the better default. It is the same API family covered in our Roblox Open Cloud APIs guide, and it removes the runner-maintenance burden entirely.
Setting Up The Test Experience And API Key
First, create a separate private experience used only for tests, and never point CI at your live game. Then create an Open Cloud API key scoped to that one experience with permission to write place versions and create Luau execution tasks.
Store the key as a repository secret named ROBLOX_API_KEY, and store the universe and place IDs as repository variables. Keep in mind that Open Cloud keys can be restricted by IP range — GitHub-hosted runners use wide, changing ranges, so most teams leave the IP allowlist open and rely on the narrow scope instead.
The Workflow File
Here is the workflow, saved as .github/workflows/test.yml:
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
jest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: CompeyDev/setup-rokit@v0.1.2
- run: wally install
- run: rojo build test.project.json -o test.rbxl
- name: Run Jest Lua via Open Cloud
env:
ROBLOX_API_KEY: ${{ secrets.ROBLOX_API_KEY }}
UNIVERSE_ID: ${{ vars.TEST_UNIVERSE_ID }}
PLACE_ID: ${{ vars.TEST_PLACE_ID }}
run: python3 scripts/run_tests.py test.rbxl tests/run.server.luau
The run_tests.py script does three things. It uploads test.rbxl as a new saved place version, creates a Luau execution task against that version with the runner script's source, and then polls the task until it reaches a terminal state.
What The Script Checks
Once the task completes, the script fetches the task's output logs, prints them to the Actions console, and exits non-zero if the task state is FAILED. Because the runner script raises an error on any failed spec, a single broken test marks the task as failed and turns the pull request check red.
Several details keep this step reliable in practice:
- Set a polling timeout. Cap the poll loop at around five minutes so a stuck task fails the job rather than burning the full six-hour GitHub Actions limit.
- Print logs on success too. Seeing the pass count in every run makes it obvious when a test file was silently skipped because testMatch stopped matching it.
- Serialize runs per branch. Add a concurrency group to the workflow so two pushes to the same pull request do not race to upload place versions.
- Smoke-test the pipeline first. Before wiring in your real suite, run a single spec that asserts true equals true. If that fails, the problem is the environment — keys, IDs, or module loading — rather than your code.
That last point deserves emphasis, because the execution environment is not identical to Studio. Confirming that Jest Lua loads and completes in the cloud with a trivial spec isolates environment problems before they get tangled up with real test failures.
Can Roblox tests run on GitHub's Linux runners? Yes, through Open Cloud Luau Execution. CI builds the place with Rojo, uploads it as a place version, runs the test script remotely, and fails the job if the task errors.
How Fast Should The Suite Be?
Speed determines whether developers actually wait for the check or learn to ignore it. A well-scoped suite of a few hundred pure-logic specs typically finishes inside the engine in seconds, and most of the CI wall-clock time goes to installing tools, building the place, and uploading it.
Accordingly, cache the Rokit tool directory and the Packages and DevPackages folders keyed on your lockfiles. That usually brings the job down to a couple of minutes end to end, which is short enough that nobody merges before it finishes.
Growing The Suite Without Slowing The Team
Do not try to backfill tests for the whole codebase in one sprint. Instead, adopt one rule: every bug fix ships with a spec that would have caught it, and every new module that touches currency, inventory, or persistence ships with specs from the start.
Over a few months, that rule concentrates coverage exactly where your game has already proven fragile. Remember that the goal is fewer regressions reaching players, not a coverage percentage on a dashboard.
Bringing It Together
Taken together, Jest Lua, Wally, Rojo, and Open Cloud give Roblox code the same test-and-merge loop that web and backend teams take for granted. The setup takes an afternoon, and the first time a pull request goes red on a currency bug before it reaches a live server, it pays for itself.
If you are building out the rest of your Roblox engineering practice, our guides cover the systems these tests protect — from Roblox leaderboards and matchmaking to monetization. Start with the modules that handle player progress, write the first spec today, and let the pipeline enforce it from there.
Frequently Asked Questions
Is TestEZ still a good choice for Roblox testing?
TestEZ still works, but Roblox's own teams moved to Jest Lua, and TestEZ is no longer actively developed. For new projects, Jest Lua offers richer matchers, mock functions, module mocking, and snapshot testing.
Why does Jest Lua fail with a loadmodule error in Studio?
Jest Lua needs debug.loadmodule to isolate modules between test files. Add FFlagEnableLoadModule set to true in Studio's ClientAppSettings.json, then fully restart Studio before running the tests again.
Should tests run against my live Roblox experience?
No — create a separate private experience for CI and scope the Open Cloud key to it alone. Uploading test builds to your live place would publish test code and risks touching production DataStores.
Where should spec files live in a Rojo project?
Place each spec beside the module it tests with a .spec.luau suffix, then exclude them from production by mapping only source folders in default.project.json. Keeping them adjacent makes missing tests obvious in review.
Can Jest Lua snapshot-test Roblox data tables?
Yes — toMatchSnapshot serializes Luau tables and stores snapshots in ModuleScripts. It works well for save-format and config tables, but snapshots written in CI must be committed back to Git to persist.


