roblox-opencode 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -122
- package/commands/setup-game.md +108 -108
- package/commands/sync-check.md +53 -53
- package/core/roblox-core.md +93 -93
- package/dist/server.js +189 -167
- package/package.json +35 -35
- package/skills/roblox-analytics/SKILL.md +277 -277
- package/skills/roblox-analytics/references/event-batcher.luau +75 -75
- package/skills/roblox-animation-vfx/SKILL.md +1325 -1325
- package/skills/roblox-architecture/SKILL.md +877 -863
- package/skills/roblox-architecture/references/combat-systems.md +1381 -1381
- package/skills/roblox-code-review/SKILL.md +686 -686
- package/skills/roblox-data/SKILL.md +889 -889
- package/skills/roblox-data/references/inventory-systems.md +1729 -1729
- package/skills/roblox-debug/SKILL.md +98 -98
- package/skills/roblox-gui/SKILL.md +1103 -1103
- package/skills/roblox-gui-fusion/SKILL.md +150 -150
- package/skills/roblox-gui-fusion/references/inventory.luau +427 -427
- package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -579
- package/skills/roblox-gui-fusion/references/shop.luau +411 -411
- package/skills/roblox-luau-mastery/SKILL.md +1618 -1519
- package/skills/roblox-monetization/SKILL.md +1084 -1084
- package/skills/roblox-monetization/references/process-receipt.luau +131 -131
- package/skills/roblox-networking/SKILL.md +669 -669
- package/skills/roblox-networking/references/remote-validator.luau +193 -193
- package/skills/roblox-publish-checklist/SKILL.md +127 -127
- package/skills/roblox-runtime/SKILL.md +753 -753
- package/skills/roblox-sharp-edges/SKILL.md +294 -294
- package/skills/roblox-sync/SKILL.md +126 -126
- package/skills/roblox-testing/SKILL.md +943 -943
- package/skills/roblox-tooling/SKILL.md +149 -149
- package/vendor/LICENSES/ProfileStore-LICENSE +201 -201
- package/vendor/LICENSES/RbxUtil-LICENSE +7 -7
- package/vendor/LICENSES/promise-LICENSE +20 -20
- package/vendor/LICENSES/t-LICENSE +21 -21
- package/vendor/LICENSES/testez-LICENSE +200 -200
- package/vendor/README.md +83 -83
- package/vendor/fusion/Animation/ExternalTime.luau +83 -83
- package/vendor/fusion/Animation/Spring.luau +321 -321
- package/vendor/fusion/Animation/Stopwatch.luau +127 -127
- package/vendor/fusion/Animation/Tween.luau +187 -187
- package/vendor/fusion/Animation/getTweenDuration.luau +27 -27
- package/vendor/fusion/Animation/getTweenRatio.luau +47 -47
- package/vendor/fusion/Animation/lerpType.luau +163 -163
- package/vendor/fusion/Animation/packType.luau +99 -99
- package/vendor/fusion/Animation/springCoefficients.luau +80 -80
- package/vendor/fusion/Animation/unpackType.luau +102 -102
- package/vendor/fusion/Colour/Oklab.luau +70 -70
- package/vendor/fusion/Colour/sRGB.luau +54 -54
- package/vendor/fusion/External.luau +167 -167
- package/vendor/fusion/ExternalDebug.luau +69 -69
- package/vendor/fusion/Graph/Observer.luau +113 -113
- package/vendor/fusion/Graph/castToGraph.luau +28 -28
- package/vendor/fusion/Graph/change.luau +80 -80
- package/vendor/fusion/Graph/depend.luau +32 -32
- package/vendor/fusion/Graph/evaluate.luau +55 -55
- package/vendor/fusion/Instances/Attribute.luau +57 -57
- package/vendor/fusion/Instances/AttributeChange.luau +46 -46
- package/vendor/fusion/Instances/AttributeOut.luau +63 -63
- package/vendor/fusion/Instances/Child.luau +21 -21
- package/vendor/fusion/Instances/Children.luau +147 -147
- package/vendor/fusion/Instances/Hydrate.luau +32 -32
- package/vendor/fusion/Instances/New.luau +52 -52
- package/vendor/fusion/Instances/OnChange.luau +49 -49
- package/vendor/fusion/Instances/OnEvent.luau +53 -53
- package/vendor/fusion/Instances/Out.luau +69 -69
- package/vendor/fusion/Instances/applyInstanceProps.luau +148 -148
- package/vendor/fusion/Instances/defaultProps.luau +194 -194
- package/vendor/fusion/LICENSE +21 -21
- package/vendor/fusion/Logging/formatError.luau +48 -48
- package/vendor/fusion/Logging/messages.luau +51 -51
- package/vendor/fusion/Logging/parseError.luau +24 -24
- package/vendor/fusion/Memory/checkLifetime.luau +133 -133
- package/vendor/fusion/Memory/deriveScope.luau +23 -23
- package/vendor/fusion/Memory/deriveScopeImpl.luau +44 -44
- package/vendor/fusion/Memory/doCleanup.luau +78 -78
- package/vendor/fusion/Memory/innerScope.luau +33 -33
- package/vendor/fusion/Memory/legacyCleanup.luau +17 -17
- package/vendor/fusion/Memory/needsDestruction.luau +16 -16
- package/vendor/fusion/Memory/poisonScope.luau +33 -33
- package/vendor/fusion/Memory/scopePool.luau +54 -54
- package/vendor/fusion/Memory/scoped.luau +26 -26
- package/vendor/fusion/Memory/whichLivesLonger.luau +74 -74
- package/vendor/fusion/RobloxExternal.luau +97 -97
- package/vendor/fusion/State/Computed.luau +138 -138
- package/vendor/fusion/State/For/Disassembly.luau +210 -210
- package/vendor/fusion/State/For/ForTypes.luau +30 -30
- package/vendor/fusion/State/For/init.luau +109 -109
- package/vendor/fusion/State/ForKeys.luau +93 -93
- package/vendor/fusion/State/ForPairs.luau +96 -96
- package/vendor/fusion/State/ForValues.luau +93 -93
- package/vendor/fusion/State/Value.luau +87 -87
- package/vendor/fusion/State/castToState.luau +25 -25
- package/vendor/fusion/State/peek.luau +30 -30
- package/vendor/fusion/Types.luau +314 -314
- package/vendor/fusion/Utility/Contextual.luau +90 -90
- package/vendor/fusion/Utility/Safe.luau +22 -22
- package/vendor/fusion/Utility/isSimilar.luau +29 -29
- package/vendor/fusion/Utility/merge.luau +35 -35
- package/vendor/fusion/Utility/nameOf.luau +34 -34
- package/vendor/fusion/Utility/never.luau +13 -13
- package/vendor/fusion/Utility/nicknames.luau +10 -10
- package/vendor/fusion/Utility/xtypeof.luau +26 -26
- package/vendor/fusion/init.luau +82 -82
- package/vendor/profilestore/init.luau +2242 -2242
- package/vendor/promise/init.luau +1982 -1982
- package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -25
- package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -228
- package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -269
- package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -223
- package/vendor/rbxutil/buffer-util/Types.luau +60 -60
- package/vendor/rbxutil/buffer-util/index.d.ts +153 -153
- package/vendor/rbxutil/buffer-util/init.luau +41 -41
- package/vendor/rbxutil/buffer-util/package.json +16 -16
- package/vendor/rbxutil/buffer-util/wally.toml +9 -9
- package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -232
- package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -156
- package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -109
- package/vendor/rbxutil/comm/Client/init.luau +135 -135
- package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -295
- package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -211
- package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -211
- package/vendor/rbxutil/comm/Server/init.luau +140 -140
- package/vendor/rbxutil/comm/Types.luau +18 -18
- package/vendor/rbxutil/comm/Util.luau +27 -27
- package/vendor/rbxutil/comm/init.luau +35 -35
- package/vendor/rbxutil/comm/wally.toml +13 -13
- package/vendor/rbxutil/component/init.luau +759 -759
- package/vendor/rbxutil/component/init.test.luau +311 -311
- package/vendor/rbxutil/component/wally.toml +14 -14
- package/vendor/rbxutil/concur/init.luau +542 -542
- package/vendor/rbxutil/concur/init.test.luau +364 -364
- package/vendor/rbxutil/concur/wally.toml +8 -8
- package/vendor/rbxutil/enum-list/init.luau +101 -101
- package/vendor/rbxutil/enum-list/init.test.luau +91 -91
- package/vendor/rbxutil/enum-list/wally.toml +8 -8
- package/vendor/rbxutil/find/index.d.ts +20 -20
- package/vendor/rbxutil/find/init.luau +44 -44
- package/vendor/rbxutil/find/package.json +17 -17
- package/vendor/rbxutil/find/wally.toml +8 -8
- package/vendor/rbxutil/input/Gamepad.luau +559 -559
- package/vendor/rbxutil/input/Keyboard.luau +124 -124
- package/vendor/rbxutil/input/Mouse.luau +278 -278
- package/vendor/rbxutil/input/PreferredInput.luau +91 -91
- package/vendor/rbxutil/input/Touch.luau +120 -120
- package/vendor/rbxutil/input/init.luau +33 -33
- package/vendor/rbxutil/input/wally.toml +12 -12
- package/vendor/rbxutil/loader/index.d.ts +15 -15
- package/vendor/rbxutil/loader/init.luau +137 -137
- package/vendor/rbxutil/loader/wally.toml +8 -8
- package/vendor/rbxutil/log/index.d.ts +38 -38
- package/vendor/rbxutil/log/init.luau +746 -746
- package/vendor/rbxutil/log/wally.toml +8 -8
- package/vendor/rbxutil/net/init.luau +190 -190
- package/vendor/rbxutil/net/wally.toml +8 -8
- package/vendor/rbxutil/option/index.d.ts +44 -44
- package/vendor/rbxutil/option/init.luau +489 -489
- package/vendor/rbxutil/option/init.test.luau +342 -342
- package/vendor/rbxutil/option/wally.toml +8 -8
- package/vendor/rbxutil/pid/index.d.ts +53 -53
- package/vendor/rbxutil/pid/init.luau +195 -195
- package/vendor/rbxutil/pid/package.json +16 -16
- package/vendor/rbxutil/pid/wally.toml +9 -9
- package/vendor/rbxutil/quaternion/index.d.ts +117 -117
- package/vendor/rbxutil/quaternion/init.luau +570 -570
- package/vendor/rbxutil/quaternion/package.json +16 -16
- package/vendor/rbxutil/quaternion/wally.toml +9 -9
- package/vendor/rbxutil/query/index.d.ts +43 -43
- package/vendor/rbxutil/query/init.luau +117 -117
- package/vendor/rbxutil/query/package.json +18 -18
- package/vendor/rbxutil/query/wally.toml +9 -9
- package/vendor/rbxutil/sequent/index.d.ts +28 -28
- package/vendor/rbxutil/sequent/init.luau +340 -340
- package/vendor/rbxutil/sequent/package.json +16 -16
- package/vendor/rbxutil/sequent/wally.toml +9 -9
- package/vendor/rbxutil/ser/init.luau +175 -175
- package/vendor/rbxutil/ser/init.test.luau +50 -50
- package/vendor/rbxutil/ser/wally.toml +11 -11
- package/vendor/rbxutil/shake/index.d.ts +36 -36
- package/vendor/rbxutil/shake/init.luau +532 -532
- package/vendor/rbxutil/shake/init.test.luau +267 -267
- package/vendor/rbxutil/shake/package.json +16 -16
- package/vendor/rbxutil/shake/wally.toml +9 -9
- package/vendor/rbxutil/signal/index.d.ts +100 -100
- package/vendor/rbxutil/signal/init.luau +432 -432
- package/vendor/rbxutil/signal/init.test.luau +190 -190
- package/vendor/rbxutil/signal/package.json +17 -17
- package/vendor/rbxutil/signal/wally.toml +9 -9
- package/vendor/rbxutil/silo/TableWatcher.luau +65 -65
- package/vendor/rbxutil/silo/Util.luau +55 -55
- package/vendor/rbxutil/silo/init.luau +338 -338
- package/vendor/rbxutil/silo/init.test.luau +215 -215
- package/vendor/rbxutil/silo/wally.toml +8 -8
- package/vendor/rbxutil/spring/index.d.ts +40 -40
- package/vendor/rbxutil/spring/init.luau +97 -97
- package/vendor/rbxutil/spring/package.json +17 -17
- package/vendor/rbxutil/spring/wally.toml +8 -8
- package/vendor/rbxutil/stream/index.d.ts +88 -88
- package/vendor/rbxutil/stream/init.luau +597 -597
- package/vendor/rbxutil/stream/package.json +18 -18
- package/vendor/rbxutil/stream/wally.toml +9 -9
- package/vendor/rbxutil/streamable/Streamable.luau +202 -202
- package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -80
- package/vendor/rbxutil/streamable/init.luau +8 -8
- package/vendor/rbxutil/streamable/wally.toml +12 -12
- package/vendor/rbxutil/symbol/init.luau +56 -56
- package/vendor/rbxutil/symbol/init.test.luau +37 -37
- package/vendor/rbxutil/symbol/wally.toml +8 -8
- package/vendor/rbxutil/table-util/init.luau +938 -938
- package/vendor/rbxutil/table-util/init.test.luau +439 -439
- package/vendor/rbxutil/task-queue/index.d.ts +27 -27
- package/vendor/rbxutil/task-queue/init.luau +97 -97
- package/vendor/rbxutil/task-queue/wally.toml +8 -8
- package/vendor/rbxutil/timer/index.d.ts +81 -81
- package/vendor/rbxutil/timer/init.luau +249 -249
- package/vendor/rbxutil/timer/init.test.luau +73 -73
- package/vendor/rbxutil/timer/wally.toml +11 -11
- package/vendor/rbxutil/tree/index.d.ts +15 -15
- package/vendor/rbxutil/tree/init.luau +137 -137
- package/vendor/rbxutil/tree/wally.toml +8 -8
- package/vendor/rbxutil/trove/index.d.ts +46 -46
- package/vendor/rbxutil/trove/init.luau +787 -787
- package/vendor/rbxutil/trove/init.test.luau +203 -203
- package/vendor/rbxutil/trove/wally.toml +8 -8
- package/vendor/rbxutil/typed-remote/init.luau +196 -196
- package/vendor/rbxutil/typed-remote/wally.toml +8 -8
- package/vendor/rbxutil/wait-for/index.d.ts +17 -17
- package/vendor/rbxutil/wait-for/init.luau +257 -257
- package/vendor/rbxutil/wait-for/init.test.luau +182 -182
- package/vendor/rbxutil/wait-for/wally.toml +11 -11
- package/vendor/t/t.lua +1350 -1350
- package/vendor/testez/Context.lua +26 -26
- package/vendor/testez/Expectation.lua +311 -311
- package/vendor/testez/ExpectationContext.lua +38 -38
- package/vendor/testez/LifecycleHooks.lua +89 -89
- package/vendor/testez/Reporters/TeamCityReporter.lua +101 -101
- package/vendor/testez/Reporters/TextReporter.lua +105 -105
- package/vendor/testez/Reporters/TextReporterQuiet.lua +96 -96
- package/vendor/testez/TestBootstrap.lua +146 -146
- package/vendor/testez/TestEnum.lua +27 -27
- package/vendor/testez/TestPlan.lua +304 -304
- package/vendor/testez/TestPlanner.lua +39 -39
- package/vendor/testez/TestResults.lua +111 -111
- package/vendor/testez/TestRunner.lua +188 -188
- package/vendor/testez/TestSession.lua +243 -243
- package/vendor/testez/init.lua +39 -39
|
@@ -1,943 +1,943 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: roblox-testing
|
|
3
|
-
description: TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox.
|
|
4
|
-
last_reviewed: 2026-05-21
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
|
|
8
|
-
|
|
9
|
-
# Roblox Testing Patterns Reference
|
|
10
|
-
|
|
11
|
-
## 1. Overview
|
|
12
|
-
|
|
13
|
-
Load this reference when:
|
|
14
|
-
|
|
15
|
-
- Writing unit or integration tests for Roblox game modules
|
|
16
|
-
- Setting up test infrastructure for a new or existing project
|
|
17
|
-
- Configuring CI/CD pipelines for automated linting, formatting, and test runs
|
|
18
|
-
- Refactoring modules to improve testability
|
|
19
|
-
- Debugging failures caught during playtesting or production monitoring
|
|
20
|
-
|
|
21
|
-
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.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## Quick Reference
|
|
26
|
-
|
|
27
|
-
**Load Full Reference below only when you need specific mock implementations or test patterns.**
|
|
28
|
-
|
|
29
|
-
Key rules:
|
|
30
|
-
- TestEZ is the framework. BDD style: `describe/it/expect`. Files named `*.spec.luau`.
|
|
31
|
-
- Test pure logic modules first (no Roblox dependencies). Highest ROI.
|
|
32
|
-
- Dependency injection for testability: pass services as constructor args, mock in tests.
|
|
33
|
-
- Mock pattern: table that mimics the service interface. Only implement methods you test.
|
|
34
|
-
- `beforeEach` for fresh state per test. Never share mutable state between `it` blocks.
|
|
35
|
-
- Integration tests: test module interactions, not Roblox engine behavior.
|
|
36
|
-
- MCP-powered testing: use execute_luau to run tests in Studio, read output.
|
|
37
|
-
- Don't test Roblox engine behavior (physics, rendering). Test YOUR logic.
|
|
38
|
-
- Run tests via: require(TestEZ).TestBootstrap:run({testRoot})
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
## Full Reference
|
|
43
|
-
|
|
44
|
-
## 2. TestEZ Framework
|
|
45
|
-
|
|
46
|
-
> **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.
|
|
47
|
-
|
|
48
|
-
### Test File Conventions
|
|
49
|
-
|
|
50
|
-
- Test files use the suffix `.spec.luau` (e.g., `CurrencyManager.spec.luau`).
|
|
51
|
-
- Each spec file lives alongside or mirrors the module it tests.
|
|
52
|
-
- TestEZ discovers specs by recursively scanning a root container you point it at.
|
|
53
|
-
|
|
54
|
-
### Core Syntax
|
|
55
|
-
|
|
56
|
-
```luau
|
|
57
|
-
return function()
|
|
58
|
-
describe("CurrencyManager", function()
|
|
59
|
-
local CurrencyManager
|
|
60
|
-
|
|
61
|
-
beforeEach(function()
|
|
62
|
-
-- Fresh module state before every test
|
|
63
|
-
CurrencyManager = require(script.Parent.CurrencyManager)
|
|
64
|
-
end)
|
|
65
|
-
|
|
66
|
-
afterEach(function()
|
|
67
|
-
-- Teardown: reset any shared state
|
|
68
|
-
end)
|
|
69
|
-
|
|
70
|
-
it("should initialize a player with zero gold", function()
|
|
71
|
-
local data = CurrencyManager.newPlayerData()
|
|
72
|
-
expect(data.gold).to.equal(0)
|
|
73
|
-
end)
|
|
74
|
-
|
|
75
|
-
it("should add currency correctly", function()
|
|
76
|
-
local data = CurrencyManager.newPlayerData()
|
|
77
|
-
CurrencyManager.addGold(data, 100)
|
|
78
|
-
expect(data.gold).to.equal(100)
|
|
79
|
-
end)
|
|
80
|
-
|
|
81
|
-
it("should never allow negative gold", function()
|
|
82
|
-
local data = CurrencyManager.newPlayerData()
|
|
83
|
-
CurrencyManager.addGold(data, 50)
|
|
84
|
-
CurrencyManager.removeGold(data, 999)
|
|
85
|
-
expect(data.gold).to.equal(0)
|
|
86
|
-
end)
|
|
87
|
-
end)
|
|
88
|
-
end
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
### Assertion API Highlights
|
|
92
|
-
|
|
93
|
-
```luau
|
|
94
|
-
expect(value).to.equal(expected) -- strict equality
|
|
95
|
-
expect(value).to.be.ok() -- truthy
|
|
96
|
-
expect(value).to.be.a("table") -- type check
|
|
97
|
-
expect(value).never.to.equal(unexpected) -- negation
|
|
98
|
-
expect(function()
|
|
99
|
-
error("boom")
|
|
100
|
-
end).to.throw() -- error expected
|
|
101
|
-
expect(value).to.be.near(3.14, 0.01) -- float tolerance
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
### Running Tests Inside Studio
|
|
105
|
-
|
|
106
|
-
Create a test runner script in `ServerScriptService`:
|
|
107
|
-
|
|
108
|
-
```luau
|
|
109
|
-
local TestEZ = require(game.ReplicatedStorage.DevPackages.TestEZ)
|
|
110
|
-
local results = TestEZ.TestBootstrap:run({
|
|
111
|
-
game.ReplicatedStorage.Shared, -- scan these containers
|
|
112
|
-
game.ServerScriptService.Server,
|
|
113
|
-
})
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## 3. Unit Testing ModuleScripts
|
|
119
|
-
|
|
120
|
-
Unit tests target **pure logic** - functions that take inputs and return outputs without touching engine APIs.
|
|
121
|
-
|
|
122
|
-
### Good Candidates for Unit Testing
|
|
123
|
-
|
|
124
|
-
| Module Type | Examples |
|
|
125
|
-
|---|---|
|
|
126
|
-
| Damage calculation | `calculateDamage(baseDmg, armor, crit)` |
|
|
127
|
-
| Inventory operations | `addItem(inventory, itemId, qty)` |
|
|
128
|
-
| Data transforms | `serializeLoadout(loadout)` / `deserializeLoadout(raw)` |
|
|
129
|
-
| Config validators | `validateWeaponConfig(config)` |
|
|
130
|
-
| Math/utility | `clamp`, `lerp`, `formatNumber` |
|
|
131
|
-
|
|
132
|
-
### Pattern: Test a Pure Module
|
|
133
|
-
|
|
134
|
-
Module under test (`DamageCalc.luau`):
|
|
135
|
-
|
|
136
|
-
```luau
|
|
137
|
-
local DamageCalc = {}
|
|
138
|
-
|
|
139
|
-
function DamageCalc.calculate(baseDamage: number, armor: number, isCrit: boolean): number
|
|
140
|
-
local reduction = math.clamp(armor / 100, 0, 0.75)
|
|
141
|
-
local damage = baseDamage * (1 - reduction)
|
|
142
|
-
if isCrit then
|
|
143
|
-
damage *= 2
|
|
144
|
-
end
|
|
145
|
-
return math.floor(damage)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
return DamageCalc
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
Spec (`DamageCalc.spec.luau`):
|
|
152
|
-
|
|
153
|
-
```luau
|
|
154
|
-
return function()
|
|
155
|
-
local DamageCalc = require(script.Parent.DamageCalc)
|
|
156
|
-
|
|
157
|
-
describe("calculate", function()
|
|
158
|
-
it("should apply armor reduction", function()
|
|
159
|
-
-- 50 armor = 50% reduction on 100 base = 50
|
|
160
|
-
expect(DamageCalc.calculate(100, 50, false)).to.equal(50)
|
|
161
|
-
end)
|
|
162
|
-
|
|
163
|
-
it("should cap armor reduction at 75%", function()
|
|
164
|
-
expect(DamageCalc.calculate(100, 200, false)).to.equal(25)
|
|
165
|
-
end)
|
|
166
|
-
|
|
167
|
-
it("should double damage on crit", function()
|
|
168
|
-
expect(DamageCalc.calculate(100, 0, true)).to.equal(200)
|
|
169
|
-
end)
|
|
170
|
-
|
|
171
|
-
it("should floor the result", function()
|
|
172
|
-
expect(DamageCalc.calculate(33, 10, false)).to.equal(29)
|
|
173
|
-
end)
|
|
174
|
-
end)
|
|
175
|
-
end
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
### Keep Modules Testable
|
|
179
|
-
|
|
180
|
-
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.
|
|
181
|
-
|
|
182
|
-
```luau
|
|
183
|
-
-- BAD: untestable, reaches into the engine
|
|
184
|
-
function Module.getPlayerHealth(player)
|
|
185
|
-
local char = player.Character
|
|
186
|
-
local humanoid = char:FindFirstChildOfClass("Humanoid")
|
|
187
|
-
return humanoid.Health
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
-- GOOD: testable, accepts the value it needs
|
|
191
|
-
function Module.isLowHealth(currentHealth: number, threshold: number): boolean
|
|
192
|
-
return currentHealth <= threshold
|
|
193
|
-
end
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
## 4. Dependency Injection for Testability
|
|
199
|
-
|
|
200
|
-
When a module must interact with a Roblox service, inject the service as a parameter instead of hard-coding `game:GetService()`.
|
|
201
|
-
|
|
202
|
-
### Pattern: Constructor Injection
|
|
203
|
-
|
|
204
|
-
```luau
|
|
205
|
-
local InventoryManager = {}
|
|
206
|
-
InventoryManager.__index = InventoryManager
|
|
207
|
-
|
|
208
|
-
-- Accept services through the constructor
|
|
209
|
-
function InventoryManager.new(dataStoreService, messagingService)
|
|
210
|
-
local self = setmetatable({}, InventoryManager)
|
|
211
|
-
self._dataStore = dataStoreService:GetDataStore("Inventory")
|
|
212
|
-
self._messaging = messagingService
|
|
213
|
-
return self
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
function InventoryManager:saveInventory(playerId: number, inventory: { [string]: number })
|
|
217
|
-
local key = `inv_{playerId}`
|
|
218
|
-
self._dataStore:SetAsync(key, inventory)
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
function InventoryManager:loadInventory(playerId: number): { [string]: number }
|
|
222
|
-
local key = `inv_{playerId}`
|
|
223
|
-
return self._dataStore:GetAsync(key) or {}
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
return InventoryManager
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
### Production Wiring
|
|
230
|
-
|
|
231
|
-
```luau
|
|
232
|
-
local DataStoreService = game:GetService("DataStoreService")
|
|
233
|
-
local MessagingService = game:GetService("MessagingService")
|
|
234
|
-
local InventoryManager = require(path.to.InventoryManager)
|
|
235
|
-
|
|
236
|
-
local manager = InventoryManager.new(DataStoreService, MessagingService)
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
### Test Wiring (inject mocks)
|
|
240
|
-
|
|
241
|
-
```luau
|
|
242
|
-
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
|
|
243
|
-
local InventoryManager = require(script.Parent.InventoryManager)
|
|
244
|
-
|
|
245
|
-
local manager = InventoryManager.new(MockDataStoreService.new(), mockMessaging)
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
### Alternative: Module-Level Injection via `.init()`
|
|
249
|
-
|
|
250
|
-
For modules that are singletons rather than classes:
|
|
251
|
-
|
|
252
|
-
```luau
|
|
253
|
-
local Module = {}
|
|
254
|
-
local _players = nil
|
|
255
|
-
|
|
256
|
-
function Module.init(playersService)
|
|
257
|
-
_players = playersService
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
function Module.getPlayerCount(): number
|
|
261
|
-
return #_players:GetPlayers()
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
return Module
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
---
|
|
268
|
-
|
|
269
|
-
## 5. Mocking Roblox Services
|
|
270
|
-
|
|
271
|
-
### Mock Players Service
|
|
272
|
-
|
|
273
|
-
```luau
|
|
274
|
-
local MockPlayers = {}
|
|
275
|
-
MockPlayers.__index = MockPlayers
|
|
276
|
-
|
|
277
|
-
function MockPlayers.new()
|
|
278
|
-
local self = setmetatable({}, MockPlayers)
|
|
279
|
-
self._players = {}
|
|
280
|
-
self.PlayerAdded = MockSignal.new()
|
|
281
|
-
self.PlayerRemoving = MockSignal.new()
|
|
282
|
-
return self
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
function MockPlayers:GetPlayers()
|
|
286
|
-
return self._players
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
function MockPlayers:addFakePlayer(mockPlayer)
|
|
290
|
-
table.insert(self._players, mockPlayer)
|
|
291
|
-
self.PlayerAdded:Fire(mockPlayer)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
function MockPlayers:removeFakePlayer(mockPlayer)
|
|
295
|
-
local idx = table.find(self._players, mockPlayer)
|
|
296
|
-
if idx then
|
|
297
|
-
table.remove(self._players, idx)
|
|
298
|
-
self.PlayerRemoving:Fire(mockPlayer)
|
|
299
|
-
end
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
return MockPlayers
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### Mock Signal (for RBXScriptSignal-like behavior)
|
|
306
|
-
|
|
307
|
-
```luau
|
|
308
|
-
local MockSignal = {}
|
|
309
|
-
MockSignal.__index = MockSignal
|
|
310
|
-
|
|
311
|
-
function MockSignal.new()
|
|
312
|
-
return setmetatable({ _connections = {} }, MockSignal)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
function MockSignal:Connect(callback)
|
|
316
|
-
table.insert(self._connections, callback)
|
|
317
|
-
return {
|
|
318
|
-
Disconnect = function(conn)
|
|
319
|
-
local idx = table.find(self._connections, callback)
|
|
320
|
-
if idx then
|
|
321
|
-
table.remove(self._connections, idx)
|
|
322
|
-
end
|
|
323
|
-
end,
|
|
324
|
-
}
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
function MockSignal:Fire(...)
|
|
328
|
-
for _, cb in self._connections do
|
|
329
|
-
task.spawn(cb, ...)
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
return MockSignal
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
### Mock DataStoreService (In-Memory)
|
|
337
|
-
|
|
338
|
-
```luau
|
|
339
|
-
local MockDataStore = {}
|
|
340
|
-
MockDataStore.__index = MockDataStore
|
|
341
|
-
|
|
342
|
-
function MockDataStore.new()
|
|
343
|
-
return setmetatable({ _data = {} }, MockDataStore)
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
function MockDataStore:GetAsync(key)
|
|
347
|
-
return self._data[key]
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
function MockDataStore:SetAsync(key, value)
|
|
351
|
-
self._data[key] = value
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
function MockDataStore:UpdateAsync(key, transformFunction)
|
|
355
|
-
local old = self._data[key]
|
|
356
|
-
self._data[key] = transformFunction(old)
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
function MockDataStore:RemoveAsync(key)
|
|
360
|
-
self._data[key] = nil
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
-- ------------------------------------------------
|
|
364
|
-
|
|
365
|
-
local MockDataStoreService = {}
|
|
366
|
-
MockDataStoreService.__index = MockDataStoreService
|
|
367
|
-
|
|
368
|
-
function MockDataStoreService.new()
|
|
369
|
-
return setmetatable({ _stores = {} }, MockDataStoreService)
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
function MockDataStoreService:GetDataStore(name)
|
|
373
|
-
if not self._stores[name] then
|
|
374
|
-
self._stores[name] = MockDataStore.new()
|
|
375
|
-
end
|
|
376
|
-
return self._stores[name]
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
return MockDataStoreService
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
### Mock MarketplaceService
|
|
383
|
-
|
|
384
|
-
```luau
|
|
385
|
-
local MockMarketplaceService = {}
|
|
386
|
-
MockMarketplaceService.__index = MockMarketplaceService
|
|
387
|
-
|
|
388
|
-
function MockMarketplaceService.new(ownedGamepasses: { [number]: boolean }?)
|
|
389
|
-
local self = setmetatable({}, MockMarketplaceService)
|
|
390
|
-
self._ownedPasses = ownedGamepasses or {}
|
|
391
|
-
self.PromptGamePassPurchaseFinished = MockSignal.new()
|
|
392
|
-
return self
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
function MockMarketplaceService:UserOwnsGamePassAsync(_userId, gamePassId)
|
|
396
|
-
return self._ownedPasses[gamePassId] == true
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
return MockMarketplaceService
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
---
|
|
403
|
-
|
|
404
|
-
## 6. Integration Testing
|
|
405
|
-
|
|
406
|
-
Integration tests verify that **multiple modules work together correctly** - data flows across boundaries and side effects happen as expected.
|
|
407
|
-
|
|
408
|
-
### Pattern: DataManager + InventoryManager
|
|
409
|
-
|
|
410
|
-
```luau
|
|
411
|
-
return function()
|
|
412
|
-
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
|
|
413
|
-
local DataManager = require(script.Parent.DataManager)
|
|
414
|
-
local InventoryManager = require(script.Parent.InventoryManager)
|
|
415
|
-
|
|
416
|
-
describe("DataManager + InventoryManager integration", function()
|
|
417
|
-
local mockDSS
|
|
418
|
-
local dataMgr
|
|
419
|
-
local invMgr
|
|
420
|
-
|
|
421
|
-
beforeEach(function()
|
|
422
|
-
mockDSS = MockDataStoreService.new()
|
|
423
|
-
dataMgr = DataManager.new(mockDSS)
|
|
424
|
-
invMgr = InventoryManager.new(mockDSS)
|
|
425
|
-
end)
|
|
426
|
-
|
|
427
|
-
it("should persist inventory through save/load cycle", function()
|
|
428
|
-
local playerId = 12345
|
|
429
|
-
local inventory = { Sword = 1, Shield = 2, Potion = 10 }
|
|
430
|
-
|
|
431
|
-
invMgr:saveInventory(playerId, inventory)
|
|
432
|
-
local loaded = invMgr:loadInventory(playerId)
|
|
433
|
-
|
|
434
|
-
expect(loaded.Sword).to.equal(1)
|
|
435
|
-
expect(loaded.Shield).to.equal(2)
|
|
436
|
-
expect(loaded.Potion).to.equal(10)
|
|
437
|
-
end)
|
|
438
|
-
|
|
439
|
-
it("should return empty inventory for new player", function()
|
|
440
|
-
local loaded = invMgr:loadInventory(99999)
|
|
441
|
-
expect(next(loaded)).to.equal(nil)
|
|
442
|
-
end)
|
|
443
|
-
end)
|
|
444
|
-
end
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
### Testing RemoteEvent Flows
|
|
448
|
-
|
|
449
|
-
For end-to-end remote flows in a test environment, create mock remotes:
|
|
450
|
-
|
|
451
|
-
```luau
|
|
452
|
-
local MockRemoteEvent = {}
|
|
453
|
-
MockRemoteEvent.__index = MockRemoteEvent
|
|
454
|
-
|
|
455
|
-
function MockRemoteEvent.new()
|
|
456
|
-
local self = setmetatable({}, MockRemoteEvent)
|
|
457
|
-
self.OnServerEvent = MockSignal.new()
|
|
458
|
-
self.OnClientEvent = MockSignal.new()
|
|
459
|
-
return self
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
function MockRemoteEvent:FireServer(...)
|
|
463
|
-
self.OnServerEvent:Fire(nil, ...) -- nil = fake player in test
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
function MockRemoteEvent:FireClient(_player, ...)
|
|
467
|
-
self.OnClientEvent:Fire(...)
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
function MockRemoteEvent:FireAllClients(...)
|
|
471
|
-
self.OnClientEvent:Fire(...)
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
return MockRemoteEvent
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
Usage in a test:
|
|
478
|
-
|
|
479
|
-
```luau
|
|
480
|
-
it("should process purchase request and respond", function()
|
|
481
|
-
local remote = MockRemoteEvent.new()
|
|
482
|
-
local responded = false
|
|
483
|
-
|
|
484
|
-
-- Simulate server handler
|
|
485
|
-
remote.OnServerEvent:Connect(function(_player, itemId)
|
|
486
|
-
-- Server validates and responds
|
|
487
|
-
local success = ShopManager:tryPurchase(itemId, playerData)
|
|
488
|
-
remote:FireClient(nil, success, itemId)
|
|
489
|
-
end)
|
|
490
|
-
|
|
491
|
-
-- Capture client response
|
|
492
|
-
remote.OnClientEvent:Connect(function(success, itemId)
|
|
493
|
-
responded = true
|
|
494
|
-
expect(success).to.equal(true)
|
|
495
|
-
expect(itemId).to.equal("Sword")
|
|
496
|
-
end)
|
|
497
|
-
|
|
498
|
-
remote:FireServer("Sword")
|
|
499
|
-
expect(responded).to.equal(true)
|
|
500
|
-
end)
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
---
|
|
504
|
-
|
|
505
|
-
## 7. MCP-Powered Testing
|
|
506
|
-
|
|
507
|
-
When Roblox Studio MCP tools are available, use them for automated smoke testing without leaving your editor.
|
|
508
|
-
|
|
509
|
-
### Automated Smoke Test Workflow
|
|
510
|
-
|
|
511
|
-
```
|
|
512
|
-
1. start_playtest()
|
|
513
|
-
- Launches a local test server in Studio
|
|
514
|
-
|
|
515
|
-
2. wait 5-10 seconds for game initialization
|
|
516
|
-
|
|
517
|
-
3. get_playtest_output()
|
|
518
|
-
- Captures Output window logs
|
|
519
|
-
- Look for: errors, warnings, "Script timeout" messages
|
|
520
|
-
|
|
521
|
-
4. Analyze the output
|
|
522
|
-
- Any red error lines? -> investigate and fix
|
|
523
|
-
- Module load failures? -> check requires and paths
|
|
524
|
-
- DataStore errors? -> check mock/fallback setup
|
|
525
|
-
|
|
526
|
-
5. stop_playtest()
|
|
527
|
-
- Ends the session cleanly
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
### Iterative Debugging Loop with MCP
|
|
531
|
-
|
|
532
|
-
```
|
|
533
|
-
REPEAT:
|
|
534
|
-
1. Apply code fix
|
|
535
|
-
2. start_playtest()
|
|
536
|
-
3. get_playtest_output() - scan for the specific error you are fixing
|
|
537
|
-
4. If error persists → stop_playtest(), refine fix, go to 1
|
|
538
|
-
5. If error gone → stop_playtest(), move on
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
### Checking for Specific Errors
|
|
542
|
-
|
|
543
|
-
After `get_playtest_output()`, filter for critical patterns:
|
|
544
|
-
|
|
545
|
-
- `"error"` or `"Error"` - runtime errors
|
|
546
|
-
- `"attempt to index nil"` - missing references
|
|
547
|
-
- `"Infinite yield possible"` - WaitForChild timeouts
|
|
548
|
-
- `"HTTP 429"` - DataStore throttling
|
|
549
|
-
- `"not a valid member"` - API misuse or renamed properties
|
|
550
|
-
|
|
551
|
-
---
|
|
552
|
-
|
|
553
|
-
## 8. Manual Testing Workflows
|
|
554
|
-
|
|
555
|
-
Automated tests do not cover everything. Use this checklist for manual playtesting:
|
|
556
|
-
|
|
557
|
-
### Pre-Release Checklist
|
|
558
|
-
|
|
559
|
-
- [ ] **Mobile playtest** - Touch controls work, UI fits small screens, no overlapping buttons
|
|
560
|
-
- [ ] **Multi-player test** (Studio local server, 2+ players) - Replication works, no desync, RemoteEvents fire correctly
|
|
561
|
-
- [ ] **Edge cases**:
|
|
562
|
-
- Disconnect mid-save (does data persist or rollback cleanly?)
|
|
563
|
-
- Rejoin immediately after leaving
|
|
564
|
-
- Rapid-fire actions (spam click buy button, spam attack)
|
|
565
|
-
- Inventory at max capacity
|
|
566
|
-
- [ ] **Monetization flow**:
|
|
567
|
-
- Game pass ownership detection works
|
|
568
|
-
- Developer product purchase prompt appears
|
|
569
|
-
- Receipt processing completes and grants items
|
|
570
|
-
- Duplicate receipt handling (idempotent processing)
|
|
571
|
-
- [ ] **First-time user experience** - New player gets default data, tutorial triggers, no errors in output
|
|
572
|
-
- [ ] **Performance** - MicroProfiler shows no frame spikes on spawn, no memory leaks during extended play
|
|
573
|
-
|
|
574
|
-
### Studio Test Server (Multi-Player)
|
|
575
|
-
|
|
576
|
-
1. File -> Test -> Start (set player count to 2+)
|
|
577
|
-
2. Each client window is a separate player instance
|
|
578
|
-
3. Server window shows server-side output
|
|
579
|
-
4. Verify: leaderboards update for both, chat works, interactions replicate
|
|
580
|
-
|
|
581
|
-
---
|
|
582
|
-
|
|
583
|
-
## 9. Test Organization
|
|
584
|
-
|
|
585
|
-
### Project Structure
|
|
586
|
-
|
|
587
|
-
```
|
|
588
|
-
ServerScriptService/
|
|
589
|
-
Tests/
|
|
590
|
-
TestRunner (Script)
|
|
591
|
-
Specs/
|
|
592
|
-
CurrencyManager.spec
|
|
593
|
-
DamageCalc.spec
|
|
594
|
-
Mocks/
|
|
595
|
-
MockDataStoreService
|
|
596
|
-
MockPlayers
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
### Naming Conventions
|
|
600
|
-
|
|
601
|
-
| Convention | Example |
|
|
602
|
-
|---|---|
|
|
603
|
-
| Spec file suffix | `CurrencyManager.spec.luau` |
|
|
604
|
-
| Mock file prefix | `MockDataStoreService.luau` |
|
|
605
|
-
| Describe block | `describe("CurrencyManager")` |
|
|
606
|
-
| Test name | `it("should deduct gold on purchase")` |
|
|
607
|
-
|
|
608
|
-
---
|
|
609
|
-
|
|
610
|
-
---
|
|
611
|
-
|
|
612
|
-
## 11. Best Practices
|
|
613
|
-
|
|
614
|
-
### Test Critical Paths First
|
|
615
|
-
|
|
616
|
-
Prioritize tests for code where bugs cost the most:
|
|
617
|
-
|
|
618
|
-
1. **Data save/load** - A bug here can wipe player progress. Test serialization, deserialization, migration, and edge cases (empty data, corrupt data).
|
|
619
|
-
2. **Purchases and monetization** - Receipt processing must be idempotent. Test duplicate receipts, test granting after purchase, test failure recovery.
|
|
620
|
-
3. **Combat damage / core gameplay math** - Players notice immediately when damage numbers are wrong. Test crit, armor, buffs, edge values.
|
|
621
|
-
4. **Server-side validation** - Every RemoteEvent handler that accepts client input must validate. Test with out-of-range values, wrong types, and nil.
|
|
622
|
-
|
|
623
|
-
### General Guidelines
|
|
624
|
-
|
|
625
|
-
- **Keep tests fast.** Each test should run in milliseconds. If a test needs `task.wait()`, you are likely testing integration-level behavior - separate it from unit tests.
|
|
626
|
-
- **One assertion focus per test.** A test named `"should add gold"` should test adding gold, not also test removing gold and checking the balance format.
|
|
627
|
-
- **Test server-side validation independently.** Do not rely on the client sending correct data. Write tests that call server validation functions with malicious inputs.
|
|
628
|
-
- **Write a test before fixing a bug.** Reproduce the bug in a failing test first, then fix the code until the test passes. This prevents regressions.
|
|
629
|
-
- **Use deterministic data.** Avoid `math.random()` in tests. Use fixed seed values or hardcoded inputs so failures are reproducible.
|
|
630
|
-
- **Reset state in `beforeEach`.** Never let one test depend on the side effects of another.
|
|
631
|
-
|
|
632
|
-
---
|
|
633
|
-
|
|
634
|
-
## 12. Anti-Patterns
|
|
635
|
-
|
|
636
|
-
### Testing Only Manually in Studio
|
|
637
|
-
|
|
638
|
-
**Problem:** You click around in Studio, it seems to work, you ship. A week later an edge case surfaces in production.
|
|
639
|
-
|
|
640
|
-
**Fix:** Write automated tests for core logic. Manual playtesting supplements automated tests; it does not replace them.
|
|
641
|
-
|
|
642
|
-
### No Tests for Monetization Code
|
|
643
|
-
|
|
644
|
-
**Problem:** Receipt processing is written once, never tested, and breaks silently. Players pay real money and receive nothing.
|
|
645
|
-
|
|
646
|
-
**Fix:** Unit test `processReceipt` with mock MarketplaceService. Test duplicate receipts, test every product ID, test failure paths.
|
|
647
|
-
|
|
648
|
-
### Untestable Tightly-Coupled Modules
|
|
649
|
-
|
|
650
|
-
**Problem:**
|
|
651
|
-
|
|
652
|
-
```luau
|
|
653
|
-
-- Everything is hardcoded; impossible to test without a live game
|
|
654
|
-
function Module.onPlayerJoin()
|
|
655
|
-
local player = game.Players.LocalPlayer
|
|
656
|
-
local data = game:GetService("DataStoreService"):GetDataStore("Main"):GetAsync(player.UserId)
|
|
657
|
-
local gui = player.PlayerGui:WaitForChild("MainUI")
|
|
658
|
-
gui.GoldLabel.Text = tostring(data.gold)
|
|
659
|
-
end
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
**Fix:** Break it apart. Pure logic in one module, engine glue in another. Inject services.
|
|
663
|
-
|
|
664
|
-
```luau
|
|
665
|
-
-- Pure logic (testable)
|
|
666
|
-
function CurrencyFormatter.formatGold(gold: number): string
|
|
667
|
-
return tostring(gold)
|
|
668
|
-
end
|
|
669
|
-
|
|
670
|
-
-- Glue code (thin, not unit tested, covered by integration/manual tests)
|
|
671
|
-
function UIController.updateGoldDisplay(player, gold)
|
|
672
|
-
local gui = player.PlayerGui:WaitForChild("MainUI")
|
|
673
|
-
gui.GoldLabel.Text = CurrencyFormatter.formatGold(gold)
|
|
674
|
-
end
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
### Tests That Depend on Execution Order
|
|
678
|
-
|
|
679
|
-
**Problem:** Test B passes only if Test A runs first because A sets up shared state.
|
|
680
|
-
|
|
681
|
-
**Fix:** Use `beforeEach` to create fresh state for every test. Each test must be independently runnable.
|
|
682
|
-
|
|
683
|
-
### Ignoring Flaky Tests
|
|
684
|
-
|
|
685
|
-
**Problem:** A test sometimes passes and sometimes fails. The team marks it as "known flaky" and ignores it.
|
|
686
|
-
|
|
687
|
-
**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.
|
|
688
|
-
|
|
689
|
-
---
|
|
690
|
-
|
|
691
|
-
## Full Example: CurrencyManager with Tests
|
|
692
|
-
|
|
693
|
-
### Module (`CurrencyManager.luau`)
|
|
694
|
-
|
|
695
|
-
```luau
|
|
696
|
-
local CurrencyManager = {}
|
|
697
|
-
CurrencyManager.__index = CurrencyManager
|
|
698
|
-
|
|
699
|
-
export type CurrencyData = {
|
|
700
|
-
gold: number,
|
|
701
|
-
gems: number,
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function CurrencyManager.new(dataStoreService)
|
|
705
|
-
local self = setmetatable({}, CurrencyManager)
|
|
706
|
-
self._store = dataStoreService:GetDataStore("Currency")
|
|
707
|
-
self._cache = {} :: { [number]: CurrencyData }
|
|
708
|
-
return self
|
|
709
|
-
end
|
|
710
|
-
|
|
711
|
-
function CurrencyManager.newPlayerData(): CurrencyData
|
|
712
|
-
return {
|
|
713
|
-
gold = 0,
|
|
714
|
-
gems = 0,
|
|
715
|
-
}
|
|
716
|
-
end
|
|
717
|
-
|
|
718
|
-
function CurrencyManager:loadPlayer(playerId: number): CurrencyData
|
|
719
|
-
local raw = self._store:GetAsync(`currency_{playerId}`)
|
|
720
|
-
local data = raw or CurrencyManager.newPlayerData()
|
|
721
|
-
self._cache[playerId] = data
|
|
722
|
-
return data
|
|
723
|
-
end
|
|
724
|
-
|
|
725
|
-
function CurrencyManager:savePlayer(playerId: number)
|
|
726
|
-
local data = self._cache[playerId]
|
|
727
|
-
if data then
|
|
728
|
-
self._store:SetAsync(`currency_{playerId}`, data)
|
|
729
|
-
end
|
|
730
|
-
end
|
|
731
|
-
|
|
732
|
-
function CurrencyManager:getGold(playerId: number): number
|
|
733
|
-
local data = self._cache[playerId]
|
|
734
|
-
return if data then data.gold else 0
|
|
735
|
-
end
|
|
736
|
-
|
|
737
|
-
function CurrencyManager:addGold(playerId: number, amount: number): boolean
|
|
738
|
-
if amount <= 0 then
|
|
739
|
-
return false
|
|
740
|
-
end
|
|
741
|
-
local data = self._cache[playerId]
|
|
742
|
-
if not data then
|
|
743
|
-
return false
|
|
744
|
-
end
|
|
745
|
-
data.gold += amount
|
|
746
|
-
return true
|
|
747
|
-
end
|
|
748
|
-
|
|
749
|
-
function CurrencyManager:removeGold(playerId: number, amount: number): boolean
|
|
750
|
-
if amount <= 0 then
|
|
751
|
-
return false
|
|
752
|
-
end
|
|
753
|
-
local data = self._cache[playerId]
|
|
754
|
-
if not data then
|
|
755
|
-
return false
|
|
756
|
-
end
|
|
757
|
-
if data.gold < amount then
|
|
758
|
-
return false -- insufficient funds
|
|
759
|
-
end
|
|
760
|
-
data.gold -= amount
|
|
761
|
-
return true
|
|
762
|
-
end
|
|
763
|
-
|
|
764
|
-
function CurrencyManager:transferGold(fromId: number, toId: number, amount: number): boolean
|
|
765
|
-
if not self:removeGold(fromId, amount) then
|
|
766
|
-
return false
|
|
767
|
-
end
|
|
768
|
-
if not self:addGold(toId, amount) then
|
|
769
|
-
-- Rollback
|
|
770
|
-
self:addGold(fromId, amount)
|
|
771
|
-
return false
|
|
772
|
-
end
|
|
773
|
-
return true
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
return CurrencyManager
|
|
777
|
-
```
|
|
778
|
-
|
|
779
|
-
### Test (`CurrencyManager.spec.luau`)
|
|
780
|
-
|
|
781
|
-
```luau
|
|
782
|
-
return function()
|
|
783
|
-
local MockDataStoreService = require(script.Parent.Parent.Mocks.MockDataStoreService)
|
|
784
|
-
local CurrencyManager = require(script.Parent.CurrencyManager)
|
|
785
|
-
|
|
786
|
-
describe("CurrencyManager", function()
|
|
787
|
-
local mockDSS
|
|
788
|
-
local manager
|
|
789
|
-
|
|
790
|
-
beforeEach(function()
|
|
791
|
-
mockDSS = MockDataStoreService.new()
|
|
792
|
-
manager = CurrencyManager.new(mockDSS)
|
|
793
|
-
end)
|
|
794
|
-
|
|
795
|
-
describe("newPlayerData", function()
|
|
796
|
-
it("should return zero gold and zero gems", function()
|
|
797
|
-
local data = CurrencyManager.newPlayerData()
|
|
798
|
-
expect(data.gold).to.equal(0)
|
|
799
|
-
expect(data.gems).to.equal(0)
|
|
800
|
-
end)
|
|
801
|
-
end)
|
|
802
|
-
|
|
803
|
-
describe("loadPlayer", function()
|
|
804
|
-
it("should return default data for a new player", function()
|
|
805
|
-
local data = manager:loadPlayer(1001)
|
|
806
|
-
expect(data.gold).to.equal(0)
|
|
807
|
-
expect(data.gems).to.equal(0)
|
|
808
|
-
end)
|
|
809
|
-
|
|
810
|
-
it("should return saved data for an existing player", function()
|
|
811
|
-
-- Pre-populate the mock store
|
|
812
|
-
local store = mockDSS:GetDataStore("Currency")
|
|
813
|
-
store:SetAsync("currency_1001", { gold = 500, gems = 10 })
|
|
814
|
-
|
|
815
|
-
local data = manager:loadPlayer(1001)
|
|
816
|
-
expect(data.gold).to.equal(500)
|
|
817
|
-
expect(data.gems).to.equal(10)
|
|
818
|
-
end)
|
|
819
|
-
end)
|
|
820
|
-
|
|
821
|
-
describe("savePlayer", function()
|
|
822
|
-
it("should persist data to the store", function()
|
|
823
|
-
manager:loadPlayer(1001)
|
|
824
|
-
manager:addGold(1001, 250)
|
|
825
|
-
manager:savePlayer(1001)
|
|
826
|
-
|
|
827
|
-
-- Verify by reading directly from the mock store
|
|
828
|
-
local store = mockDSS:GetDataStore("Currency")
|
|
829
|
-
local raw = store:GetAsync("currency_1001")
|
|
830
|
-
expect(raw.gold).to.equal(250)
|
|
831
|
-
end)
|
|
832
|
-
|
|
833
|
-
it("should do nothing for an unloaded player", function()
|
|
834
|
-
-- Should not error
|
|
835
|
-
manager:savePlayer(9999)
|
|
836
|
-
end)
|
|
837
|
-
end)
|
|
838
|
-
|
|
839
|
-
describe("addGold", function()
|
|
840
|
-
it("should increase gold by the given amount", function()
|
|
841
|
-
manager:loadPlayer(1001)
|
|
842
|
-
local ok = manager:addGold(1001, 100)
|
|
843
|
-
expect(ok).to.equal(true)
|
|
844
|
-
expect(manager:getGold(1001)).to.equal(100)
|
|
845
|
-
end)
|
|
846
|
-
|
|
847
|
-
it("should accumulate across multiple calls", function()
|
|
848
|
-
manager:loadPlayer(1001)
|
|
849
|
-
manager:addGold(1001, 50)
|
|
850
|
-
manager:addGold(1001, 75)
|
|
851
|
-
expect(manager:getGold(1001)).to.equal(125)
|
|
852
|
-
end)
|
|
853
|
-
|
|
854
|
-
it("should reject zero amount", function()
|
|
855
|
-
manager:loadPlayer(1001)
|
|
856
|
-
local ok = manager:addGold(1001, 0)
|
|
857
|
-
expect(ok).to.equal(false)
|
|
858
|
-
expect(manager:getGold(1001)).to.equal(0)
|
|
859
|
-
end)
|
|
860
|
-
|
|
861
|
-
it("should reject negative amount", function()
|
|
862
|
-
manager:loadPlayer(1001)
|
|
863
|
-
local ok = manager:addGold(1001, -50)
|
|
864
|
-
expect(ok).to.equal(false)
|
|
865
|
-
end)
|
|
866
|
-
|
|
867
|
-
it("should fail for unloaded player", function()
|
|
868
|
-
local ok = manager:addGold(9999, 100)
|
|
869
|
-
expect(ok).to.equal(false)
|
|
870
|
-
end)
|
|
871
|
-
end)
|
|
872
|
-
|
|
873
|
-
describe("removeGold", function()
|
|
874
|
-
it("should decrease gold by the given amount", function()
|
|
875
|
-
manager:loadPlayer(1001)
|
|
876
|
-
manager:addGold(1001, 200)
|
|
877
|
-
local ok = manager:removeGold(1001, 50)
|
|
878
|
-
expect(ok).to.equal(true)
|
|
879
|
-
expect(manager:getGold(1001)).to.equal(150)
|
|
880
|
-
end)
|
|
881
|
-
|
|
882
|
-
it("should reject removal exceeding balance", function()
|
|
883
|
-
manager:loadPlayer(1001)
|
|
884
|
-
manager:addGold(1001, 30)
|
|
885
|
-
local ok = manager:removeGold(1001, 50)
|
|
886
|
-
expect(ok).to.equal(false)
|
|
887
|
-
expect(manager:getGold(1001)).to.equal(30) -- unchanged
|
|
888
|
-
end)
|
|
889
|
-
|
|
890
|
-
it("should reject zero amount", function()
|
|
891
|
-
manager:loadPlayer(1001)
|
|
892
|
-
local ok = manager:removeGold(1001, 0)
|
|
893
|
-
expect(ok).to.equal(false)
|
|
894
|
-
end)
|
|
895
|
-
|
|
896
|
-
it("should reject negative amount", function()
|
|
897
|
-
manager:loadPlayer(1001)
|
|
898
|
-
local ok = manager:removeGold(1001, -10)
|
|
899
|
-
expect(ok).to.equal(false)
|
|
900
|
-
end)
|
|
901
|
-
end)
|
|
902
|
-
|
|
903
|
-
describe("transferGold", function()
|
|
904
|
-
it("should move gold from one player to another", function()
|
|
905
|
-
manager:loadPlayer(1001)
|
|
906
|
-
manager:loadPlayer(1002)
|
|
907
|
-
manager:addGold(1001, 500)
|
|
908
|
-
|
|
909
|
-
local ok = manager:transferGold(1001, 1002, 200)
|
|
910
|
-
expect(ok).to.equal(true)
|
|
911
|
-
expect(manager:getGold(1001)).to.equal(300)
|
|
912
|
-
expect(manager:getGold(1002)).to.equal(200)
|
|
913
|
-
end)
|
|
914
|
-
|
|
915
|
-
it("should fail if sender has insufficient funds", function()
|
|
916
|
-
manager:loadPlayer(1001)
|
|
917
|
-
manager:loadPlayer(1002)
|
|
918
|
-
manager:addGold(1001, 50)
|
|
919
|
-
|
|
920
|
-
local ok = manager:transferGold(1001, 1002, 100)
|
|
921
|
-
expect(ok).to.equal(false)
|
|
922
|
-
expect(manager:getGold(1001)).to.equal(50) -- unchanged
|
|
923
|
-
expect(manager:getGold(1002)).to.equal(0) -- unchanged
|
|
924
|
-
end)
|
|
925
|
-
|
|
926
|
-
it("should fail if recipient is not loaded", function()
|
|
927
|
-
manager:loadPlayer(1001)
|
|
928
|
-
manager:addGold(1001, 500)
|
|
929
|
-
|
|
930
|
-
local ok = manager:transferGold(1001, 9999, 100)
|
|
931
|
-
expect(ok).to.equal(false)
|
|
932
|
-
expect(manager:getGold(1001)).to.equal(500) -- rollback
|
|
933
|
-
end)
|
|
934
|
-
end)
|
|
935
|
-
|
|
936
|
-
describe("getGold", function()
|
|
937
|
-
it("should return 0 for unloaded player", function()
|
|
938
|
-
expect(manager:getGold(9999)).to.equal(0)
|
|
939
|
-
end)
|
|
940
|
-
end)
|
|
941
|
-
end)
|
|
942
|
-
end
|
|
943
|
-
```
|
|
1
|
+
---
|
|
2
|
+
name: roblox-testing
|
|
3
|
+
description: TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox.
|
|
4
|
+
last_reviewed: 2026-05-21
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- Source: brockmartin/roblox-game-skill (MIT) -->
|
|
8
|
+
|
|
9
|
+
# Roblox Testing Patterns Reference
|
|
10
|
+
|
|
11
|
+
## 1. Overview
|
|
12
|
+
|
|
13
|
+
Load this reference when:
|
|
14
|
+
|
|
15
|
+
- Writing unit or integration tests for Roblox game modules
|
|
16
|
+
- Setting up test infrastructure for a new or existing project
|
|
17
|
+
- Configuring CI/CD pipelines for automated linting, formatting, and test runs
|
|
18
|
+
- Refactoring modules to improve testability
|
|
19
|
+
- Debugging failures caught during playtesting or production monitoring
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick Reference
|
|
26
|
+
|
|
27
|
+
**Load Full Reference below only when you need specific mock implementations or test patterns.**
|
|
28
|
+
|
|
29
|
+
Key rules:
|
|
30
|
+
- TestEZ is the framework. BDD style: `describe/it/expect`. Files named `*.spec.luau`.
|
|
31
|
+
- Test pure logic modules first (no Roblox dependencies). Highest ROI.
|
|
32
|
+
- Dependency injection for testability: pass services as constructor args, mock in tests.
|
|
33
|
+
- Mock pattern: table that mimics the service interface. Only implement methods you test.
|
|
34
|
+
- `beforeEach` for fresh state per test. Never share mutable state between `it` blocks.
|
|
35
|
+
- Integration tests: test module interactions, not Roblox engine behavior.
|
|
36
|
+
- MCP-powered testing: use execute_luau to run tests in Studio, read output.
|
|
37
|
+
- Don't test Roblox engine behavior (physics, rendering). Test YOUR logic.
|
|
38
|
+
- Run tests via: require(TestEZ).TestBootstrap:run({testRoot})
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Full Reference
|
|
43
|
+
|
|
44
|
+
## 2. TestEZ Framework
|
|
45
|
+
|
|
46
|
+
> **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.
|
|
47
|
+
|
|
48
|
+
### Test File Conventions
|
|
49
|
+
|
|
50
|
+
- Test files use the suffix `.spec.luau` (e.g., `CurrencyManager.spec.luau`).
|
|
51
|
+
- Each spec file lives alongside or mirrors the module it tests.
|
|
52
|
+
- TestEZ discovers specs by recursively scanning a root container you point it at.
|
|
53
|
+
|
|
54
|
+
### Core Syntax
|
|
55
|
+
|
|
56
|
+
```luau
|
|
57
|
+
return function()
|
|
58
|
+
describe("CurrencyManager", function()
|
|
59
|
+
local CurrencyManager
|
|
60
|
+
|
|
61
|
+
beforeEach(function()
|
|
62
|
+
-- Fresh module state before every test
|
|
63
|
+
CurrencyManager = require(script.Parent.CurrencyManager)
|
|
64
|
+
end)
|
|
65
|
+
|
|
66
|
+
afterEach(function()
|
|
67
|
+
-- Teardown: reset any shared state
|
|
68
|
+
end)
|
|
69
|
+
|
|
70
|
+
it("should initialize a player with zero gold", function()
|
|
71
|
+
local data = CurrencyManager.newPlayerData()
|
|
72
|
+
expect(data.gold).to.equal(0)
|
|
73
|
+
end)
|
|
74
|
+
|
|
75
|
+
it("should add currency correctly", function()
|
|
76
|
+
local data = CurrencyManager.newPlayerData()
|
|
77
|
+
CurrencyManager.addGold(data, 100)
|
|
78
|
+
expect(data.gold).to.equal(100)
|
|
79
|
+
end)
|
|
80
|
+
|
|
81
|
+
it("should never allow negative gold", function()
|
|
82
|
+
local data = CurrencyManager.newPlayerData()
|
|
83
|
+
CurrencyManager.addGold(data, 50)
|
|
84
|
+
CurrencyManager.removeGold(data, 999)
|
|
85
|
+
expect(data.gold).to.equal(0)
|
|
86
|
+
end)
|
|
87
|
+
end)
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Assertion API Highlights
|
|
92
|
+
|
|
93
|
+
```luau
|
|
94
|
+
expect(value).to.equal(expected) -- strict equality
|
|
95
|
+
expect(value).to.be.ok() -- truthy
|
|
96
|
+
expect(value).to.be.a("table") -- type check
|
|
97
|
+
expect(value).never.to.equal(unexpected) -- negation
|
|
98
|
+
expect(function()
|
|
99
|
+
error("boom")
|
|
100
|
+
end).to.throw() -- error expected
|
|
101
|
+
expect(value).to.be.near(3.14, 0.01) -- float tolerance
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Running Tests Inside Studio
|
|
105
|
+
|
|
106
|
+
Create a test runner script in `ServerScriptService`:
|
|
107
|
+
|
|
108
|
+
```luau
|
|
109
|
+
local TestEZ = require(game.ReplicatedStorage.DevPackages.TestEZ)
|
|
110
|
+
local results = TestEZ.TestBootstrap:run({
|
|
111
|
+
game.ReplicatedStorage.Shared, -- scan these containers
|
|
112
|
+
game.ServerScriptService.Server,
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 3. Unit Testing ModuleScripts
|
|
119
|
+
|
|
120
|
+
Unit tests target **pure logic** - functions that take inputs and return outputs without touching engine APIs.
|
|
121
|
+
|
|
122
|
+
### Good Candidates for Unit Testing
|
|
123
|
+
|
|
124
|
+
| Module Type | Examples |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Damage calculation | `calculateDamage(baseDmg, armor, crit)` |
|
|
127
|
+
| Inventory operations | `addItem(inventory, itemId, qty)` |
|
|
128
|
+
| Data transforms | `serializeLoadout(loadout)` / `deserializeLoadout(raw)` |
|
|
129
|
+
| Config validators | `validateWeaponConfig(config)` |
|
|
130
|
+
| Math/utility | `clamp`, `lerp`, `formatNumber` |
|
|
131
|
+
|
|
132
|
+
### Pattern: Test a Pure Module
|
|
133
|
+
|
|
134
|
+
Module under test (`DamageCalc.luau`):
|
|
135
|
+
|
|
136
|
+
```luau
|
|
137
|
+
local DamageCalc = {}
|
|
138
|
+
|
|
139
|
+
function DamageCalc.calculate(baseDamage: number, armor: number, isCrit: boolean): number
|
|
140
|
+
local reduction = math.clamp(armor / 100, 0, 0.75)
|
|
141
|
+
local damage = baseDamage * (1 - reduction)
|
|
142
|
+
if isCrit then
|
|
143
|
+
damage *= 2
|
|
144
|
+
end
|
|
145
|
+
return math.floor(damage)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return DamageCalc
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Spec (`DamageCalc.spec.luau`):
|
|
152
|
+
|
|
153
|
+
```luau
|
|
154
|
+
return function()
|
|
155
|
+
local DamageCalc = require(script.Parent.DamageCalc)
|
|
156
|
+
|
|
157
|
+
describe("calculate", function()
|
|
158
|
+
it("should apply armor reduction", function()
|
|
159
|
+
-- 50 armor = 50% reduction on 100 base = 50
|
|
160
|
+
expect(DamageCalc.calculate(100, 50, false)).to.equal(50)
|
|
161
|
+
end)
|
|
162
|
+
|
|
163
|
+
it("should cap armor reduction at 75%", function()
|
|
164
|
+
expect(DamageCalc.calculate(100, 200, false)).to.equal(25)
|
|
165
|
+
end)
|
|
166
|
+
|
|
167
|
+
it("should double damage on crit", function()
|
|
168
|
+
expect(DamageCalc.calculate(100, 0, true)).to.equal(200)
|
|
169
|
+
end)
|
|
170
|
+
|
|
171
|
+
it("should floor the result", function()
|
|
172
|
+
expect(DamageCalc.calculate(33, 10, false)).to.equal(29)
|
|
173
|
+
end)
|
|
174
|
+
end)
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Keep Modules Testable
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
```luau
|
|
183
|
+
-- BAD: untestable, reaches into the engine
|
|
184
|
+
function Module.getPlayerHealth(player)
|
|
185
|
+
local char = player.Character
|
|
186
|
+
local humanoid = char:FindFirstChildOfClass("Humanoid")
|
|
187
|
+
return humanoid.Health
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
-- GOOD: testable, accepts the value it needs
|
|
191
|
+
function Module.isLowHealth(currentHealth: number, threshold: number): boolean
|
|
192
|
+
return currentHealth <= threshold
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 4. Dependency Injection for Testability
|
|
199
|
+
|
|
200
|
+
When a module must interact with a Roblox service, inject the service as a parameter instead of hard-coding `game:GetService()`.
|
|
201
|
+
|
|
202
|
+
### Pattern: Constructor Injection
|
|
203
|
+
|
|
204
|
+
```luau
|
|
205
|
+
local InventoryManager = {}
|
|
206
|
+
InventoryManager.__index = InventoryManager
|
|
207
|
+
|
|
208
|
+
-- Accept services through the constructor
|
|
209
|
+
function InventoryManager.new(dataStoreService, messagingService)
|
|
210
|
+
local self = setmetatable({}, InventoryManager)
|
|
211
|
+
self._dataStore = dataStoreService:GetDataStore("Inventory")
|
|
212
|
+
self._messaging = messagingService
|
|
213
|
+
return self
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
function InventoryManager:saveInventory(playerId: number, inventory: { [string]: number })
|
|
217
|
+
local key = `inv_{playerId}`
|
|
218
|
+
self._dataStore:SetAsync(key, inventory)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
function InventoryManager:loadInventory(playerId: number): { [string]: number }
|
|
222
|
+
local key = `inv_{playerId}`
|
|
223
|
+
return self._dataStore:GetAsync(key) or {}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
return InventoryManager
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Production Wiring
|
|
230
|
+
|
|
231
|
+
```luau
|
|
232
|
+
local DataStoreService = game:GetService("DataStoreService")
|
|
233
|
+
local MessagingService = game:GetService("MessagingService")
|
|
234
|
+
local InventoryManager = require(path.to.InventoryManager)
|
|
235
|
+
|
|
236
|
+
local manager = InventoryManager.new(DataStoreService, MessagingService)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Test Wiring (inject mocks)
|
|
240
|
+
|
|
241
|
+
```luau
|
|
242
|
+
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
|
|
243
|
+
local InventoryManager = require(script.Parent.InventoryManager)
|
|
244
|
+
|
|
245
|
+
local manager = InventoryManager.new(MockDataStoreService.new(), mockMessaging)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Alternative: Module-Level Injection via `.init()`
|
|
249
|
+
|
|
250
|
+
For modules that are singletons rather than classes:
|
|
251
|
+
|
|
252
|
+
```luau
|
|
253
|
+
local Module = {}
|
|
254
|
+
local _players = nil
|
|
255
|
+
|
|
256
|
+
function Module.init(playersService)
|
|
257
|
+
_players = playersService
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
function Module.getPlayerCount(): number
|
|
261
|
+
return #_players:GetPlayers()
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
return Module
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 5. Mocking Roblox Services
|
|
270
|
+
|
|
271
|
+
### Mock Players Service
|
|
272
|
+
|
|
273
|
+
```luau
|
|
274
|
+
local MockPlayers = {}
|
|
275
|
+
MockPlayers.__index = MockPlayers
|
|
276
|
+
|
|
277
|
+
function MockPlayers.new()
|
|
278
|
+
local self = setmetatable({}, MockPlayers)
|
|
279
|
+
self._players = {}
|
|
280
|
+
self.PlayerAdded = MockSignal.new()
|
|
281
|
+
self.PlayerRemoving = MockSignal.new()
|
|
282
|
+
return self
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
function MockPlayers:GetPlayers()
|
|
286
|
+
return self._players
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
function MockPlayers:addFakePlayer(mockPlayer)
|
|
290
|
+
table.insert(self._players, mockPlayer)
|
|
291
|
+
self.PlayerAdded:Fire(mockPlayer)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
function MockPlayers:removeFakePlayer(mockPlayer)
|
|
295
|
+
local idx = table.find(self._players, mockPlayer)
|
|
296
|
+
if idx then
|
|
297
|
+
table.remove(self._players, idx)
|
|
298
|
+
self.PlayerRemoving:Fire(mockPlayer)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
return MockPlayers
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Mock Signal (for RBXScriptSignal-like behavior)
|
|
306
|
+
|
|
307
|
+
```luau
|
|
308
|
+
local MockSignal = {}
|
|
309
|
+
MockSignal.__index = MockSignal
|
|
310
|
+
|
|
311
|
+
function MockSignal.new()
|
|
312
|
+
return setmetatable({ _connections = {} }, MockSignal)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
function MockSignal:Connect(callback)
|
|
316
|
+
table.insert(self._connections, callback)
|
|
317
|
+
return {
|
|
318
|
+
Disconnect = function(conn)
|
|
319
|
+
local idx = table.find(self._connections, callback)
|
|
320
|
+
if idx then
|
|
321
|
+
table.remove(self._connections, idx)
|
|
322
|
+
end
|
|
323
|
+
end,
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
function MockSignal:Fire(...)
|
|
328
|
+
for _, cb in self._connections do
|
|
329
|
+
task.spawn(cb, ...)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
return MockSignal
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Mock DataStoreService (In-Memory)
|
|
337
|
+
|
|
338
|
+
```luau
|
|
339
|
+
local MockDataStore = {}
|
|
340
|
+
MockDataStore.__index = MockDataStore
|
|
341
|
+
|
|
342
|
+
function MockDataStore.new()
|
|
343
|
+
return setmetatable({ _data = {} }, MockDataStore)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
function MockDataStore:GetAsync(key)
|
|
347
|
+
return self._data[key]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
function MockDataStore:SetAsync(key, value)
|
|
351
|
+
self._data[key] = value
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
function MockDataStore:UpdateAsync(key, transformFunction)
|
|
355
|
+
local old = self._data[key]
|
|
356
|
+
self._data[key] = transformFunction(old)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
function MockDataStore:RemoveAsync(key)
|
|
360
|
+
self._data[key] = nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
-- ------------------------------------------------
|
|
364
|
+
|
|
365
|
+
local MockDataStoreService = {}
|
|
366
|
+
MockDataStoreService.__index = MockDataStoreService
|
|
367
|
+
|
|
368
|
+
function MockDataStoreService.new()
|
|
369
|
+
return setmetatable({ _stores = {} }, MockDataStoreService)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
function MockDataStoreService:GetDataStore(name)
|
|
373
|
+
if not self._stores[name] then
|
|
374
|
+
self._stores[name] = MockDataStore.new()
|
|
375
|
+
end
|
|
376
|
+
return self._stores[name]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
return MockDataStoreService
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Mock MarketplaceService
|
|
383
|
+
|
|
384
|
+
```luau
|
|
385
|
+
local MockMarketplaceService = {}
|
|
386
|
+
MockMarketplaceService.__index = MockMarketplaceService
|
|
387
|
+
|
|
388
|
+
function MockMarketplaceService.new(ownedGamepasses: { [number]: boolean }?)
|
|
389
|
+
local self = setmetatable({}, MockMarketplaceService)
|
|
390
|
+
self._ownedPasses = ownedGamepasses or {}
|
|
391
|
+
self.PromptGamePassPurchaseFinished = MockSignal.new()
|
|
392
|
+
return self
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
function MockMarketplaceService:UserOwnsGamePassAsync(_userId, gamePassId)
|
|
396
|
+
return self._ownedPasses[gamePassId] == true
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
return MockMarketplaceService
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## 6. Integration Testing
|
|
405
|
+
|
|
406
|
+
Integration tests verify that **multiple modules work together correctly** - data flows across boundaries and side effects happen as expected.
|
|
407
|
+
|
|
408
|
+
### Pattern: DataManager + InventoryManager
|
|
409
|
+
|
|
410
|
+
```luau
|
|
411
|
+
return function()
|
|
412
|
+
local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
|
|
413
|
+
local DataManager = require(script.Parent.DataManager)
|
|
414
|
+
local InventoryManager = require(script.Parent.InventoryManager)
|
|
415
|
+
|
|
416
|
+
describe("DataManager + InventoryManager integration", function()
|
|
417
|
+
local mockDSS
|
|
418
|
+
local dataMgr
|
|
419
|
+
local invMgr
|
|
420
|
+
|
|
421
|
+
beforeEach(function()
|
|
422
|
+
mockDSS = MockDataStoreService.new()
|
|
423
|
+
dataMgr = DataManager.new(mockDSS)
|
|
424
|
+
invMgr = InventoryManager.new(mockDSS)
|
|
425
|
+
end)
|
|
426
|
+
|
|
427
|
+
it("should persist inventory through save/load cycle", function()
|
|
428
|
+
local playerId = 12345
|
|
429
|
+
local inventory = { Sword = 1, Shield = 2, Potion = 10 }
|
|
430
|
+
|
|
431
|
+
invMgr:saveInventory(playerId, inventory)
|
|
432
|
+
local loaded = invMgr:loadInventory(playerId)
|
|
433
|
+
|
|
434
|
+
expect(loaded.Sword).to.equal(1)
|
|
435
|
+
expect(loaded.Shield).to.equal(2)
|
|
436
|
+
expect(loaded.Potion).to.equal(10)
|
|
437
|
+
end)
|
|
438
|
+
|
|
439
|
+
it("should return empty inventory for new player", function()
|
|
440
|
+
local loaded = invMgr:loadInventory(99999)
|
|
441
|
+
expect(next(loaded)).to.equal(nil)
|
|
442
|
+
end)
|
|
443
|
+
end)
|
|
444
|
+
end
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### Testing RemoteEvent Flows
|
|
448
|
+
|
|
449
|
+
For end-to-end remote flows in a test environment, create mock remotes:
|
|
450
|
+
|
|
451
|
+
```luau
|
|
452
|
+
local MockRemoteEvent = {}
|
|
453
|
+
MockRemoteEvent.__index = MockRemoteEvent
|
|
454
|
+
|
|
455
|
+
function MockRemoteEvent.new()
|
|
456
|
+
local self = setmetatable({}, MockRemoteEvent)
|
|
457
|
+
self.OnServerEvent = MockSignal.new()
|
|
458
|
+
self.OnClientEvent = MockSignal.new()
|
|
459
|
+
return self
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
function MockRemoteEvent:FireServer(...)
|
|
463
|
+
self.OnServerEvent:Fire(nil, ...) -- nil = fake player in test
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
function MockRemoteEvent:FireClient(_player, ...)
|
|
467
|
+
self.OnClientEvent:Fire(...)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
function MockRemoteEvent:FireAllClients(...)
|
|
471
|
+
self.OnClientEvent:Fire(...)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
return MockRemoteEvent
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Usage in a test:
|
|
478
|
+
|
|
479
|
+
```luau
|
|
480
|
+
it("should process purchase request and respond", function()
|
|
481
|
+
local remote = MockRemoteEvent.new()
|
|
482
|
+
local responded = false
|
|
483
|
+
|
|
484
|
+
-- Simulate server handler
|
|
485
|
+
remote.OnServerEvent:Connect(function(_player, itemId)
|
|
486
|
+
-- Server validates and responds
|
|
487
|
+
local success = ShopManager:tryPurchase(itemId, playerData)
|
|
488
|
+
remote:FireClient(nil, success, itemId)
|
|
489
|
+
end)
|
|
490
|
+
|
|
491
|
+
-- Capture client response
|
|
492
|
+
remote.OnClientEvent:Connect(function(success, itemId)
|
|
493
|
+
responded = true
|
|
494
|
+
expect(success).to.equal(true)
|
|
495
|
+
expect(itemId).to.equal("Sword")
|
|
496
|
+
end)
|
|
497
|
+
|
|
498
|
+
remote:FireServer("Sword")
|
|
499
|
+
expect(responded).to.equal(true)
|
|
500
|
+
end)
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## 7. MCP-Powered Testing
|
|
506
|
+
|
|
507
|
+
When Roblox Studio MCP tools are available, use them for automated smoke testing without leaving your editor.
|
|
508
|
+
|
|
509
|
+
### Automated Smoke Test Workflow
|
|
510
|
+
|
|
511
|
+
```
|
|
512
|
+
1. start_playtest()
|
|
513
|
+
- Launches a local test server in Studio
|
|
514
|
+
|
|
515
|
+
2. wait 5-10 seconds for game initialization
|
|
516
|
+
|
|
517
|
+
3. get_playtest_output()
|
|
518
|
+
- Captures Output window logs
|
|
519
|
+
- Look for: errors, warnings, "Script timeout" messages
|
|
520
|
+
|
|
521
|
+
4. Analyze the output
|
|
522
|
+
- Any red error lines? -> investigate and fix
|
|
523
|
+
- Module load failures? -> check requires and paths
|
|
524
|
+
- DataStore errors? -> check mock/fallback setup
|
|
525
|
+
|
|
526
|
+
5. stop_playtest()
|
|
527
|
+
- Ends the session cleanly
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Iterative Debugging Loop with MCP
|
|
531
|
+
|
|
532
|
+
```
|
|
533
|
+
REPEAT:
|
|
534
|
+
1. Apply code fix
|
|
535
|
+
2. start_playtest()
|
|
536
|
+
3. get_playtest_output() - scan for the specific error you are fixing
|
|
537
|
+
4. If error persists → stop_playtest(), refine fix, go to 1
|
|
538
|
+
5. If error gone → stop_playtest(), move on
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Checking for Specific Errors
|
|
542
|
+
|
|
543
|
+
After `get_playtest_output()`, filter for critical patterns:
|
|
544
|
+
|
|
545
|
+
- `"error"` or `"Error"` - runtime errors
|
|
546
|
+
- `"attempt to index nil"` - missing references
|
|
547
|
+
- `"Infinite yield possible"` - WaitForChild timeouts
|
|
548
|
+
- `"HTTP 429"` - DataStore throttling
|
|
549
|
+
- `"not a valid member"` - API misuse or renamed properties
|
|
550
|
+
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
## 8. Manual Testing Workflows
|
|
554
|
+
|
|
555
|
+
Automated tests do not cover everything. Use this checklist for manual playtesting:
|
|
556
|
+
|
|
557
|
+
### Pre-Release Checklist
|
|
558
|
+
|
|
559
|
+
- [ ] **Mobile playtest** - Touch controls work, UI fits small screens, no overlapping buttons
|
|
560
|
+
- [ ] **Multi-player test** (Studio local server, 2+ players) - Replication works, no desync, RemoteEvents fire correctly
|
|
561
|
+
- [ ] **Edge cases**:
|
|
562
|
+
- Disconnect mid-save (does data persist or rollback cleanly?)
|
|
563
|
+
- Rejoin immediately after leaving
|
|
564
|
+
- Rapid-fire actions (spam click buy button, spam attack)
|
|
565
|
+
- Inventory at max capacity
|
|
566
|
+
- [ ] **Monetization flow**:
|
|
567
|
+
- Game pass ownership detection works
|
|
568
|
+
- Developer product purchase prompt appears
|
|
569
|
+
- Receipt processing completes and grants items
|
|
570
|
+
- Duplicate receipt handling (idempotent processing)
|
|
571
|
+
- [ ] **First-time user experience** - New player gets default data, tutorial triggers, no errors in output
|
|
572
|
+
- [ ] **Performance** - MicroProfiler shows no frame spikes on spawn, no memory leaks during extended play
|
|
573
|
+
|
|
574
|
+
### Studio Test Server (Multi-Player)
|
|
575
|
+
|
|
576
|
+
1. File -> Test -> Start (set player count to 2+)
|
|
577
|
+
2. Each client window is a separate player instance
|
|
578
|
+
3. Server window shows server-side output
|
|
579
|
+
4. Verify: leaderboards update for both, chat works, interactions replicate
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## 9. Test Organization
|
|
584
|
+
|
|
585
|
+
### Project Structure
|
|
586
|
+
|
|
587
|
+
```
|
|
588
|
+
ServerScriptService/
|
|
589
|
+
Tests/
|
|
590
|
+
TestRunner (Script)
|
|
591
|
+
Specs/
|
|
592
|
+
CurrencyManager.spec
|
|
593
|
+
DamageCalc.spec
|
|
594
|
+
Mocks/
|
|
595
|
+
MockDataStoreService
|
|
596
|
+
MockPlayers
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Naming Conventions
|
|
600
|
+
|
|
601
|
+
| Convention | Example |
|
|
602
|
+
|---|---|
|
|
603
|
+
| Spec file suffix | `CurrencyManager.spec.luau` |
|
|
604
|
+
| Mock file prefix | `MockDataStoreService.luau` |
|
|
605
|
+
| Describe block | `describe("CurrencyManager")` |
|
|
606
|
+
| Test name | `it("should deduct gold on purchase")` |
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## 11. Best Practices
|
|
613
|
+
|
|
614
|
+
### Test Critical Paths First
|
|
615
|
+
|
|
616
|
+
Prioritize tests for code where bugs cost the most:
|
|
617
|
+
|
|
618
|
+
1. **Data save/load** - A bug here can wipe player progress. Test serialization, deserialization, migration, and edge cases (empty data, corrupt data).
|
|
619
|
+
2. **Purchases and monetization** - Receipt processing must be idempotent. Test duplicate receipts, test granting after purchase, test failure recovery.
|
|
620
|
+
3. **Combat damage / core gameplay math** - Players notice immediately when damage numbers are wrong. Test crit, armor, buffs, edge values.
|
|
621
|
+
4. **Server-side validation** - Every RemoteEvent handler that accepts client input must validate. Test with out-of-range values, wrong types, and nil.
|
|
622
|
+
|
|
623
|
+
### General Guidelines
|
|
624
|
+
|
|
625
|
+
- **Keep tests fast.** Each test should run in milliseconds. If a test needs `task.wait()`, you are likely testing integration-level behavior - separate it from unit tests.
|
|
626
|
+
- **One assertion focus per test.** A test named `"should add gold"` should test adding gold, not also test removing gold and checking the balance format.
|
|
627
|
+
- **Test server-side validation independently.** Do not rely on the client sending correct data. Write tests that call server validation functions with malicious inputs.
|
|
628
|
+
- **Write a test before fixing a bug.** Reproduce the bug in a failing test first, then fix the code until the test passes. This prevents regressions.
|
|
629
|
+
- **Use deterministic data.** Avoid `math.random()` in tests. Use fixed seed values or hardcoded inputs so failures are reproducible.
|
|
630
|
+
- **Reset state in `beforeEach`.** Never let one test depend on the side effects of another.
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## 12. Anti-Patterns
|
|
635
|
+
|
|
636
|
+
### Testing Only Manually in Studio
|
|
637
|
+
|
|
638
|
+
**Problem:** You click around in Studio, it seems to work, you ship. A week later an edge case surfaces in production.
|
|
639
|
+
|
|
640
|
+
**Fix:** Write automated tests for core logic. Manual playtesting supplements automated tests; it does not replace them.
|
|
641
|
+
|
|
642
|
+
### No Tests for Monetization Code
|
|
643
|
+
|
|
644
|
+
**Problem:** Receipt processing is written once, never tested, and breaks silently. Players pay real money and receive nothing.
|
|
645
|
+
|
|
646
|
+
**Fix:** Unit test `processReceipt` with mock MarketplaceService. Test duplicate receipts, test every product ID, test failure paths.
|
|
647
|
+
|
|
648
|
+
### Untestable Tightly-Coupled Modules
|
|
649
|
+
|
|
650
|
+
**Problem:**
|
|
651
|
+
|
|
652
|
+
```luau
|
|
653
|
+
-- Everything is hardcoded; impossible to test without a live game
|
|
654
|
+
function Module.onPlayerJoin()
|
|
655
|
+
local player = game.Players.LocalPlayer
|
|
656
|
+
local data = game:GetService("DataStoreService"):GetDataStore("Main"):GetAsync(player.UserId)
|
|
657
|
+
local gui = player.PlayerGui:WaitForChild("MainUI")
|
|
658
|
+
gui.GoldLabel.Text = tostring(data.gold)
|
|
659
|
+
end
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**Fix:** Break it apart. Pure logic in one module, engine glue in another. Inject services.
|
|
663
|
+
|
|
664
|
+
```luau
|
|
665
|
+
-- Pure logic (testable)
|
|
666
|
+
function CurrencyFormatter.formatGold(gold: number): string
|
|
667
|
+
return tostring(gold)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
-- Glue code (thin, not unit tested, covered by integration/manual tests)
|
|
671
|
+
function UIController.updateGoldDisplay(player, gold)
|
|
672
|
+
local gui = player.PlayerGui:WaitForChild("MainUI")
|
|
673
|
+
gui.GoldLabel.Text = CurrencyFormatter.formatGold(gold)
|
|
674
|
+
end
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
### Tests That Depend on Execution Order
|
|
678
|
+
|
|
679
|
+
**Problem:** Test B passes only if Test A runs first because A sets up shared state.
|
|
680
|
+
|
|
681
|
+
**Fix:** Use `beforeEach` to create fresh state for every test. Each test must be independently runnable.
|
|
682
|
+
|
|
683
|
+
### Ignoring Flaky Tests
|
|
684
|
+
|
|
685
|
+
**Problem:** A test sometimes passes and sometimes fails. The team marks it as "known flaky" and ignores it.
|
|
686
|
+
|
|
687
|
+
**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.
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
## Full Example: CurrencyManager with Tests
|
|
692
|
+
|
|
693
|
+
### Module (`CurrencyManager.luau`)
|
|
694
|
+
|
|
695
|
+
```luau
|
|
696
|
+
local CurrencyManager = {}
|
|
697
|
+
CurrencyManager.__index = CurrencyManager
|
|
698
|
+
|
|
699
|
+
export type CurrencyData = {
|
|
700
|
+
gold: number,
|
|
701
|
+
gems: number,
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function CurrencyManager.new(dataStoreService)
|
|
705
|
+
local self = setmetatable({}, CurrencyManager)
|
|
706
|
+
self._store = dataStoreService:GetDataStore("Currency")
|
|
707
|
+
self._cache = {} :: { [number]: CurrencyData }
|
|
708
|
+
return self
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
function CurrencyManager.newPlayerData(): CurrencyData
|
|
712
|
+
return {
|
|
713
|
+
gold = 0,
|
|
714
|
+
gems = 0,
|
|
715
|
+
}
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
function CurrencyManager:loadPlayer(playerId: number): CurrencyData
|
|
719
|
+
local raw = self._store:GetAsync(`currency_{playerId}`)
|
|
720
|
+
local data = raw or CurrencyManager.newPlayerData()
|
|
721
|
+
self._cache[playerId] = data
|
|
722
|
+
return data
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
function CurrencyManager:savePlayer(playerId: number)
|
|
726
|
+
local data = self._cache[playerId]
|
|
727
|
+
if data then
|
|
728
|
+
self._store:SetAsync(`currency_{playerId}`, data)
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
function CurrencyManager:getGold(playerId: number): number
|
|
733
|
+
local data = self._cache[playerId]
|
|
734
|
+
return if data then data.gold else 0
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
function CurrencyManager:addGold(playerId: number, amount: number): boolean
|
|
738
|
+
if amount <= 0 then
|
|
739
|
+
return false
|
|
740
|
+
end
|
|
741
|
+
local data = self._cache[playerId]
|
|
742
|
+
if not data then
|
|
743
|
+
return false
|
|
744
|
+
end
|
|
745
|
+
data.gold += amount
|
|
746
|
+
return true
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
function CurrencyManager:removeGold(playerId: number, amount: number): boolean
|
|
750
|
+
if amount <= 0 then
|
|
751
|
+
return false
|
|
752
|
+
end
|
|
753
|
+
local data = self._cache[playerId]
|
|
754
|
+
if not data then
|
|
755
|
+
return false
|
|
756
|
+
end
|
|
757
|
+
if data.gold < amount then
|
|
758
|
+
return false -- insufficient funds
|
|
759
|
+
end
|
|
760
|
+
data.gold -= amount
|
|
761
|
+
return true
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
function CurrencyManager:transferGold(fromId: number, toId: number, amount: number): boolean
|
|
765
|
+
if not self:removeGold(fromId, amount) then
|
|
766
|
+
return false
|
|
767
|
+
end
|
|
768
|
+
if not self:addGold(toId, amount) then
|
|
769
|
+
-- Rollback
|
|
770
|
+
self:addGold(fromId, amount)
|
|
771
|
+
return false
|
|
772
|
+
end
|
|
773
|
+
return true
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
return CurrencyManager
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
### Test (`CurrencyManager.spec.luau`)
|
|
780
|
+
|
|
781
|
+
```luau
|
|
782
|
+
return function()
|
|
783
|
+
local MockDataStoreService = require(script.Parent.Parent.Mocks.MockDataStoreService)
|
|
784
|
+
local CurrencyManager = require(script.Parent.CurrencyManager)
|
|
785
|
+
|
|
786
|
+
describe("CurrencyManager", function()
|
|
787
|
+
local mockDSS
|
|
788
|
+
local manager
|
|
789
|
+
|
|
790
|
+
beforeEach(function()
|
|
791
|
+
mockDSS = MockDataStoreService.new()
|
|
792
|
+
manager = CurrencyManager.new(mockDSS)
|
|
793
|
+
end)
|
|
794
|
+
|
|
795
|
+
describe("newPlayerData", function()
|
|
796
|
+
it("should return zero gold and zero gems", function()
|
|
797
|
+
local data = CurrencyManager.newPlayerData()
|
|
798
|
+
expect(data.gold).to.equal(0)
|
|
799
|
+
expect(data.gems).to.equal(0)
|
|
800
|
+
end)
|
|
801
|
+
end)
|
|
802
|
+
|
|
803
|
+
describe("loadPlayer", function()
|
|
804
|
+
it("should return default data for a new player", function()
|
|
805
|
+
local data = manager:loadPlayer(1001)
|
|
806
|
+
expect(data.gold).to.equal(0)
|
|
807
|
+
expect(data.gems).to.equal(0)
|
|
808
|
+
end)
|
|
809
|
+
|
|
810
|
+
it("should return saved data for an existing player", function()
|
|
811
|
+
-- Pre-populate the mock store
|
|
812
|
+
local store = mockDSS:GetDataStore("Currency")
|
|
813
|
+
store:SetAsync("currency_1001", { gold = 500, gems = 10 })
|
|
814
|
+
|
|
815
|
+
local data = manager:loadPlayer(1001)
|
|
816
|
+
expect(data.gold).to.equal(500)
|
|
817
|
+
expect(data.gems).to.equal(10)
|
|
818
|
+
end)
|
|
819
|
+
end)
|
|
820
|
+
|
|
821
|
+
describe("savePlayer", function()
|
|
822
|
+
it("should persist data to the store", function()
|
|
823
|
+
manager:loadPlayer(1001)
|
|
824
|
+
manager:addGold(1001, 250)
|
|
825
|
+
manager:savePlayer(1001)
|
|
826
|
+
|
|
827
|
+
-- Verify by reading directly from the mock store
|
|
828
|
+
local store = mockDSS:GetDataStore("Currency")
|
|
829
|
+
local raw = store:GetAsync("currency_1001")
|
|
830
|
+
expect(raw.gold).to.equal(250)
|
|
831
|
+
end)
|
|
832
|
+
|
|
833
|
+
it("should do nothing for an unloaded player", function()
|
|
834
|
+
-- Should not error
|
|
835
|
+
manager:savePlayer(9999)
|
|
836
|
+
end)
|
|
837
|
+
end)
|
|
838
|
+
|
|
839
|
+
describe("addGold", function()
|
|
840
|
+
it("should increase gold by the given amount", function()
|
|
841
|
+
manager:loadPlayer(1001)
|
|
842
|
+
local ok = manager:addGold(1001, 100)
|
|
843
|
+
expect(ok).to.equal(true)
|
|
844
|
+
expect(manager:getGold(1001)).to.equal(100)
|
|
845
|
+
end)
|
|
846
|
+
|
|
847
|
+
it("should accumulate across multiple calls", function()
|
|
848
|
+
manager:loadPlayer(1001)
|
|
849
|
+
manager:addGold(1001, 50)
|
|
850
|
+
manager:addGold(1001, 75)
|
|
851
|
+
expect(manager:getGold(1001)).to.equal(125)
|
|
852
|
+
end)
|
|
853
|
+
|
|
854
|
+
it("should reject zero amount", function()
|
|
855
|
+
manager:loadPlayer(1001)
|
|
856
|
+
local ok = manager:addGold(1001, 0)
|
|
857
|
+
expect(ok).to.equal(false)
|
|
858
|
+
expect(manager:getGold(1001)).to.equal(0)
|
|
859
|
+
end)
|
|
860
|
+
|
|
861
|
+
it("should reject negative amount", function()
|
|
862
|
+
manager:loadPlayer(1001)
|
|
863
|
+
local ok = manager:addGold(1001, -50)
|
|
864
|
+
expect(ok).to.equal(false)
|
|
865
|
+
end)
|
|
866
|
+
|
|
867
|
+
it("should fail for unloaded player", function()
|
|
868
|
+
local ok = manager:addGold(9999, 100)
|
|
869
|
+
expect(ok).to.equal(false)
|
|
870
|
+
end)
|
|
871
|
+
end)
|
|
872
|
+
|
|
873
|
+
describe("removeGold", function()
|
|
874
|
+
it("should decrease gold by the given amount", function()
|
|
875
|
+
manager:loadPlayer(1001)
|
|
876
|
+
manager:addGold(1001, 200)
|
|
877
|
+
local ok = manager:removeGold(1001, 50)
|
|
878
|
+
expect(ok).to.equal(true)
|
|
879
|
+
expect(manager:getGold(1001)).to.equal(150)
|
|
880
|
+
end)
|
|
881
|
+
|
|
882
|
+
it("should reject removal exceeding balance", function()
|
|
883
|
+
manager:loadPlayer(1001)
|
|
884
|
+
manager:addGold(1001, 30)
|
|
885
|
+
local ok = manager:removeGold(1001, 50)
|
|
886
|
+
expect(ok).to.equal(false)
|
|
887
|
+
expect(manager:getGold(1001)).to.equal(30) -- unchanged
|
|
888
|
+
end)
|
|
889
|
+
|
|
890
|
+
it("should reject zero amount", function()
|
|
891
|
+
manager:loadPlayer(1001)
|
|
892
|
+
local ok = manager:removeGold(1001, 0)
|
|
893
|
+
expect(ok).to.equal(false)
|
|
894
|
+
end)
|
|
895
|
+
|
|
896
|
+
it("should reject negative amount", function()
|
|
897
|
+
manager:loadPlayer(1001)
|
|
898
|
+
local ok = manager:removeGold(1001, -10)
|
|
899
|
+
expect(ok).to.equal(false)
|
|
900
|
+
end)
|
|
901
|
+
end)
|
|
902
|
+
|
|
903
|
+
describe("transferGold", function()
|
|
904
|
+
it("should move gold from one player to another", function()
|
|
905
|
+
manager:loadPlayer(1001)
|
|
906
|
+
manager:loadPlayer(1002)
|
|
907
|
+
manager:addGold(1001, 500)
|
|
908
|
+
|
|
909
|
+
local ok = manager:transferGold(1001, 1002, 200)
|
|
910
|
+
expect(ok).to.equal(true)
|
|
911
|
+
expect(manager:getGold(1001)).to.equal(300)
|
|
912
|
+
expect(manager:getGold(1002)).to.equal(200)
|
|
913
|
+
end)
|
|
914
|
+
|
|
915
|
+
it("should fail if sender has insufficient funds", function()
|
|
916
|
+
manager:loadPlayer(1001)
|
|
917
|
+
manager:loadPlayer(1002)
|
|
918
|
+
manager:addGold(1001, 50)
|
|
919
|
+
|
|
920
|
+
local ok = manager:transferGold(1001, 1002, 100)
|
|
921
|
+
expect(ok).to.equal(false)
|
|
922
|
+
expect(manager:getGold(1001)).to.equal(50) -- unchanged
|
|
923
|
+
expect(manager:getGold(1002)).to.equal(0) -- unchanged
|
|
924
|
+
end)
|
|
925
|
+
|
|
926
|
+
it("should fail if recipient is not loaded", function()
|
|
927
|
+
manager:loadPlayer(1001)
|
|
928
|
+
manager:addGold(1001, 500)
|
|
929
|
+
|
|
930
|
+
local ok = manager:transferGold(1001, 9999, 100)
|
|
931
|
+
expect(ok).to.equal(false)
|
|
932
|
+
expect(manager:getGold(1001)).to.equal(500) -- rollback
|
|
933
|
+
end)
|
|
934
|
+
end)
|
|
935
|
+
|
|
936
|
+
describe("getGold", function()
|
|
937
|
+
it("should return 0 for unloaded player", function()
|
|
938
|
+
expect(manager:getGold(9999)).to.equal(0)
|
|
939
|
+
end)
|
|
940
|
+
end)
|
|
941
|
+
end)
|
|
942
|
+
end
|
|
943
|
+
```
|