ワンクリックで
roblox-testing
TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Service hierarchy, 7 foundational patterns, cross-platform input. Client-server architecture, module patterns, framework options.
Luau language fundamentals, type system, OOP, deprecation table, error patterns.
Roblox AnalyticsService: custom events, economy tracking, funnels, rate limits, event taxonomy.
Animations, particles, tweens, ContentProvider, visual effects.
Code review with security, performance, and monetization lenses for Roblox projects
DataStores, ProfileStore, session locking, data persistence patterns.
| name | roblox-testing |
| description | TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox. |
| last_reviewed | "2026-05-21T00:00:00.000Z" |
Load this reference when:
Testing in Roblox is non-trivial because game code depends heavily on engine services (Players, DataStoreService, ReplicatedStorage, etc.) that are unavailable outside Studio. The patterns here show how to write testable code, mock those services, and automate verification at every stage of development.
Load Full Reference below only when you need specific mock implementations or test patterns.
Key rules:
describe/it/expect. Files named *.spec.luau.beforeEach for fresh state per test. Never share mutable state between it blocks.TestEZ is vendored in this harness at
vendor/testez/(v0.4.2, Apache 2.0). The agent can place it into your project when you need testing. No Wally install required.
.spec.luau (e.g., CurrencyManager.spec.luau).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
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
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 tests target pure logic - functions that take inputs and return outputs without touching engine APIs.
| Module Type | Examples |
|---|---|
| Damage calculation | calculateDamage(baseDmg, armor, crit) |
| Inventory operations | addItem(inventory, itemId, qty) |
| Data transforms | serializeLoadout(loadout) / deserializeLoadout(raw) |
| Config validators | validateWeaponConfig(config) |
| Math/utility | clamp, lerp, formatNumber |
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
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
When a module must interact with a Roblox service, inject the service as a parameter instead of hard-coding game:GetService().
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_{playerId}`
self._dataStore:SetAsync(key, inventory)
end
function InventoryManager:loadInventory(playerId: number): { [string]: number }
local key = `inv_{playerId}`
return self._dataStore:GetAsync(key) or {}
end
return InventoryManager
local DataStoreService = game:GetService("DataStoreService")
local MessagingService = game:GetService("MessagingService")
local InventoryManager = require(path.to.InventoryManager)
local manager = InventoryManager.new(DataStoreService, MessagingService)
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
local InventoryManager = require(script.Parent.InventoryManager)
local manager = InventoryManager.new(MockDataStoreService.new(), mockMessaging)
.init()For modules that are singletons rather than classes:
local Module = {}
local _players = nil
function Module.init(playersService)
_players = playersService
end
function Module.getPlayerCount(): number
return #_players:GetPlayers()
end
return Module
local MockPlayers = {}
MockPlayers.__index = MockPlayers
function MockPlayers.new()
local self = setmetatable({}, MockPlayers)
self._players = {}
self.PlayerAdded = MockSignal.new()
self.PlayerRemoving = MockSignal.new()
return self
end
function MockPlayers:GetPlayers()
return self._players
end
function MockPlayers:addFakePlayer(mockPlayer)
table.insert(self._players, mockPlayer)
self.PlayerAdded:Fire(mockPlayer)
end
function MockPlayers:removeFakePlayer(mockPlayer)
local idx = table.find(self._players, mockPlayer)
if idx then
table.remove(self._players, idx)
self.PlayerRemoving:Fire(mockPlayer)
end
end
return MockPlayers
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(conn)
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
local MockDataStore = {}
MockDataStore.__index = MockDataStore
function MockDataStore.new()
return setmetatable({ _data = {} }, MockDataStore)
end
function MockDataStore:GetAsync(key)
return self._data[key]
end
function MockDataStore:SetAsync(key, value)
self._data[key] = value
end
function MockDataStore:UpdateAsync(key, transformFunction)
local old = self._data[key]
self._data[key] = transformFunction(old)
end
function MockDataStore:RemoveAsync(key)
self._data[key] = nil
end
-- ------------------------------------------------
local MockDataStoreService = {}
MockDataStoreService.__index = MockDataStoreService
function MockDataStoreService.new()
return setmetatable({ _stores = {} }, MockDataStoreService)
end
function MockDataStoreService:GetDataStore(name)
if not self._stores[name] then
self._stores[name] = MockDataStore.new()
end
return self._stores[name]
end
return MockDataStoreService
local MockMarketplaceService = {}
MockMarketplaceService.__index = MockMarketplaceService
function MockMarketplaceService.new(ownedGamepasses: { [number]: boolean }?)
local self = setmetatable({}, MockMarketplaceService)
self._ownedPasses = ownedGamepasses or {}
self.PromptGamePassPurchaseFinished = MockSignal.new()
return self
end
function MockMarketplaceService:UserOwnsGamePassAsync(_userId, gamePassId)
return self._ownedPasses[gamePassId] == true
end
return MockMarketplaceService
Integration tests verify that multiple modules work together correctly - data flows across boundaries and side effects happen as expected.
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
For end-to-end remote flows in a test environment, create mock remotes:
local MockRemoteEvent = {}
MockRemoteEvent.__index = MockRemoteEvent
function MockRemoteEvent.new()
local self = setmetatable({}, MockRemoteEvent)
self.OnServerEvent = MockSignal.new()
self.OnClientEvent = MockSignal.new()
return self
end
function MockRemoteEvent:FireServer(...)
self.OnServerEvent:Fire(nil, ...) -- nil = fake player in test
end
function MockRemoteEvent:FireClient(_player, ...)
self.OnClientEvent:Fire(...)
end
function MockRemoteEvent:FireAllClients(...)
self.OnClientEvent:Fire(...)
end
return MockRemoteEvent
Usage in a test:
it("should process purchase request and respond", function()
local remote = MockRemoteEvent.new()
local responded = false
-- Simulate server handler
remote.OnServerEvent:Connect(function(_player, itemId)
-- Server validates and responds
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)
When Roblox Studio MCP tools are available, use them for automated smoke testing without leaving your editor.
1. start_playtest()
- Launches a local test server in Studio
2. wait 5-10 seconds for game initialization
3. get_playtest_output()
- Captures Output window logs
- Look for: errors, warnings, "Script timeout" messages
4. Analyze the output
- Any red error lines? -> investigate and fix
- Module load failures? -> check requires and paths
- DataStore errors? -> check mock/fallback setup
5. stop_playtest()
- Ends the session cleanly
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
After get_playtest_output(), filter for critical patterns:
"error" or "Error" - runtime errors"attempt to index nil" - missing references"Infinite yield possible" - WaitForChild timeouts"HTTP 429" - DataStore throttling"not a valid member" - API misuse or renamed propertiesAutomated tests do not cover everything. Use this checklist for manual playtesting:
ServerScriptService/
Tests/
TestRunner (Script)
Specs/
CurrencyManager.spec
DamageCalc.spec
Mocks/
MockDataStoreService
MockPlayers
| Convention | Example |
|---|---|
| Spec file suffix | CurrencyManager.spec.luau |
| Mock file prefix | MockDataStoreService.luau |
| Describe block | describe("CurrencyManager") |
| Test name | it("should deduct gold on purchase") |
Prioritize tests for code where bugs cost the most:
task.wait(), you are likely testing integration-level behavior - separate it from unit tests."should add gold" should test adding gold, not also test removing gold and checking the balance format.math.random() in tests. Use fixed seed values or hardcoded inputs so failures are reproducible.beforeEach. Never let one test depend on the side effects of another.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 mock MarketplaceService. Test duplicate receipts, test every product ID, test failure paths.
Problem:
-- Everything is hardcoded; impossible to test without a live game
function Module.onPlayerJoin()
local player = game.Players.LocalPlayer
local data = game:GetService("DataStoreService"):GetDataStore("Main"):GetAsync(player.UserId)
local gui = player.PlayerGui:WaitForChild("MainUI")
gui.GoldLabel.Text = tostring(data.gold)
end
Fix: Break it apart. Pure logic in one module, engine glue in another. Inject services.
-- Pure logic (testable)
function CurrencyFormatter.formatGold(gold: number): string
return tostring(gold)
end
-- Glue code (thin, not unit tested, covered by integration/manual tests)
function UIController.updateGoldDisplay(player, gold)
local gui = player.PlayerGui:WaitForChild("MainUI")
gui.GoldLabel.Text = CurrencyFormatter.formatGold(gold)
end
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 is worse than no test because it erodes trust in the suite.
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_{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_{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 -- insufficient funds
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
-- Rollback
self:addGold(fromId, amount)
return false
end
return true
end
return CurrencyManager
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)
expect(data.gems).to.equal(0)
end)
it("should return saved data for an existing player", function()
-- Pre-populate the mock store
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("savePlayer", function()
it("should persist data to the store", function()
manager:loadPlayer(1001)
manager:addGold(1001, 250)
manager:savePlayer(1001)
-- Verify by reading directly from the mock store
local store = mockDSS:GetDataStore("Currency")
local raw = store:GetAsync("currency_1001")
expect(raw.gold).to.equal(250)
end)
it("should do nothing for an unloaded player", function()
-- Should not error
manager:savePlayer(9999)
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 accumulate across multiple calls", function()
manager:loadPlayer(1001)
manager:addGold(1001, 50)
manager:addGold(1001, 75)
expect(manager:getGold(1001)).to.equal(125)
end)
it("should reject zero amount", function()
manager:loadPlayer(1001)
local ok = manager:addGold(1001, 0)
expect(ok).to.equal(false)
expect(manager:getGold(1001)).to.equal(0)
end)
it("should reject negative amount", function()
manager:loadPlayer(1001)
local ok = manager:addGold(1001, -50)
expect(ok).to.equal(false)
end)
it("should fail for unloaded player", function()
local ok = manager:addGold(9999, 100)
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)
it("should reject zero amount", function()
manager:loadPlayer(1001)
local ok = manager:removeGold(1001, 0)
expect(ok).to.equal(false)
end)
it("should reject negative amount", function()
manager:loadPlayer(1001)
local ok = manager:removeGold(1001, -10)
expect(ok).to.equal(false)
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 fail 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) -- rollback
end)
end)
describe("getGold", function()
it("should return 0 for unloaded player", function()
expect(manager:getGold(9999)).to.equal(0)
end)
end)
end)
end