One command runs your specs. Plain logic finishes in milliseconds without opening Studio, and tests that need the real engine run in Studio or on Roblox's own servers.
curl -fsSL https://raw.githubusercontent.com/AustinBarikdar/LuneTest/main/install.luau | lune run -
cmd /c "curl -fsSL https://raw.githubusercontent.com/AustinBarikdar/LuneTest/main/install.luau | lune run -"
Then it's just lune run test. Needs Rojo, Lune and Wally.
$ lune run test PASS [unit] Economy/CoinsSpec › adds coins PASS [unit] Network/PacketsSpec › batched packets go client -> server -> client FAIL [unit] Economy/CoinsSpec › rejects negative amounts ReplicatedStorage.LuneTestSpecs.Unit.Economy.CoinsSpec:12: expected function to throw PASS [server] ExampleServerSpec › runs on the server (lune) [lunetest] 2 of 3 e2e specs need Studio [lunetest] running in Studio (in the background)... PASS [server] Combat/RaycastSpec › ray into empty space hits nothing (studio) PASS [client] HudSpec › PlayerGui exists (studio) [lunetest] 5 passed, 1 failed (8.06s)
You decide by which folder a spec lives in. LuneTest picks the fastest place that can actually run it.
Runs against your Rojo-built place without Studio. 6,000 tests take about 0.2 seconds. For functions, data, state, and client ↔ server messaging.
A real server and a real client with a character. Studio starts in the background, runs the specs, and closes itself.
Server and shared specs run in an Open Cloud task, so they work in CI where there is no Studio. Your place is never changed.
Many specs written for Studio pass in Lune too, so every e2e spec is tried there first. Studio opens only for the ones that need it.
A spec is a file ending in Spec that returns named test functions. Subfolders are groups you can run on their own.
--!strict -- tests/unit/Economy/CoinsSpec.luau local ReplicatedStorage = game:GetService("ReplicatedStorage") local expect = require(ReplicatedStorage.LuneTest).expect local Coins = require(ReplicatedStorage.Shared.Coins) return { ["adds coins"] = function() expect(Coins.add(10, 5)).toBe(15) end, ["rejects negative amounts"] = function() expect(function() Coins.add(10, -1) end).toThrow("must be positive") end, }
WaitForChild that never returns fails one test.lune run test unit Economy runs a folder, a spec or a single test by name.--!strict.toBe, toEqual, toBeCloseTo, toContain, toThrow, toBeA and more, each with .never.LuneTest.network runs client and server code together inside Lune. RemoteEvents, RemoteFunctions and per-frame packet batching all work, so packet libraries need no setup.
["server answers a ping"] = function() network.server(function() remote.OnServerEvent:Connect(function(player, msg) remote:FireClient(player, msg .. " pong") end) end) local reply: string? = nil network.client(function() remote.OnClientEvent:Connect(function(msg) reply = msg end) remote:FireServer("ping") end) network.flush() expect(reply).toBe("ping pong") end,
network.client gets its own copy of every module, and RunService:IsClient() answers correctly for each side.network.spy(remote) records everything sent through a remote.Unit specs run on any CI machine. Server specs can run on Roblox's own servers. Specs that need a real player still need Studio.
| Kind of test | In CI? | How |
|---|---|---|
| Unit specs, including simulated client ↔ server | Yes | lune run test unit |
| Server and shared specs needing the real engine | Yes | lune run test --cloud |
| Anything needing a real player, character or UI | No | Studio, on your own machine |
# .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: CompeyDev/setup-rokit@v0.1.2 - run: wally install - run: lune run test unit
Your tests can overwrite live data. Specs run inside the experience you configure, with full access to its DataStores, MemoryStores and messaging. A spec that calls SetAsync or RemoveAsync changes that experience's saved data for real. Pointed at your production game, a test could overwrite or delete real player data.
The place itself is not overwritten: --cloud never saves or publishes anything to it. A blank test experience has no data to lose.
.lune/test.luau: cloud = { universeId = …, placeId = … },luau-execution-sessions → Write on only that test experience.ROBLOX_API_KEY environment variable (a repository secret in CI). Never put it in a file.lune run test --cloud.A cloud task is a bare server: no players, no client, no running physics, and your game's own scripts don't start. Client specs are listed as SKIP so nothing disappears silently.
Setup installs a skill that teaches Claude Code and Codex how to write LuneTest specs well: failing test first, behavior-named tests, boundaries and error paths, deterministic and independent tests.
Picked up automatically when you ask for tests.
Same skill file, in Codex's skills folder. Unit tests run inside its sandbox.
It's a plain Markdown file. Add your project's conventions and commit it for the whole team.
Add LuneTest like any other package. You only run init once per project; after that, updating is just wally install.
# wally.toml [dev-dependencies] LuneTest = "austinbarikdar/lunetest@0.1.0"
wally install lune run DevPackages/_Index/*/lunetest/lune/init.luau lune run test
wally install lune run (Resolve-Path DevPackages/_Index/*/lunetest/lune/init.luau) lune run test
The full reference (config, matchers, troubleshooting) is in the README.