Testing Patterns: Unit and Integration Tests for Roblox Luau
Roblox testing patterns covering TestEZ unit tests, integration testing via MCP play mode, mock service patterns, and CI/CD strategies for Luau codebases.
Use this file to discover all available pages before exploring further.
Testing in Roblox is non-trivial because game code depends heavily on engine services — Players, DataStoreService, ReplicatedStorage, and dozens more — that are unavailable outside Studio. The patterns in this reference show how to write testable Luau code, mock those services for unit tests, drive automated smoke tests through the MCP play mode, and integrate everything into a CI/CD pipeline that catches regressions before they reach players.
TestEZ is Roblox’s official BDD-style testing framework. Add it as a dev dependency in wally.toml and it is available inside Studio for running specs against live game code.
return function() describe("CurrencyManager", function() local CurrencyManager beforeEach(function() -- Fresh module state before every test CurrencyManager = require(script.Parent.CurrencyManager) end) afterEach(function() -- Teardown: reset any shared state end) it("should initialize a player with zero gold", function() local data = CurrencyManager.newPlayerData() expect(data.gold).to.equal(0) end) it("should add currency correctly", function() local data = CurrencyManager.newPlayerData() CurrencyManager.addGold(data, 100) expect(data.gold).to.equal(100) end) it("should never allow negative gold", function() local data = CurrencyManager.newPlayerData() CurrencyManager.addGold(data, 50) CurrencyManager.removeGold(data, 999) expect(data.gold).to.equal(0) end) end)end
The key rule: avoid calling game:GetService() or accessing game.* directly inside functions you want to unit test. Extract engine interactions to the boundary and pass data in.
-- BAD: untestable, reaches into the enginefunction Module.getPlayerHealth(player) local char = player.Character local humanoid = char:FindFirstChildOfClass("Humanoid") return humanoid.Healthend-- GOOD: testable, accepts the value it needsfunction Module.isLowHealth(currentHealth: number, threshold: number): boolean return currentHealth <= thresholdend
When a module must interact with a Roblox service, inject the service as a parameter instead of hard-coding game:GetService(). This makes the module testable with mock objects.
When Roblox Studio MCP tools are available, use them for automated smoke testing without leaving your editor. This catches runtime errors that unit tests can’t see — missing instances, bad requires, infinite yields.
1
Start the playtest
Call start_playtest() to launch a local test server in Studio.
2
Wait for initialization
Wait 5–10 seconds for all scripts to initialize and for the game loop to start.
3
Read console output
Call get_playtest_output() to capture the Output window logs. Scan for:
Red error lines (runtime errors)
"attempt to index nil" — missing references
"Infinite yield possible" — WaitForChild timeouts
"HTTP 429" — DataStore throttling
"not a valid member" — renamed or missing API members
4
Stop and iterate
Call stop_playtest(). If errors were found, apply fixes and repeat.
REPEAT: 1. Apply code fix 2. start_playtest() 3. get_playtest_output() — scan for the specific error you are fixing 4. If error persists → stop_playtest(), refine fix, go to 1 5. If error gone → stop_playtest(), move on
Do not use execute_luau / run_code to modify the DataModel while a playtest is active — changes will be lost when the playtest ends. Always stop the playtest before applying fixes.
local CurrencyManager = {}CurrencyManager.__index = CurrencyManagerexport type CurrencyData = { gold: number, gems: number,}function CurrencyManager.new(dataStoreService) local self = setmetatable({}, CurrencyManager) self._store = dataStoreService:GetDataStore("Currency") self._cache = {} :: { [number]: CurrencyData } return selfendfunction CurrencyManager.newPlayerData(): CurrencyData return { gold = 0, gems = 0, }endfunction CurrencyManager:loadPlayer(playerId: number): CurrencyData local raw = self._store:GetAsync("currency_" .. tostring(playerId)) local data = raw or CurrencyManager.newPlayerData() self._cache[playerId] = data return dataendfunction CurrencyManager:savePlayer(playerId: number) local data = self._cache[playerId] if data then self._store:SetAsync("currency_" .. tostring(playerId), data) endendfunction CurrencyManager:getGold(playerId: number): number local data = self._cache[playerId] return if data then data.gold else 0endfunction CurrencyManager:addGold(playerId: number, amount: number): boolean if amount <= 0 then return false end local data = self._cache[playerId] if not data then return false end data.gold += amount return trueendfunction CurrencyManager:removeGold(playerId: number, amount: number): boolean if amount <= 0 then return false end local data = self._cache[playerId] if not data then return false end if data.gold < amount then return false end data.gold -= amount return trueendfunction CurrencyManager:transferGold(fromId: number, toId: number, amount: number): boolean if not self:removeGold(fromId, amount) then return false end if not self:addGold(toId, amount) then self:addGold(fromId, amount) -- rollback return false end return trueendreturn CurrencyManager
return function() local MockDataStoreService = require(script.Parent.Parent.Mocks.MockDataStoreService) local CurrencyManager = require(script.Parent.CurrencyManager) describe("CurrencyManager", function() local mockDSS local manager beforeEach(function() mockDSS = MockDataStoreService.new() manager = CurrencyManager.new(mockDSS) end) describe("newPlayerData", function() it("should return zero gold and zero gems", function() local data = CurrencyManager.newPlayerData() expect(data.gold).to.equal(0) expect(data.gems).to.equal(0) end) end) describe("loadPlayer", function() it("should return default data for a new player", function() local data = manager:loadPlayer(1001) expect(data.gold).to.equal(0) end) it("should return saved data for an existing player", function() local store = mockDSS:GetDataStore("Currency") store:SetAsync("currency_1001", { gold = 500, gems = 10 }) local data = manager:loadPlayer(1001) expect(data.gold).to.equal(500) expect(data.gems).to.equal(10) end) end) describe("addGold", function() it("should increase gold by the given amount", function() manager:loadPlayer(1001) local ok = manager:addGold(1001, 100) expect(ok).to.equal(true) expect(manager:getGold(1001)).to.equal(100) end) it("should reject zero amount", function() manager:loadPlayer(1001) local ok = manager:addGold(1001, 0) expect(ok).to.equal(false) end) it("should reject negative amount", function() manager:loadPlayer(1001) local ok = manager:addGold(1001, -50) expect(ok).to.equal(false) end) end) describe("removeGold", function() it("should decrease gold by the given amount", function() manager:loadPlayer(1001) manager:addGold(1001, 200) local ok = manager:removeGold(1001, 50) expect(ok).to.equal(true) expect(manager:getGold(1001)).to.equal(150) end) it("should reject removal exceeding balance", function() manager:loadPlayer(1001) manager:addGold(1001, 30) local ok = manager:removeGold(1001, 50) expect(ok).to.equal(false) expect(manager:getGold(1001)).to.equal(30) -- unchanged end) end) describe("transferGold", function() it("should move gold from one player to another", function() manager:loadPlayer(1001) manager:loadPlayer(1002) manager:addGold(1001, 500) local ok = manager:transferGold(1001, 1002, 200) expect(ok).to.equal(true) expect(manager:getGold(1001)).to.equal(300) expect(manager:getGold(1002)).to.equal(200) end) it("should fail if sender has insufficient funds", function() manager:loadPlayer(1001) manager:loadPlayer(1002) manager:addGold(1001, 50) local ok = manager:transferGold(1001, 1002, 100) expect(ok).to.equal(false) expect(manager:getGold(1001)).to.equal(50) -- unchanged expect(manager:getGold(1002)).to.equal(0) -- unchanged end) it("should rollback if recipient is not loaded", function() manager:loadPlayer(1001) manager:addGold(1001, 500) local ok = manager:transferGold(1001, 9999, 100) expect(ok).to.equal(false) expect(manager:getGold(1001)).to.equal(500) -- rolled back end) end) end)end
Problem: You click around in Studio, it seems to work, you ship. A week later an edge case surfaces in production.Fix: Write automated tests for core logic. Manual playtesting supplements automated tests; it does not replace them.
No tests for monetization code
Problem: Receipt processing is written once, never tested, and breaks silently. Players pay real money and receive nothing.Fix: Unit test processReceipt with a mock MarketplaceService. Test duplicate receipts, every product ID, and all failure paths.
Untestable tightly-coupled modules
Problem: Everything is hardcoded with game:GetService() calls inline, making it impossible to test without a live game.Fix: Break it apart. Pure logic in one module, engine glue in another. Inject services via constructor or init().
Tests that depend on execution order
Problem: Test B passes only if Test A runs first because A sets up shared state.Fix: Use beforeEach to create fresh state for every test. Each test must be independently runnable.
Ignoring flaky tests
Problem: A test sometimes passes and sometimes fails. The team marks it as “known flaky” and ignores it.Fix: Flaky tests usually indicate shared mutable state, timing issues, or race conditions. Fix the root cause or delete the test — a flaky test erodes trust in the entire suite.