Still in development. Expect bugs and changes between versions. Found one? Report it.
LuneTest logo

Test your Roblox game
from the terminal.

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.

run in your Rojo project folder
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)

Three places a test can run

You decide by which folder a spec lives in. LuneTest picks the fastest place that can actually run it.

UNIT

Inside Lune

Runs against your Rojo-built place without Studio. 6,000 tests take about 0.2 seconds. For functions, data, state, and client ↔ server messaging.

tests/unit/ · lune run test unit
E2E

Real Roblox Studio

A real server and a real client with a character. Studio starts in the background, runs the specs, and closes itself.

tests/e2e/{server,client,shared}/ · lune run test e2e
CLOUD

Roblox's servers

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.

lune run test --cloud

Lune first, Studio only when needed

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.

BuildRojo builds your project with the test runtime and specs injected. Your own project file is untouched.
Try in LuneServer and shared specs run as a simulated server, client specs as a simulated client.
Fall backA spec with any failure is rerun in Studio (or the cloud). That result is final, so Lune can't cause a false failure.
RememberSpecs that needed Studio skip the Lune attempt next time. Editing a spec retries it.

Writing a test

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,
}
  • Failures point at the line. Every failure shows the spec file and line number.
  • Stuck tests don't hang the run. Each test has its own timeout, so a WaitForChild that never returns fails one test.
  • Filter by anything. lune run test unit Economy runs a folder, a spec or a single test by name.
  • Strict by default. The runtime, matchers and templates all pass --!strict.
  • Matchers you expect. toBe, toEqual, toBeCloseTo, toContain, toThrow, toBeA and more, each with .never.

Test networking without opening Studio

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,
  • Two sides, one process. Code in network.client gets its own copy of every module, and RunService:IsClient() answers correctly for each side.
  • Delivered like Roblox. Messages arrive on a later frame with their arguments copied.
  • Handler errors fail the test. An error inside a remote handler isn't lost.
  • Spy on traffic. network.spy(remote) records everything sent through a remote.
  • Remotes from a table. Declare your remotes in the config and they exist in every test.

CI/CD

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 testIn CI?How
Unit specs, including simulated client ↔ serverYeslune run test unit
Server and shared specs needing the real engineYeslune run test --cloud
Anything needing a real player, character or UINoStudio, on your own machine

Unit tests on every push

# .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

Server specs on Roblox's servers

Do NOT use your production game's Universe ID or Place ID. Create a blank place and use its IDs instead.

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.

  1. Create a blank test place. In Studio: File → New → Baseplate, then File → Publish to Roblox.
  2. Copy its Universe ID and Start Place ID from the ⋯ menu on the Creator Dashboard.
  3. Add them to .lune/test.luau: cloud = { universeId = …, placeId = … },
  4. Create an Open Cloud API key with luau-execution-sessions → Write on only that test experience.
  5. Give the key to the run as the ROBLOX_API_KEY environment variable (a repository secret in CI). Never put it in a file.
  6. Run 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.

Built for AI coding agents too

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.

Claude Code

Picked up automatically when you ask for tests.

.claude/skills/lunetest-testing/

Codex

Same skill file, in Codex's skills folder. Unit tests run inside its sandbox.

.agents/skills/lunetest-testing/

Yours to edit

It's a plain Markdown file. Add your project's conventions and commit it for the whole team.

SKILL.md

Already use Wally?

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

The full reference (config, matchers, troubleshooting) is in the README.