Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

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 Framework

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.

Installation

[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"
Run wally install. The package lands in DevPackages/. Sync it into Studio via your Rojo project file, typically under ReplicatedStorage.DevPackages.

Test File Conventions

  • Test files use the suffix .spec.luau (e.g., CurrencyManager.spec.luau).
  • Each spec file lives alongside or mirrors the module it tests.
  • TestEZ discovers specs by recursively scanning root containers you pass to TestBootstrap:run.

Core Syntax

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

Assertion API

expect(value).to.equal(expected)          -- strict equality
expect(value).to.be.ok()                  -- truthy
expect(value).to.be.a("table")            -- type check
expect(value).never.to.equal(unexpected)  -- negation
expect(function()
    error("boom")
end).to.throw()                           -- error expected
expect(value).to.be.near(3.14, 0.01)      -- float tolerance

Running Tests Inside Studio

Create a test runner script in ServerScriptService:
local TestEZ = require(game.ReplicatedStorage.DevPackages.TestEZ)
local results = TestEZ.TestBootstrap:run({
    game.ReplicatedStorage.Shared,        -- scan these containers
    game.ServerScriptService.Server,
})

Unit Testing ModuleScripts

Unit tests target pure logic — functions that take inputs and return outputs without touching engine APIs.

Good Candidates for Unit Testing

Module TypeExamples
Damage calculationcalculateDamage(baseDmg, armor, crit)
Inventory operationsaddItem(inventory, itemId, qty)
Data transformsserializeLoadout(loadout) / deserializeLoadout(raw)
Config validatorsvalidateWeaponConfig(config)
Math/utilityclamp, lerp, formatNumber

Keep Modules Testable

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 engine
function Module.getPlayerHealth(player)
    local char = player.Character
    local humanoid = char:FindFirstChildOfClass("Humanoid")
    return humanoid.Health
end

-- GOOD: testable, accepts the value it needs
function Module.isLowHealth(currentHealth: number, threshold: number): boolean
    return currentHealth <= threshold
end

Pattern: Pure Module with Tests

Module under test (DamageCalc.luau):
local DamageCalc = {}

function DamageCalc.calculate(baseDamage: number, armor: number, isCrit: boolean): number
    local reduction = math.clamp(armor / 100, 0, 0.75)
    local damage = baseDamage * (1 - reduction)
    if isCrit then
        damage *= 2
    end
    return math.floor(damage)
end

return DamageCalc
Spec (DamageCalc.spec.luau):
return function()
    local DamageCalc = require(script.Parent.DamageCalc)

    describe("calculate", function()
        it("should apply armor reduction", function()
            -- 50 armor = 50% reduction on 100 base = 50
            expect(DamageCalc.calculate(100, 50, false)).to.equal(50)
        end)

        it("should cap armor reduction at 75%", function()
            expect(DamageCalc.calculate(100, 200, false)).to.equal(25)
        end)

        it("should double damage on crit", function()
            expect(DamageCalc.calculate(100, 0, true)).to.equal(200)
        end)

        it("should floor the result", function()
            expect(DamageCalc.calculate(33, 10, false)).to.equal(29)
        end)
    end)
end

Dependency Injection for Testability

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.

Constructor Injection

local InventoryManager = {}
InventoryManager.__index = InventoryManager

-- Accept services through the constructor
function InventoryManager.new(dataStoreService, messagingService)
    local self = setmetatable({}, InventoryManager)
    self._dataStore = dataStoreService:GetDataStore("Inventory")
    self._messaging = messagingService
    return self
end

function InventoryManager:saveInventory(playerId: number, inventory: { [string]: number })
    local key = "inv_" .. tostring(playerId)
    self._dataStore:SetAsync(key, inventory)
end

function InventoryManager:loadInventory(playerId: number): { [string]: number }
    local key = "inv_" .. tostring(playerId)
    return self._dataStore:GetAsync(key) or {}
end

return InventoryManager
Production wiring (real services):
local DataStoreService  = game:GetService("DataStoreService")
local MessagingService  = game:GetService("MessagingService")
local InventoryManager  = require(path.to.InventoryManager)

local manager = InventoryManager.new(DataStoreService, MessagingService)
Test wiring (inject mocks):
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
local InventoryManager     = require(script.Parent.InventoryManager)

local manager = InventoryManager.new(MockDataStoreService.new(), mockMessaging)

Module-Level Injection via .init()

For singleton modules:
local Module = {}
local _players = nil

function Module.init(playersService)
    _players = playersService
end

function Module.getPlayerCount(): number
    return #_players:GetPlayers()
end

return Module

Mocking Roblox Services

Mock objects stand in for real Roblox services during tests, providing the same interface without touching the engine.
local MockSignal = {}
MockSignal.__index = MockSignal

function MockSignal.new()
    return setmetatable({ _connections = {} }, MockSignal)
end

function MockSignal:Connect(callback)
    table.insert(self._connections, callback)
    return {
        Disconnect = function()
            local idx = table.find(self._connections, callback)
            if idx then
                table.remove(self._connections, idx)
            end
        end,
    }
end

function MockSignal:Fire(...)
    for _, cb in self._connections do
        task.spawn(cb, ...)
    end
end

return MockSignal

Integration Testing

Integration tests verify that multiple modules work together correctly — data flows across boundaries and side effects happen as expected.

Pattern: DataManager + InventoryManager

return function()
    local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
    local DataManager          = require(script.Parent.DataManager)
    local InventoryManager     = require(script.Parent.InventoryManager)

    describe("DataManager + InventoryManager integration", function()
        local mockDSS
        local dataMgr
        local invMgr

        beforeEach(function()
            mockDSS = MockDataStoreService.new()
            dataMgr = DataManager.new(mockDSS)
            invMgr  = InventoryManager.new(mockDSS)
        end)

        it("should persist inventory through save/load cycle", function()
            local playerId  = 12345
            local inventory = { Sword = 1, Shield = 2, Potion = 10 }

            invMgr:saveInventory(playerId, inventory)
            local loaded = invMgr:loadInventory(playerId)

            expect(loaded.Sword).to.equal(1)
            expect(loaded.Shield).to.equal(2)
            expect(loaded.Potion).to.equal(10)
        end)

        it("should return empty inventory for new player", function()
            local loaded = invMgr:loadInventory(99999)
            expect(next(loaded)).to.equal(nil)
        end)
    end)
end

Testing RemoteEvent Flows

it("should process purchase request and respond", function()
    local remote    = MockRemoteEvent.new()
    local responded = false

    -- Simulate server handler
    remote.OnServerEvent:Connect(function(_player, itemId)
        local success = ShopManager:tryPurchase(itemId, playerData)
        remote:FireClient(nil, success, itemId)
    end)

    -- Capture client response
    remote.OnClientEvent:Connect(function(success, itemId)
        responded = true
        expect(success).to.equal(true)
        expect(itemId).to.equal("Sword")
    end)

    remote:FireServer("Sword")
    expect(responded).to.equal(true)
end)

Integration Testing with MCP

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.

Iterative Debug Loop

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.

What to Test: Server vs. Client

Test on the Server

  • Data save/load and serialization
  • Economy logic (purchases, transfers, balances)
  • Server-side RemoteEvent validation (bad inputs, range checks)
  • Combat damage calculations
  • Matchmaking and session management

Test on the Client (or Shared)

  • UI state transformations
  • Input parsing and mapping
  • Format utilities (formatGold, formatTime)
  • Pure math/utility shared modules
  • Config validators

Test Critical Paths First

Prioritize tests where bugs cost the most:
  1. Data save/load — A bug here can wipe player progress. Test serialization, deserialization, migration, and edge cases (empty data, corrupt data).
  2. Purchases and monetization — Receipt processing must be idempotent. Test duplicate receipts, granting after purchase, and failure recovery.
  3. Combat damage / core gameplay math — Players notice immediately when numbers are wrong. Test crit, armor, buffs, and edge values.
  4. Server-side RemoteEvent validation — Test with out-of-range values, wrong types, and nil inputs.

Full Example: CurrencyManager

Module (CurrencyManager.luau)

local CurrencyManager = {}
CurrencyManager.__index = CurrencyManager

export type CurrencyData = {
    gold: number,
    gems: number,
}

function CurrencyManager.new(dataStoreService)
    local self = setmetatable({}, CurrencyManager)
    self._store = dataStoreService:GetDataStore("Currency")
    self._cache = {} :: { [number]: CurrencyData }
    return self
end

function CurrencyManager.newPlayerData(): CurrencyData
    return {
        gold = 0,
        gems = 0,
    }
end

function CurrencyManager:loadPlayer(playerId: number): CurrencyData
    local raw  = self._store:GetAsync("currency_" .. tostring(playerId))
    local data = raw or CurrencyManager.newPlayerData()
    self._cache[playerId] = data
    return data
end

function CurrencyManager:savePlayer(playerId: number)
    local data = self._cache[playerId]
    if data then
        self._store:SetAsync("currency_" .. tostring(playerId), data)
    end
end

function CurrencyManager:getGold(playerId: number): number
    local data = self._cache[playerId]
    return if data then data.gold else 0
end

function 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 true
end

function 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 true
end

function 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 true
end

return CurrencyManager

Test Suite (CurrencyManager.spec.luau)

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

Test Organization

project/
  src/
    server/
      DataManager.luau
      InventoryManager.luau
      CurrencyManager.luau
    shared/
      DamageCalc.luau
      Utils.luau
    client/
      UIController.luau
  tests/
    server/
      DataManager.spec.luau
      InventoryManager.spec.luau
      CurrencyManager.spec.luau
    shared/
      DamageCalc.spec.luau
      Utils.spec.luau
    mocks/
      MockDataStoreService.luau
      MockPlayers.luau
      MockSignal.luau
      MockRemoteEvent.luau
    init.spec.luau        -- test runner entry

Naming Conventions

ConventionExample
Spec file suffixCurrencyManager.spec.luau
Mock file prefixMockDataStoreService.luau
Describe blockdescribe("CurrencyManager")
Test nameit("should deduct gold on purchase")

CI/CD Integration

Running Tests Headlessly with Lune

Lune is a standalone Luau runtime that can execute TestEZ specs outside of Roblox Studio. Create a runner script:
-- run-tests.luau
local process = require("@lune/process")

local TestEZ = require("./DevPackages/TestEZ")

local results = TestEZ.TestBootstrap:run({
    "./tests/server",
    "./tests/shared",
})

if results.failureCount > 0 then
    process.exit(1)
end

GitHub Actions Workflow

name: Roblox CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Aftman
        uses: ok-nick/setup-aftman@v0.4.2

      - name: Install Wally packages
        run: wally install

      - name: Selene lint
        run: selene src/ tests/

      - name: StyLua format check
        run: stylua --check src/ tests/

      - name: Run TestEZ tests via Lune
        run: lune run run-tests.luau

      - name: Build Roblox place file
        run: rojo build default.project.json -o game.rbxl

Testing Anti-Patterns

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.
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.
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().
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.
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.

Build docs developers (and LLMs) love