Appearance
Testing
Written: 2026-10-14
Learning objectives
- You can design testable pure functions first.
- You can inject dependencies through start args.
- You can write framework-free assert harnesses.
- You can run unit scripts in Studio and Lune.
- You can fake dependencies with table doubles.
- You can test gameplay joins in Play.
Prerequisites
Design testable code first
Pure logic comes out of Services. Price math, validation, and cooldown checks live in Shared functions. State passes in and results return out. Services, remotes, and clocks stay out of those functions.
Pure functions test in one call. Inputs go in and outputs get asserted. No player, DataStore, or Play session is needed. I/O stays at the edges in start and handlers.
ModuleScript in ReplicatedStorage named ShopMath
luau
--!strict
local ShopMath = {}
function ShopMath.total(price: number, count: number): number
if not math.isfinite(price) then
return 0
end
if not math.isfinite(count) then
return 0
end
if price < 0 then
return 0
end
if count < 1 or count > 5 then
return 0
end
return math.floor(price * count)
end
function ShopMath.canAfford(owned: number, price: number, count: number): boolean
if not math.isfinite(owned) then
return false
end
return owned >= ShopMath.total(price, count)
end
return ShopMathEvery input gets a guard. total is reused inside canAfford. Both sides can require this module. Expected behavior: total(5, 2) returns 10 and canAfford(9, 5, 2) returns false.
Inject doubles through start args
Never hardcode a DataStore inside buy logic. Accept a grantCoins function or a store table through start args. Tests pass a fake table double. Production passes the real accessor. This injection comes from Patterns and Boundaries.
Doubles stay tiny. Implement only the methods the code calls. Record calls in a list. Assert on that list.
ModuleScript in ServerScriptService named ShopService
luau
--!strict
local ShopService = {}
local grantFn: ((Player, number) -> ())? = nil
function ShopService.start(_janitor: any, deps: { grantCoins: (Player, number) -> () }): ()
grantFn = deps.grantCoins
end
function ShopService.checkout(player: Player, owned: number, price: number, count: number): boolean
if not math.isfinite(owned) then
return false
end
if not math.isfinite(price) then
return false
end
if count < 1 or count > 5 then
return false
end
if owned < price * count then
return false
end
local grant = grantFn
if grant == nil then
return false
end
return true
end
return ShopServiceThe injected grant stores once. Funds validate without touching a store. Tests inject a spy and assert it was passed correctly. Expected behavior: checkout returns true for funded buys and false for poor buyers or missing deps.
Write assert harnesses without a framework
Write plain scripts with assert and print. Group one check per function. Run the file and read Output. No runner needed.
TestEZ is archived read-only since September 2024 at testez, so treat it as a legacy mention only. Framework-free harnesses cover new work. Any hosted runner claim outside this page needs a check against the TestEZ readme or the Lune CLI docs first.
Script in ServerScriptService named ShopMathSpec
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ShopMath = require(ReplicatedStorage:WaitForChild("ShopMath"))
local function checkTotal(): ()
assert(ShopMath.total(5, 2) == 10, "two apples cost ten")
assert(ShopMath.total(5, 0) == 0, "zero count pays zero")
assert(ShopMath.total(5, 99) == 0, "over cap pays zero")
end
local function checkAfford(): ()
assert(ShopMath.canAfford(10, 5, 2) == true, "ten buys two apples")
assert(ShopMath.canAfford(9, 5, 2) == false, "nine cannot buy two apples")
end
checkTotal()
checkAfford()
print("ShopMath green")The unit is required once at the top. Exact values get asserted. One green line prints at the end. Expected behavior: Output prints ShopMath green when all six asserts hold, or it errors on the first mismatch.
Run units in Studio and Lune
The same pure module runs in two places. In Studio press Play and read Output. In Lune run the file from a shell when the logic avoids Engine services. The Lune CLI documents the lune run script-name form, so verify flags there before scripting them.
Keep Engine code out of pure helpers. ShopMath uses only math and table. That split lets Lune execute it without Studio. Handler code with game:GetService stays in Studio Play tests.
Script in ServerScriptService named CheckoutSpec
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local ShopMath = require(ReplicatedStorage:WaitForChild("ShopMath"))
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local calls: { number } = {}
ShopService.start({}, {
grantCoins = function(_player: Player, amount: number): ()
table.insert(calls, amount)
end,
})
local function checkCheckout(): ()
assert(ShopMath.total(3, 3) == 9, "three bread cost nine")
assert(#calls == 0, "math never grants")
end
checkCheckout()
print("Checkout green")A recording double is injected. Math and wiring assert separately. Expected behavior: Output prints Checkout green without touching a DataStore.
Fake stores with table doubles
A store fakes with a plain table. It gets Get and Set functions that mimic the real shape. Rows seed per test. Rows assert after the call.
Keep the fake synchronous. Real stores yield and fail, so failure paths test by flipping a flag on the double.
ModuleScript in ServerScriptService named FakeStore
luau
--!strict
export type Row = { coins: number }
local FakeStore = {}
local rows: { [string]: Row } = {}
local failNext: boolean = false
function FakeStore.seed(key: string, coins: number): ()
rows[key] = { coins = coins }
end
function FakeStore.setFail(nextFails: boolean): ()
failNext = nextFails
end
function FakeStore.read(key: string): (boolean, Row?)
if failNext then
return false, nil
end
local row = rows[key]
if row == nil then
return true, nil
end
return true, { coins = row.coins }
end
function FakeStore.write(key: string, row: Row): boolean
if failNext then
return false
end
rows[key] = { coins = row.coins }
return true
end
return FakeStoreRows copy on read and write. Callers cannot mutate stored state by alias. Expected behavior: seeded reads return copies, failed flags return false, and writes replace the row.
Script in ServerScriptService named FakeStoreSpec
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local FakeStore = require(ServerScriptService:WaitForChild("FakeStore"))
local function checkRoundTrip(): ()
FakeStore.seed("Coins100", 20)
local ok, row = FakeStore.read("Coins100")
assert(ok == true, "seeded read succeeds")
if row == nil then
assert(false, "seeded row exists")
return
end
assert(row.coins == 20, "seeded coins read back")
assert(FakeStore.write("Coins100", { coins = 15 }) == true, "write succeeds")
local ok2, row2 = FakeStore.read("Coins100")
assert(ok2 == true, "second read succeeds")
if row2 == nil then
assert(false, "rewritten row exists")
return
end
assert(row2.coins == 15, "rewrite persists")
end
checkRoundTrip()
print("FakeStore green")Seed, read, write, and reread. Guard nil rows before field reads. Expected behavior: Output prints FakeStore green when the double copies correctly.
Integrate in Play
Joins, prompts, and remotes confirm in a Play session. Unit asserts cannot catch a misnamed remote or a missing prompt. Build a small checklist and run it after units pass.
Join with two clients. Fire the buy remote from one. Watch the other receive the broadcast. Leave and rejoin to confirm cached data clears. Failure paths test by stopping the server store call and confirming the client shows no grant.
Common mistakes
Mistake 1: you test through the store.
Wrong example below. It hits the network per assert.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local coins = DataStoreService:GetDataStore("CoinsV1")
local saved = coins:GetAsync("Coins100")
assert(saved ~= nil, "store has row")
print("done")It flops on backend failures and pollutes live data. It also misses pcall. Fix it by testing pure math with asserts and by faking the store boundary.
Mistake 2: you hide logic inside handlers.
Wrong example below. It computes inline where tests cannot reach it.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, count: number): ()
if typeof(count) ~= "number" then
return
end
print(player.Name)
print(math.floor(5 * count))
end)That math cannot be called without firing a remote. Fix it by moving total into a Shared module and by calling it from both the handler and the spec.
Mistake 3: you share mutable doubles.
Wrong example below. It returns the live row.
ModuleScript in ServerScriptService named FakeStore
luau
--!strict
local FakeStore = {}
local rows: { [string]: { coins: number } } = {}
function FakeStore.read(key: string): { coins: number }?
return rows[key]
end
function FakeStore.write(key: string, value: { coins: number }): ()
rows[key] = value
end
return FakeStoreCallers mutate stored rows by alias. One test leaks into the next. Fix it by copying on read and write.
Exercises
- Extract a pure total function and assert three cases.
- Inject a recording grant double through start args.
- Build a copying fake store and assert a rewrite.
Solution 1
ModuleScript in ReplicatedStorage named FeeMath
luau
--!strict
local FeeMath = {}
function FeeMath.fee(amount: number): number
if not math.isfinite(amount) then
return 0
end
if amount < 0 then
return 0
end
return math.floor(amount * 0.05)
end
return FeeMathScript in ServerScriptService named FeeMathSpec
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local FeeMath = require(ReplicatedStorage:WaitForChild("FeeMath"))
assert(FeeMath.fee(100) == 5, "hundred pays five")
assert(FeeMath.fee(-4) == 0, "negative pays zero")
assert(FeeMath.fee(0) == 0, "zero pays zero")
print("FeeMath green")Expected behavior: Output prints FeeMath green when all three asserts hold.
Solution 2
Script in ServerScriptService named GrantSpec
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
local seen: { number } = {}
ShopService.start({}, {
grantCoins = function(_player: Player, amount: number): ()
table.insert(seen, amount)
end,
})
assert(#seen == 0, "start grants nothing")
print("Grant green")Expected behavior: Output prints Grant green and the double records zero calls until checkout uses it.
Solution 3
Script in ServerScriptService named CopySpec
luau
--!strict
local ServerScriptService = game:GetService("ServerScriptService")
local FakeStore = require(ServerScriptService:WaitForChild("FakeStore"))
FakeStore.seed("Coins7", 30)
local ok, row = FakeStore.read("Coins7")
assert(ok == true, "read succeeds")
if row == nil then
assert(false, "row exists")
return
end
row.coins = 1
local ok2, row2 = FakeStore.read("Coins7")
assert(ok2 == true, "reread succeeds")
if row2 == nil then
assert(false, "row still exists")
return
end
assert(row2.coins == 30, "mutating a copy never leaks")
print("Copy green")Expected behavior: Output prints Copy green because reads return copies.
Summary
Design pure functions first. Inject deps through start args. Assert with plain scripts in Studio and Lune. Fake stores with copying doubles. Confirm joins in Play. Cite TestEZ as legacy only. Next, trace failures in Debugging Workflows.