{"id":"austinbarikdar/lunetest","name":"lunetest","scope":"austinbarikdar","platform":"roblox","description":"Run Roblox specs in real Studio play mode (server + client) from the command line with Lune and Rojo.","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"212368031df823fc90afd9605b7e76e509068878d7b48972b8801bf0874271fe","likes":0,"downloads":0,"install":"forest install austinbarikdar/lunetest","url":"https://forest.dev/p/roblox/austinbarikdar/lunetest","files":"https://api.forest.dev/ai/package/roblox/austinbarikdar/lunetest/files","readme":"# LuneTest\r\n\r\nA Roblox testing framework for Rojo projects that you run from the terminal with one command:\r\n\r\n```\r\n$ lune run test\r\n  PASS  [unit] Economy/CoinsSpec › adds coins\r\n  PASS  [unit] Network/PacketsSpec › batched packets go client -> server -> client\r\n  FAIL  [unit] Economy/CoinsSpec › rejects negative amounts\r\n        ReplicatedStorage.LuneTestSpecs.Unit.Economy.CoinsSpec:12: expected function to throw\r\n[lunetest] running 2 e2e specs in Studio...\r\n  PASS  [server] ExampleServerSpec › runs on the server\r\n  PASS  [client] ExampleClientSpec › has a character\r\n\r\n[lunetest] 12 passed, 1 failed (8.06s)\r\n```\r\n\r\nThere are two kinds of test:\r\n\r\n| Kind | Folder | Runs | Speed |\r\n| --- | --- | --- | --- |\r\n| **unit** | `tests/unit/` | Inside Lune, against your Rojo-built place. No Studio. | milliseconds |\r\n| **e2e** | `tests/e2e/{server,client,shared}/` | Tried in Lune first. Any spec that fails there is rerun in a real Studio Play session. | Instant if Lune passes, ~8s if Studio is needed |\r\n\r\nStudio only opens when at least one e2e spec actually needs it. When the run is over, the test place closes. If LuneTest launched Studio, Studio quits too.\r\n\r\nThe exit code is 0 when everything passes and 1 when something fails, so it works in scripts and CI.\r\n\r\n## Requirements\r\n\r\n- [Rojo](https://rojo.space) 7, [Lune](https://lune-org.github.io/docs) 0.10+ and [Wally](https://wally.run). With [Rokit](https://github.com/rojo-rbx/rokit): `rokit init` (if the project has no `rokit.toml` yet), then `rokit add rojo`, `rokit add lune`, `rokit add wally`.\r\n- For e2e specs only: Roblox Studio on macOS or Windows, signed in.\r\n\r\n## Setup\r\n\r\n1. Add LuneTest to `wally.toml`:\r\n\r\n   ```toml\r\n   [dev-dependencies]\r\n   LuneTest = \"austinbarikdar/lunetest@0.1.0\"\r\n   ```\r\n\r\n2. Install it and run `init` from your project root:\r\n\r\n   ```sh\r\n   wally install\r\n   lune run DevPackages/_Index/*/lunetest/lune/init.luau\r\n   ```\r\n\r\n   On Windows PowerShell:\r\n\r\n   ```powershell\r\n   wally install\r\n   lune run (Resolve-Path DevPackages/_Index/*/lunetest/lune/init.luau)\r\n   ```\r\n\r\n`init` never overwrites anything, so it's safe to run again. It creates:\r\n\r\n- **`.lune/test.luau`**: the launcher and your config. If you already have a `lune/` folder, it goes at `lune/test.luau` instead.\r\n- **`tests/`**: example unit and e2e specs.\r\n- **`.gitignore` entries**: for the generated `lunetest.project.json`, `lunetest.rbxl`, `lunetest.cache.json` and `lunetest.lock`, plus the `lunetest.rbxl.lock` Studio leaves behind on Windows.\r\n- **The Studio plugin**: `LuneTest.rbxmx` in your Studio Plugins folder. **Restart Studio once** if it was already open.\r\n\r\nLune runs `lune/test.luau` before `.lune/test.luau`. If you already have a `lune/test.luau`, `init` tells you instead of writing a second launcher that would never run.\r\n\r\n## Running\r\n\r\n```sh\r\nlune run test                        # everything\r\nlune run test unit                   # unit specs only (no Studio)\r\nlune run test e2e                    # e2e specs only\r\nlune run test unit Economy           # tests whose name contains \"Economy\"\r\nlune run test unit Economy Network   # ...or \"Network\"\r\nlune run test e2e server             # e2e tests whose name contains \"server\"\r\nlune run test --fresh                # retry every e2e spec in Lune, ignoring the cache\r\n```\r\n\r\nA test's full name is `[kind] Group/SubGroup/SpecName › test name`. Any subfolder is a group, so filters can pick a folder, a spec file or a single test.\r\n\r\n## Writing specs\r\n\r\nA spec is a ModuleScript whose name ends in `Spec`. Everything in LuneTest is written in `--!strict`, including `expect` and `LuneTest.network`, so your specs can be too. It returns a table of named test functions. Other modules in the spec folders are treated as helpers and never run.\r\n\r\n```lua\r\n--!strict\r\n-- tests/unit/Economy/CoinsSpec.luau\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal expect = require(ReplicatedStorage.LuneTest).expect\r\nlocal Coins = require(ReplicatedStorage.Shared.Coins)\r\n\r\nreturn {\r\n\t[\"adds coins\"] = function()\r\n\t\texpect(Coins.add(10, 5)).toBe(15)\r\n\tend,\r\n\r\n\t[\"rejects negative amounts\"] = function()\r\n\t\texpect(function()\r\n\t\t\tCoins.add(10, -1)\r\n\t\tend).toThrow(\"must be positive\")\r\n\tend,\r\n}\r\n```\r\n\r\n| Folder | Runs on | Lives in the test place at |\r\n| --- | --- | --- |\r\n| `tests/unit` | Lune | `ReplicatedStorage.LuneTestSpecs.Unit` |\r\n| `tests/e2e/server` | Studio server | `ServerScriptService.LuneTestSpecs.Server` |\r\n| `tests/e2e/shared` | Studio server | `ReplicatedStorage.LuneTestSpecs.Shared` |\r\n| `tests/e2e/client` | Studio client | `ReplicatedStorage.LuneTestSpecs.Client` |\r\n\r\nEach test runs in its own thread with a timeout (`testTimeout`, default 30s). A stuck `WaitForChild` fails only that test instead of hanging the whole run. Tests run in name order.\r\n\r\n### Matchers\r\n\r\n`toBe`, `toEqual` (deep), `toBeTruthy`, `toBeFalsy`, `toBeNil`, `toBeA(typeName)`, `toBeCloseTo(n, epsilon?)`, `toBeGreaterThan`, `toBeLessThan`, `toContain` (works on arrays and substrings), and `toThrow(substring?)`.\r\n\r\nPut `.never` in front of any matcher to negate it: `expect(x).never.toBeNil()`.\r\n\r\n### What unit specs can use\r\n\r\nUnit specs get:\r\n- **The Rojo-built place:** `game`, `workspace`, `script`, and `require` of any ModuleScript in it.\r\n- **Lune's Roblox types:** `Instance`, `Vector3`, `CFrame`, `Enum` and the rest.\r\n- **`task`.**\r\n- **The simulated network** (below).\r\n\r\nEngine behavior is not there: no physics, no characters, no DataStores. Put tests that need those in `e2e`.\r\n\r\n## Lune first, Studio fallback (e2e specs)\r\n\r\nMany specs written for Studio pass in Lune too. So LuneTest tries every e2e spec in Lune first:\r\n\r\n```\r\n  PASS  [server] ExampleServerSpec › runs on the server  (lune)\r\n[lunetest] 2 of 3 e2e specs need Studio\r\n[lunetest] running in Studio...\r\n  PASS  [server] Combat/RaycastSpec › ray into empty space hits nothing  (studio)\r\n  PASS  [client] ExampleClientSpec › has a character  (studio)\r\n```\r\n\r\n- **In Lune:** server and shared specs run as the simulated server, and client specs run as the simulated client.\r\n- **A spec with any failure in Lune is rerun in Studio.** This covers a real bug, a missing engine feature like `workspace:Raycast`, and a character that doesn't exist. Studio's result is final, so a gap in Lune can never produce a false failure.\r\n- **The Lune attempt is fast.** A spec stops at its first failure, and each test gets 5 seconds, so specs that need Studio hand over quickly.\r\n- **Studio only runs the specs that fell back,** and it doesn't open at all if everything passed in Lune.\r\n- **Each line says where the test ran:** `(lune)` or `(studio)`.\r\n- **Studio-only specs are remembered.** LuneTest saves which specs needed Studio in `lunetest.cache.json`, keyed by a fingerprint of each spec file. On the next run they go straight to Studio without retrying in Lune. Editing a spec file makes LuneTest retry it in Lune.\r\n- **`lune run test --fresh` retries everything in Lune.** The fingerprint only covers the spec file itself, so use this after fixing a module a cached spec depends on.\r\n\r\nSet `fallback = false` in the config to always run e2e specs in Studio.\r\n\r\n## Simulated network (unit specs)\r\n\r\n`LuneTest.network` runs client and server code together inside Lune, so networking and packet code can be tested without opening Studio.\r\n\r\n```lua\r\n--!strict\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal LuneTest = require(ReplicatedStorage.LuneTest)\r\nlocal expect, network = LuneTest.expect, LuneTest.network\r\n\r\nreturn {\r\n\t[\"client -> server -> client\"] = function()\r\n\t\tlocal fromPlayer, echoed\r\n\t\tnetwork.server(function()\r\n\t\t\tlocal Packets = require(ReplicatedStorage.Shared.Packets)\r\n\t\t\tPackets.on(\"Hello\", function(data, player)\r\n\t\t\t\tfromPlayer = player\r\n\t\t\t\tPackets.send(\"Echo\", { n = data.n + 1 })\r\n\t\t\tend)\r\n\t\tend)\r\n\t\tnetwork.client(function()\r\n\t\t\tlocal Packets = require(ReplicatedStorage.Shared.Packets)\r\n\t\t\tPackets.on(\"Echo\", function(data)\r\n\t\t\t\techoed = data\r\n\t\t\tend)\r\n\t\t\tPackets.send(\"Hello\", { n = 1 })\r\n\t\tend)\r\n\r\n\t\tnetwork.flush()\r\n\t\texpect(fromPlayer).toBe(network.player())\r\n\t\texpect(echoed).toEqual({ n = 2 })\r\n\tend,\r\n}\r\n```\r\n\r\n| API | What it does |\r\n| --- | --- |\r\n| `network.client(fn, ...)` | Runs `fn` as the client and returns what it returns. `RunService:IsClient()` is true and `Players.LocalPlayer` is set. |\r\n| `network.server(fn, ...)` | Runs `fn` as the server. Spec code outside either call also counts as the server. |\r\n| `network.flush(frames?)` | Waits a few frames (default 3) so queued remotes and `Heartbeat` batches get delivered, then re-throws any error a handler hit. |\r\n| `network.spy(remote)` | Returns a live list of `{ direction = \"toServer\" \\| \"toClient\", args = {...} }` for everything sent through `remote`. |\r\n| `network.player()` | The simulated player. It joins, firing `Players.PlayerAdded`, the first time client code runs. From then on, `Players.LocalPlayer` is set for server code too (one shared `Players` service). |\r\n\r\nHow it behaves:\r\n- **Modules load once per side.** Code inside `network.client` gets its own copy of every module, just like a real client.\r\n- **Remotes behave like Roblox.** `RemoteEvent`, `UnreliableRemoteEvent` and `RemoteFunction` (`FireServer`, `FireClient`, `FireAllClients`, `InvokeServer`, `InvokeClient`) deliver on the next frame and copy their arguments. Events sent before anyone connects are queued.\r\n- **Frame events tick.** `RunService.Heartbeat`, `Stepped`, `PostSimulation` and the other frame events fire every frame, so packet libraries that batch per frame (ByteNet, Packet, custom ones) work without any setup.\r\n- **Handler errors fail the test.** If a remote handler errors, the next `network.flush()`, `network.client()` or `network.server()` fails with that error.\r\n\r\n### Custom packet handlers\r\n\r\nNo setup is needed for libraries built on remotes. If your packet module lets you replace its transport, you can test the logic without remotes instead. Call the server handler directly inside `network.server(function() ... end)` with the data your client code produced in `network.client`.\r\n\r\n## Remotes table\r\n\r\nIf your game keeps its remotes in a table, declare it in the launcher config. LuneTest creates the instances in the test place, so they exist in unit **and** e2e runs:\r\n\r\n```lua\r\nremotes = {\r\n\t[\"ReplicatedStorage.Remotes\"] = {\r\n\t\t\"Chat\",                        -- a bare name is a RemoteEvent\r\n\t\tGetData = \"RemoteFunction\",\r\n\t\tMove = \"UnreliableRemoteEvent\",\r\n\t},\r\n},\r\n```\r\n\r\nThe keys are parent paths. Folders along the path are created if they don't exist, and if the folder is already in your project, the remotes are added to it.\r\n\r\n## Config\r\n\r\nThe config is the table at the top of `.lune/test.luau`:\r\n\r\n| Key | Default | Meaning |\r\n| --- | --- | --- |\r\n| `project` | `\"default.project.json\"` | Base Rojo project. Your file is never changed. LuneTest writes `lunetest.project.json` next to it. |\r\n| `unit` | `\"tests/unit\"` | Unit spec folder |\r\n| `e2e` | `\"tests/e2e\"` | Folder holding `server/`, `client/` and `shared/` |\r\n| `testTimeout` | `30` | Seconds before a single test counts as hung |\r\n| `timeout` | `300` | Seconds to wait for Studio to report back |\r\n| `clientTimeout` | `90` | Seconds the Studio client gets to *start*. Once started, it's only limited by per-test timeouts. |\r\n| `port` | `44774` | First port tried for Studio's results. If it's taken, the next free one is used. |\r\n| `remotes` | none | See [Remotes table](#remotes-table) |\r\n| `fallback` | `true` | Try e2e specs in Lune first and send only the failures to Studio |\r\n\r\nIf your project is a library, meaning its tree isn't a `DataModel`, LuneTest mounts it at `ReplicatedStorage.<project name>`.\r\n\r\n## Reliability on slow machines\r\n\r\n- **Starting Play:** the plugin starts Play through `StudioTestService` and retries with backoff. It never sends keystrokes, so it can't press Play in the wrong window.\r\n- **Client startup:** the client reports \"started\" as soon as it loads. The server waits for that signal, not for a fixed amount of time, so a slow client is never cut off halfway through.\r\n- **Stale results:** each run has a random ID, and results from an old or unrelated Studio are ignored.\r\n- **Parallel runs:** if the port is taken, the next free one is used, so two projects can run at the same time.\r\n- **One run per project:** a second `lune run test` in the same project stops right away instead of fighting over the test place and Studio windows. A lock left behind by a crashed or Ctrl+C'd run is detected and taken over automatically.\r\n- **Updated plugin:** if the plugin was just updated while Studio is open, the run stops immediately and tells you to restart Studio, instead of waiting for the timeout.\r\n- **Back-to-back runs on macOS:** if `open` hands the place to a Studio that's still quitting and nothing launches, LuneTest notices and opens it again.\r\n\r\n## Troubleshooting\r\n\r\n- **\"updated the LuneTest Studio plugin…\"**: close Studio and run again.\r\n- **`no results from Studio`**: check Studio's Output window. The runners print `[LuneTest] N passed, M failed` when they finish.\r\n- **A unit spec says something `is not a valid member`**: that's engine behavior Lune doesn't have. Move the spec to `tests/e2e`.\r\n\r\n## Developing LuneTest\r\n\r\n`example/` is a small project that uses every feature. Wally has no local path dependencies, so link this repo into it by hand:\r\n\r\n```sh\r\nmkdir -p example/DevPackages/_Index/austinbarikdar_lunetest@0.1.0\r\nln -s \"$PWD\" example/DevPackages/_Index/austinbarikdar_lunetest@0.1.0/lunetest\r\ncd example && lune run test\r\n```\r\n\r\nOn Windows PowerShell, use a junction instead (no admin rights needed):\r\n\r\n```powershell\r\nNew-Item -ItemType Directory -Force example/DevPackages/_Index/austinbarikdar_lunetest@0.1.0\r\nNew-Item -ItemType Junction -Path example/DevPackages/_Index/austinbarikdar_lunetest@0.1.0/lunetest -Target $PWD\r\ncd example; lune run test\r\n```\r\n\r\nType-check everything in strict mode (needs Roblox's `globalTypes.d.luau` from the luau-lsp repo, and `lune setup` for the `.luaurc` alias):\r\n\r\n```sh\r\ncd example && rojo sourcemap lunetest.project.json -o sourcemap.json\r\nluau-lsp analyze --platform=roblox --sourcemap=sourcemap.json --definitions=globalTypes.d.luau src tests DevPackages/_Index/*/lunetest/src DevPackages/_Index/*/lunetest/runners DevPackages/_Index/*/lunetest/lune/plugin.luau\r\ncd .. && luau-lsp analyze --platform=standard lune/cli.luau lune/network.luau lune/init.luau example/.lune/test.luau\r\n```\r\n\r\nTo publish: `wally login`, then `wally publish`.\r\n","readmeTruncated":false}