---
url: /tooling/testing.md
description: Design testable code and verify it with framework-free assert harnesses.
---

# Testing

*Written: {{ $frontmatter.date }}*

## 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

* [Patterns and Boundaries](../intermediate/patterns-and-boundaries.md)
* [State and Data](../intermediate/state-and-data.md)
* [Async and Concurrency](../intermediate/async-and-concurrency.md)

## 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 ShopMath
```

Every 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](../intermediate/patterns-and-boundaries.md).

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 ShopService
```

The 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](https://github.com/Roblox/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](https://lune-org.github.io/docs/getting-started/2-command-line-usage/) 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](https://lune-org.github.io/docs/getting-started/2-command-line-usage/) 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 FakeStore
```

Rows 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 FakeStore
```

Callers mutate stored rows by alias. One test leaks into the next. Fix it by copying on read and write.

## Exercises

1. Extract a pure total function and assert three cases.
2. Inject a recording grant double through start args.
3. Build a copying fake store and assert a rewrite.

::: details 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 FeeMath
```

**Script 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.
:::

::: details 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.
:::

::: details 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](./debugging-workflows.md).
