A standalone, headless test framework for Roblox Lua modules that runs outside of Roblox Studio. It mocks the most common Roblox APIs so you can run your tests in CI, on a developer machine without Studio, or anywhere you have Lua 5.1.
Roblox modules are full of calls like game:GetService("Players"), Instance.new("Part"), and script.Parent. None of those exist in plain Lua 5.1, so you cannot run them directly in a normal test runner. Roblox TestKit solves that by:
- Providing lightweight mocks for
game,workspace,script,Instance, and common services. - Shipping a BDD-style test framework (
describe/it/expect). - Providing a CLI runner that loads your spec, injects the mocks, and reports results.
- Supporting colored terminal output and JUnit XML for CI systems.
- Lua 5.1 (
luaorlua5.1on your PATH)
No Roblox Studio, no Rojo, no external Lua libraries.
Clone the repository:
git clone https://github.com/SuperInstance/roblox-testkit.git
cd roblox-testkitThere is no Python/pip dependency — this is a pure Lua project. You can run it directly from the clone.
Create a symlink or copy the wrapper script to somewhere on your PATH:
chmod +x roblox-testkit
ln -s "$(pwd)/roblox-testkit" /usr/local/bin/roblox-testkitThen run from anywhere:
roblox-testkit path/to/spec.luaCreate a Roblox module, for example example/MyModule.lua:
local MyModule = {}
function MyModule.add(a, b)
return a + b
end
function MyModule.getStorage()
return game:GetService("ReplicatedStorage")
end
function MyModule.createPart(name)
local part = Instance.new("Part")
part.Name = name
part.Parent = workspace
return part
end
return MyModuleCreate a spec file, for example example/spec_example.lua:
local testkit = require("testkit")
-- Load the module under test with its own mocked script instance.
local MyModule = testkit.loadModule("example/MyModule.lua")
testkit.describe("MyModule", function()
testkit.it("adds two numbers", function()
testkit.expect(MyModule.add(2, 3)):equals(5)
end)
testkit.it("returns ReplicatedStorage", function()
local storage = MyModule.getStorage()
testkit.expect(storage):isNot():isNil()
testkit.expect(storage.Name):equals("ReplicatedStorage")
end)
testkit.it("creates a Part in Workspace", function()
local part = MyModule.createPart("TestBlock")
testkit.expect(part.Name):equals("TestBlock")
testkit.expect(part.Parent.Name):equals("Workspace")
testkit.expect(part:IsA("BasePart")):equals(true)
end)
end)Run it:
lua src/runner.lua example/spec_example.luaOutput:
Roblox TestKit
========================================
[PASS] MyModule > adds two numbers (0.000s)
[PASS] MyModule > returns ReplicatedStorage (0.000s)
[PASS] MyModule > creates a Part in Workspace (0.000s)
----------------------------------------
3 tests, 3 passed, 0 failed, 0 skipped (0.001s)
lua src/runner.lua [options] <spec-file>| Option | Description |
|---|---|
--junit <path> |
Write a JUnit XML report to <path>. |
--no-color |
Disable ANSI colors in terminal output. |
--help, -h |
Show usage help. |
# Basic run
lua src/runner.lua example/spec_example.lua
# JUnit XML for CI
lua src/runner.lua --junit results.xml example/spec_example.lua
# Disable colors
lua src/runner.lua --no-color example/spec_example.luaThe runner exits with code 0 when all tests pass and 1 when any test fails.
Defines a test suite. Suites can be nested.
testkit.describe("MyService", function()
testkit.describe("validation", function()
testkit.it("rejects bad input", function() end)
end)
end)Defines a single test case.
Run once per describe block, before/after all of its tests and nested suites.
Run before/after each test in the current describe block and all nested blocks.
Creates an assertion object for value. All assertions use Lua method syntax (:).
testkit.expect(42):equals(42)
testkit.expect({a = 1}):deepEquals({a = 1})
testkit.expect(nil):isNil()
testkit.expect("hello"):isType("string")
testkit.expect(function() error("oops") end):throws("oops")| Assertion | Description |
|---|---|
:equals(expected) |
Reference equality (==). |
:toEqual(expected) |
Alias for :equals. |
:toBe(expected) |
Alias for :equals. |
:deepEquals(expected) |
Deep table equality. |
:toDeepEqual(expected) |
Alias for :deepEquals. |
:isNil() |
Value is nil. |
:toBeNil() |
Alias for :isNil. |
:isType(typeName) |
type(value) == typeName. |
:toBeA(typeName) |
Alias for :isType. |
:throws(message?) |
Expects a function to error. If message is provided, the error must contain it. |
:toThrow(message?) |
Alias for :throws. |
Chain :isNot(), :never(), or :toNot() before the assertion:
testkit.expect(value):isNot():equals(5)
testkit.expect(value):never():isNil()Loads a .lua module with the Roblox mocks injected and a dedicated script instance. Use this to load the module you want to test.
local MyModule = testkit.loadModule("src/MyModule.lua")The returned module can use game, Instance, workspace, script, and any mocked service just like it would inside Studio.
src/roblox_mock.lua provides realistic table-based mocks for:
game(DataModel) withGetService,IsLoaded,PlaceId,JobId, etc.workspacescriptInstancewithnew,FindFirstChild,FindFirstChildOfClass,FindFirstChildWhichIsA,GetChildren,GetDescendants,Clone,Destroy,WaitForChild,GetAttribute,SetAttribute,IsA,IsDescendantOf,GetFullName, andParenthierarchy support.PlayersReplicatedStorageServerScriptServiceServerStorageLightingRunService(withHeartbeat,RenderStepped,Steppedevents)TweenServiceDebrisCollectionServiceTextServiceHttpService(with a simple JSON encoder/decoder)
The mock environment is installed into the global table (_G) by the runner, so any module loaded by the spec sees the same game, workspace, and Instance globals.
If you are coming from Roblox TestService scripts, the migration is straightforward:
- Move each
TestServicescript into a*.spec.luafile next to the module it tests. - Replace
print("success")/error("fail")style checks withtestkit.expect(...). - Load the module under test with
testkit.loadModule("path/to/Module.lua"). - Run the spec with
lua src/runner.lua path/to/Module.spec.lua. - Add the run command to your CI workflow.
Before:
-- Roblox TestService script
local Module = require(game.ReplicatedStorage.Module)
local result = Module.add(1, 2)
if result ~= 3 then
error("add failed")
end
print("add passed")After:
local testkit = require("testkit")
local Module = testkit.loadModule("src/Module.lua")
testkit.describe("Module", function()
testkit.it("adds numbers", function()
testkit.expect(Module.add(1, 2)):equals(3)
end)
end)Because the runner works with plain Lua 5.1, it fits into any CI pipeline. Example GitHub Actions workflow:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Lua
run: sudo apt-get update && sudo apt-get install -y lua5.1
- name: Run tests
run: lua src/runner.lua example/spec_example.luaFor JUnit XML output that many CI systems can display:
- name: Run tests
run: lua src/runner.lua --junit test-results.xml example/spec_example.lua
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.xmlMIT — see LICENSE.