
Roblox Testing
- 52 installs
- 11 repo stars
- Updated August 3, 2026
- nonlooped/roblox-suite
Test, debug, and profile Roblox experiences with Developer Console, TestEZ unit tests, the MicroProfiler, Script Profiler, and memory and network diagnostics.
About
Covers Roblox testing, debugging, and profiling including the Developer Console, logging discipline, pcall patterns, TestEZ unit tests, the MicroProfiler, Scene Analysis, memory diagnostics, and network debugging. A developer uses it when something is broken, slow, or unreliable, or when setting up test workflows.
- TestEZ unit tests plus pcall and assertion patterns
- MicroProfiler, Script Profiler, and memory/network diagnostics
Roblox Testing by the numbers
- 52 all-time installs (skills.sh)
- +13 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,215 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nonlooped/roblox-suite --skill roblox-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| repo stars | ★ 11 |
| Last updated | August 3, 2026 |
| Repository | nonlooped/roblox-suite ↗ |
What it does
Test, debug, and profile Roblox experiences with Developer Console, TestEZ unit tests, the MicroProfiler, Script Profiler, and memory and network diagnostics.
Files
roblox-testing
Official sources (always check these for the latest):
- https://create.roblox.com/docs/en-us/studio/developer-console
- https://create.roblox.com/docs/en-us/performance-optimization/identify
- https://create.roblox.com/docs/en-us/performance-optimization/microprofiler
- https://create.roblox.com/docs/en-us/performance-optimization/scene-analysis
- https://create.roblox.com/docs/en-us/studio/optimization/memory-usage
- https://create.roblox.com/docs/en-us/studio/optimization/scriptprofiler
This skill is about finding and fixing problems, not just writing code. It focuses on the tools and habits that separate working experiences from broken ones.
When to use this skill
Activate when:
- Something is not behaving as expected (scripts, UI, physics, data, networking).
- Frame rate, memory, or server heartbeat is degrading.
- Setting up unit tests or reproducible test cases.
- Trying to isolate whether a bug is on the client or server.
- Debugging a live issue using the Developer Console.
Cross-reference:
- roblox-core/SKILL.md for services and script contexts.
- roblox-networking/SKILL.md for debugging remote flows and network ownership.
- roblox-datastores/SKILL.md for DataStore retry patterns and debugging data store errors.
- roblox-physics/SKILL.md for debugging physics ownership and sleep.
- roblox-npcs/SKILL.md for debugging NPC behavior.
The debugging mindset
1. Reproduce it. If you can't reproduce it, you can't fix it. 2. Isolate it. Remove systems until the bug disappears; the last thing removed is the cause. 3. Measure it. Use tools instead of guessing. 4. Fix one thing at a time. Verify the fix and add a regression test if possible. 5. Log defensively. Good logs make future debugging faster.
Logging discipline
Use print, warn, and error deliberately:
printfor normal diagnostics.warnfor recoverable problems you should notice.errorfor programming errors that should stop execution.
Include context in log messages:
warn(string.format("[DataStore] Save failed for %d: %s", userId, tostring(err)))Avoid logging secrets, player data, or PII.
pcall and assertions
Wrap fallible calls, especially cloud services, HTTP, DataStores, Marketplace:
local ok, result = pcall(function()
return someService:DoSomething()
end)
if not ok then
warn("DoSomething failed:", result)
endUse assert for internal invariants that should never fail:
assert(config.MaxSpeed > 0, "MaxSpeed must be positive")Developer Console
Open with F9 in-game or in Studio play mode.
Tabs:
- Log — client/server output, errors, warnings.
- Memory — categorized memory usage.
- Network — HTTP and service requests.
- Server Stats — heartbeat, ping, data ping.
- Script Profiler — record script CPU usage.
- MicroProfiler — capture server dumps.
Toggle Client/Server views to see which side emitted output.
Unit testing with TestEZ
The standard Roblox testing framework is TestEZ. It supports nested describe/it blocks, lifecycle hooks, async tests, and a rich matcher API.
TestEZ example:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
local MathUtils = require(ReplicatedStorage.MathUtils)
describe("MathUtils", function()
it("clamps values", function()
expect(MathUtils.clamp(5, 0, 10)).to.equal(5)
expect(MathUtils.clamp(-1, 0, 10)).to.equal(0)
expect(MathUtils.clamp(11, 0, 10)).to.equal(10)
end)
end)
TestEZ.TestBootstrap:run({ game.ReplicatedStorage.Tests })For legacy in-Studio tests, TestService:Error(msg) can mark failures visibly in Studio output, but most projects should prefer TestEZ. The scripts folder contains a minimal TestRunner.lua shim only for environments where TestEZ is unavailable.
Luau type checking
Use the Luau language server (luau-lsp) or the Luau type checker in Studio to catch errors before runtime.
Best practices:
- Add type annotations to module exports and pure functions.
- Run
luau-lsp analyzein CI or before committing. - Enable
--!strictfor new modules. - Do not over-type untyped engine APIs; prefer casts at boundaries.
Studio Debugger
Use Studio's built-in debugger for step-through debugging:
- Set breakpoints by clicking the gutter next to a line number.
- Run in Play mode and use the debugger controls to step over, into, or out.
- Inspect the call stack, local variables, and upvalues in the debugger panel.
- The debugger works in both client and server contexts when Studio runs both.
xpcall and debug.traceback
Use xpcall with debug.traceback to capture full stack traces from failures:
local ok, err = xpcall(function()
riskyOperation()
end, debug.traceback)
if not ok then
warn(err)
endThis is especially useful at top-level entry points, scheduled callbacks, and connection handlers where pcall alone discards the stack.
Connection cleanup
Leaked RBXScriptConnection objects are a common source of memory leaks and stale state. Prefer explicit cleanup patterns:
- Use a Maid/Janitor-style collector:
local Maid = require(path.to.Maid)
local maid = Maid.new()
maid:GiveTask(workspace.ChildAdded:Connect(onChild))
maid:GiveTask(RunService.Heartbeat:Connect(onStep))
maid:Destroy() -- disconnects everything- Use
:Once()for one-shot event handlers. - Use
Instance.Destroyingto trigger cleanup when an instance is removed:
obj.Destroying:Connect(function()
cleanup()
end)- Always pair
:Connect()with a matching:Disconnect()or destruction.
Prefer task over spawn/delay
The task library is more predictable than legacy spawn, delay, and wait:
-- good
task.wait(1)
task.delay(1, callback)
task.spawn(coroutineOrFn)
-- avoid
spawn(callback)
delay(1, callback)
wait(1)task.defer is useful for yielding to the next resumption cycle without opening a new thread.
Log rate limiting and secrets hygiene
- Never log secrets, keys, user identifiers, or personal information.
- Sanitize values before logging:
local function sanitize(value)
if typeof(value) == "Instance" and value:IsA("Player") then
return "Player(" .. tostring(value.UserId) .. ")"
end
return value
end- Rate-limit noisy logs to avoid flooding the console and network:
local lastLog = 0
local function throttledLog(message)
local now = os.clock()
if now - lastLog > 5 then
lastLog = now
warn(message)
end
endNetwork ownership
Incorrect network ownership causes jittery physics and replication lag. Visualize ownership and assign it explicitly:
-- Server
part:SetNetworkOwner(player)
-- Client-side visualization (debug only)
local ownershipLabel -- create BillboardGui or use DebugDrawUse BasePart:GetNetworkOwner() to inspect ownership. Vehicles and held items should usually be owned by the controlling player.
DataStore retry pattern
Wrap DataStore calls with exponential backoff and jitter:
local function dataStoreWithRetry(fn, maxAttempts)
maxAttempts = maxAttempts or 5
for attempt = 1, maxAttempts do
local ok, result = pcall(fn)
if ok then
return true, result
elseif attempt == maxAttempts then
return false, result
else
warn("DataStore attempt " .. attempt .. " failed; retrying...")
task.wait(2 ^ attempt * 0.1 + math.random() * 0.5)
end
end
endAlways call DataStores from the server and validate serialization before saving.
Memory snapshot comparison
Use the Developer Console Memory tab or Scene Analysis to compare snapshots:
1. Capture a baseline snapshot in a stable state. 2. Play through the action that may leak (spawn/despawn enemies, open/close UI). 3. Return to the stable state and capture a second snapshot. 4. Compare PlaceMemory, Luau heap, and instance counts. 5. Investigate categories that did not return to baseline; look for unparented instances in Scene Analysis.
MicroProfiler
Open with Ctrl+F6 (⌘+F6) in Studio or the desktop client.
Use it to:
- Find frame-time spikes.
- Identify whether a bottleneck is script compute, physics, or rendering.
- Capture server dumps from the Developer Console.
- Add custom labels with
debug.profilebegin/debug.profileend.
Key colors:
- Orange — worker thread (scripts, physics, animations) bottleneck.
- Blue — render thread bottleneck.
- Red — GPU wait / render complexity.
Scene Analysis
Available in Studio under Window → Performance Summary → Scene Analysis.
Views:
- Script memory — per-script Luau heap.
- Unparented instances — potential memory leaks held by scripts.
- Instance composition — counts by category.
- Audio/Animation memory — asset memory usage.
- Triangle composition — draw call breakdown.
Scene Analysis is a Studio UI tool; there is no public SceneAnalysisService API.
Script Profiler
Records CPU time per script. Use it when MicroProfiler points to scripts but you need to know which script.
Common bug categories
| Symptom | Likely causes |
|---|---|
| Script silently fails | Missing pcall, error swallowed, wrong script context |
| Data not saving | DataStore called from client, non-serializable value, no pcall |
| Remote not working | Wrong side, handler not connected, argument mismatch |
| Lag spikes | Pathfinding every frame, too many particles, unbatched loops |
| Physics jitter | Wrong network ownership, assembly splits, conflicting constraints |
| NPCs stuck | Blocked path not recomputed, bad agent params, streaming issues |
| UI doesn't update | Property not replicated, wrong parent, layout order |
| Memory grows forever | Leaked connections, unparented instances, cached assets |
Network debugging
- Check Network tab in Developer Console for HTTP/DataStore failures.
- Use
Shift+F3in-game for network debug stats. - Distinguish network ping (round-trip time) from data ping (replication queue).
- Simulate latency/jitter with Studio Network Simulation (
Alt+S).
Load time debugging
Measure load time:
local start = os.clock()
game.Loaded:Connect(function()
print("Loaded in", os.clock() - start)
end)Enable Print Join Size Breakdown in Studio Settings → Network to see the largest replicated instances.
Scripts
scripts/TestRunner.lua— a minimal TestEZ fallback with nested suites, lifecycle hooks, matchers, async support, and TestService integration.scripts/Logger.lua— a structured logger with level filtering and guarded formatting.scripts/DebugDraw.lua— utility for drawing rays, points, and boxes in 3D for visual debugging.
How to proceed
1. Reproduce the issue reliably. 2. Check logs and errors in the Developer Console. 3. Determine client vs server scope. 4. Use MicroProfiler/Script Profiler for performance issues. 5. Use Scene Analysis for memory leaks and scene composition. 6. Add targeted logging or tests to confirm the fix. 7. Verify on low-end devices and with network simulation when relevant. 8. Compare memory snapshots before and after suspected leaks.
Common Bugs and Fixes
Script silently fails
Causes:
- Error before any output; check Developer Console for errors.
- Script in wrong context (server vs client).
- Event handler disconnected or never connected.
- Infinite yield from
WaitForChildwith no timeout.
Fixes:
- Use
instance:WaitForChild("Name", timeout). - Add
printat key points to trace execution. - Check script
RunContextand parent location. - Use
xpcallwithdebug.tracebackat entry points to capture full stack traces.
Data not saving
Causes:
- DataStore call from client/LocalScript.
- Non-serializable value (function, metatable, cycle, inf/nan).
- Missing
pcallaround call. - Studio API access disabled on test place or enabled on live place.
Fixes:
- Always call DataStores from server
Script. - Test serialization with
HttpService:JSONEncode. - Wrap every call in
pcall. - Keep API services off in production.
Remote events not working
Causes:
- Handler on wrong side.
- Firing before handler is connected.
- Argument types differ from handler signature.
- Remote instance not replicated yet.
Fixes:
- Define Remotes in
ReplicatedStorage. - Connect handlers early.
- Validate argument count and types.
Lag spikes
Causes:
- Pathfinding every frame.
- Updating UI every frame without throttling.
- Unbatched DataStore operations.
- Spawning many effects/instances at once.
Fixes:
- Throttle expensive work.
- Batch changes.
- Use object pools.
- Profile with MicroProfiler.
Physics jitter
Causes:
- Conflicting constraints.
- Wrong network ownership.
- Assembly splits from anchoring.
- Parts fighting each other with forces.
Fixes:
- Visualize constraints and ownership.
- Assign vehicle ownership to driver.
- Anchor only root parts of assemblies.
- Tune force limits.
Memory leaks
Causes:
Heartbeat/Steppedconnections not disconnected.- Tables caching instances after removal.
- Animation/Sound objects not cleaned up.
- Circular references with metatables.
Fixes:
- Disconnect connections in cleanup functions.
- Use
Instance.DestroyingorAncestryChangedto trigger cleanup. - Check Scene Analysis → Unparented instances.
- Compare Luau heap snapshots.
UI not updating
Causes:
- Property change not replicating.
- Layout not recalculating.
- Wrong parent or ZIndex.
- LocalScript not running in expected context.
Fixes:
- Set properties from the correct side.
- Force layout updates with size/position changes.
- Check StarterGui → PlayerGui cloning.
Pathfinding failures
Causes:
- Agent parameters incompatible with destination.
- Destination beyond 3,000 studs or vertical limits.
- Dynamic obstacle blocking path.
- Node budget exhausted in complex world.
Fixes:
- Validate parameters.
- Recompute on
path.Blocked. - Break long paths into segments.
- Simplify complex geometry.
Leaked event connections
Causes:
Heartbeat/Steppedconnections never disconnected.- One-shot connections left connected after firing.
- Cleanup code unreachable on error paths.
Fixes:
- Use a Maid/Janitor-style collector.
- Use
:Once()for one-shot handlers. - Disconnect in
Instance.DestroyingorAncestryChangedhandlers.
spawn/delay misuse
Causes:
- Legacy
spawn/delay/waitresume unpredictably and can defer indefinitely.
Fixes:
- Replace
spawn(fn)withtask.defer(fn)ortask.spawn(fn). - Replace
delay(t, fn)withtask.delay(t, fn). - Replace
wait(t)withtask.wait(t).
Log spam and leaked secrets
Causes:
- Printing every frame or every event.
- Logging raw player data, identifiers, or API keys.
Fixes:
- Rate-limit logs with a time gate or counter.
- Sanitize values before logging.
- Never commit secrets; load them from secure configuration.
DataStore throttling and failures
Causes:
- Missing retry on transient errors.
- Calling DataStores from the client.
- Saving non-serializable values.
Fixes:
- Wrap calls with exponential backoff and jitter.
- Validate JSON serialization before saving.
- Always call DataStores from a server
Script.
Network ownership issues
Causes:
- Server simulating objects that a player should control.
- Frequent ownership changes.
Fixes:
- Use
BasePart:SetNetworkOwner(player)for vehicles and held items. - Visualize ownership in debug builds with
GetNetworkOwner(). - Minimize ownership churn.
Memory snapshot comparison
For a repeatable leak-finding workflow, compare snapshots:
1. Capture a baseline in a stable state. 2. Perform the suspected leak action repeatedly. 3. Return to the stable state and capture again. 4. Compare PlaceMemory, Luau heap, and instance counts. 5. Check Scene Analysis → Unparented instances.
Debugging Tools
Official guides:
- https://create.roblox.com/docs/en-us/studio/developer-console
- https://create.roblox.com/docs/en-us/performance-optimization/microprofiler
- https://create.roblox.com/docs/en-us/performance-optimization/scene-analysis
Developer Console
Shortcuts:
F9— open console./console— in chat.
Log tab
Filter by Output, Information, Warning, Error. Toggle Client/Server to see which side produced output.
Memory tab
- PlaceMemory — breakdown by assets and engine systems.
- Luau heap — detailed script allocation snapshots.
Network tab
- Summary of HTTP and service requests.
- Per-request status, time, URL, response.
Server Stats tab
- Heartbeat steps per second.
- Average ping/data ping.
Output window
Studio-only output. Shows print/warn/error from edit-mode scripts.
MicroProfiler
Shortcuts:
Ctrl+F6(⌘+F6) — open.Ctrl+P(⌘+P) — pause.
Workflow: 1. Identify a frame-time spike. 2. Zoom into the timeline. 3. Find the widest task bar. 4. Cross-reference with the tag table. 5. Add debug.profilebegin/debug.profileend around suspicious script code.
Custom profiling:
debug.profilebegin("HeavyLoop")
-- expensive work
debug.profileend()Scene Analysis
Available in Studio under Window → Performance Summary → Scene Analysis.
Useful views:
- Script memory — which scripts allocate the most.
- Unparented instances — references held by scripts after instances are removed.
- Instance composition — total instance counts.
- Audio/Animation memory — asset retention.
- Triangle composition — rendering cost breakdown.
Scene Analysis is a Studio UI tool only; there is no public runtime API for it.
Script Profiler
Records per-script CPU cost. Good for finding expensive scripts without manual labeling.
Network simulation
Studio: Alt+S or Test → Network Simulation.
Simulate latency, jitter, and packet loss to reproduce multiplayer issues locally.
Testing modes
- Play — local client+server.
- Play Here — spawn at camera.
- Multi-client simulation — test ownership and replication with multiple local players.
Studio Debugger
Use Studio's built-in debugger for step-through debugging:
- Set breakpoints by clicking the gutter next to a line number.
- Run in Play mode and use the debugger controls to step over, into, or out.
- Inspect the call stack, local variables, and upvalues in the debugger panel.
- The debugger works in both client and server contexts when Studio runs both.
Luau type checking
Use the Luau language server (luau-lsp) or Studio's Luau type checker to catch errors before runtime.
- Add type annotations to module exports and pure functions.
- Enable
--!strictfor new modules. - Run
luau-lsp analyzein CI when available.
xpcall and debug.traceback
Use xpcall with debug.traceback to capture full stack traces from failures:
local ok, err = xpcall(riskyOperation, debug.traceback)
if not ok then
warn(err)
endPrefer xpcall at top-level entry points, scheduled callbacks, and connection handlers.
Visual debugging
Use DebugDraw (see scripts) or temporary Part instances to visualize:
- Raycast hits
- Hitboxes
- Path waypoints
- Sensor ranges
- Network ownership boundaries
Always clean up debug visuals in production.
Performance Profiling
Official guide: https://create.roblox.com/docs/en-us/performance-optimization/identify
Frame time goals
| FPS | Frame time |
|---|---|
| 30 | 33.33 ms |
| 60 | 16.67 ms |
| 120 | 8.33 ms |
| 240 | 4.17 ms |
Consistent frame times matter more than average FPS.
Server heartbeat
Capped at 60 FPS. Check in Developer Console → Server Stats → Heartbeat → Steps Per Sec. If below 60, use MicroProfiler on the server to find the bottleneck.
Client frame rate
Use Shift+F5 in-game for debug stats, or test on a real mobile device. High-end PCs can hide performance problems due to thermal/power headroom.
MicroProfiler colors
- Orange — worker thread bottleneck (scripts, physics, pathfinding, animations).
- Blue — render thread bottleneck (geometry, effects, UI).
- Red — GPU wait (complex geometry, large textures, overdrawing).
Server profiling
1. Join a live or test game with edit permissions. 2. Open Developer Console (F9). 3. Switch to MicroProfiler tab. 4. Choose Server, set frames and delay. 5. Click Begin server recording. 6. Open the saved HTML dump.
Common bottlenecks
| Area | Typical causes |
|---|---|
| Scripts | Loops every frame, unbatched datastore calls, expensive string/table ops |
| Physics | Too many unanchored parts, conflicting constraints, no sleep |
| Rendering | Too many transparent parts, particles, lights, high-poly meshes |
| Memory | Unloaded assets, leaked instances, growing tables |
| Network | Excessive remote traffic, large replication payloads |
Optimization workflow
1. Measure with MicroProfiler/Scene Analysis. 2. Identify the biggest single cost. 3. Change one thing. 4. Re-measure. 5. Repeat.
Mobile profiling
1. Open MicroProfiler on mobile from Settings. 2. Note the IP:port displayed. 3. On a dev machine on the same network, open that IP:port in a browser. 4. Capture frames and analyze in the web UI.
Counters mode and flame graphs
The web MicroProfiler supports:
- Flame graphs — aggregated call stacks.
- Diff flame graphs — compare two dumps.
- X-Ray memory — highlight allocation-heavy frames.
Use these for regressions and long-term tracking.
Memory snapshot comparison workflow
Use the Developer Console Memory tab and Scene Analysis to find leaks:
1. Enter a stable baseline state (e.g., standing in lobby with no UI open). 2. Capture snapshot A from Developer Console → Memory or Scene Analysis. 3. Perform the suspected leak action (open/close UI, spawn/despawn enemies). 4. Return to the baseline state. 5. Capture snapshot B. 6. Compare:
- Total PlaceMemory
- Luau heap by script
- Instance counts by category
- Unparented instances in Scene Analysis
7. Investigate categories that did not return to baseline.
Testing Patterns
Official guide: https://roblox.github.io/testez/
TestEZ
The standard Roblox testing framework is TestEZ. It supports nested describe/it blocks, lifecycle hooks, async tests, and a rich matcher API.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
TestEZ.TestBootstrap:run({ game.ReplicatedStorage.Tests })When to write tests
- Pure utility functions (math, formatting, validation).
- Data transformation and serialization logic.
- State machines with deterministic transitions.
- Anything that has broken before and must not break again.
Test structure with TestEZ
describe("MathUtils", function()
local clamp
beforeEach(function()
clamp = require(ReplicatedStorage.MathUtils).clamp
end)
it("clamps values", function()
expect(clamp(5, 0, 10)).to.equal(5)
expect(clamp(-1, 0, 10)).to.equal(0)
expect(clamp(11, 0, 10)).to.equal(10)
end)
end)TestEZ matchers include to.equal, to.be.near, to.be.a, to.throw, to.be.ok, to.be.truthy, and more.
Example async test
it("loads data asynchronously", function()
return expect(Promise.resolve(42)).to.equal(42)
end)For tests that return a Promise, TestEZ waits for resolution. See TestEZ documentation for the exact async API.
Integration vs unit tests
- Unit tests run fast and don't need a running game.
- Integration tests require services, instances, or network state. Run them in Studio play mode.
TestService (legacy)
TestService:Error(msg) marks a test failure visibly in Studio output. It is useful for simple in-Studio checks, but new projects should prefer TestEZ.
local TestService = game:GetService("TestService")
local ok, err = pcall(someTest)
if not ok then
TestService:Error(err)
endRunning tests
- Place TestEZ test scripts under
ReplicatedStorage.Testsor another discoverable folder. - Use
TestEZ.TestBootstrap:run({ folder })from a single entry point. - For CI-style testing, collect all test modules and run them with a single runner.
- The scripts/TestRunner.lua module is a minimal shim for environments where TestEZ is unavailable.
Good test habits
- One assertion per behavior.
- Test edge cases (empty tables, nil, huge values).
- Keep tests deterministic; avoid randomness.
- Clean up any instances created during tests.
- Use
beforeEach/afterEachfor shared setup and teardown. - Mock engine services for unit tests instead of relying on the live data model.
--!strict
--[[
DebugDraw.lua
Utility for drawing temporary debug visuals in the 3D world.
Usage:
local DebugDraw = require(path.to.DebugDraw)
DebugDraw.point(workspace, Vector3.new(0, 10, 0), Color3.fromRGB(255, 0, 0), 2)
DebugDraw.ray(workspace, origin, direction, Color3.fromRGB(0, 255, 0), 2)
DebugDraw.box(workspace, cframe, size, Color3.fromRGB(0, 0, 255), 2)
DebugDraw.clear() -- removes all tracked debug parts
]]
local DebugDraw = {}
local registry = {}
local function validateParent(parent)
assert(typeof(parent) == "Instance", "parent must be an Instance")
end
local function createBase()
local part = Instance.new("Part")
part.Anchored = true
part.CanCollide = false
part.CanQuery = false
part.CanTouch = false
part.Material = Enum.Material.SmoothPlastic
return part
end
local function track(part, lifetime)
table.insert(registry, part)
if lifetime then
task.delay(lifetime, function()
if part.Parent ~= nil then
part:Destroy()
end
for i = #registry, 1, -1 do
if registry[i] == part then
table.remove(registry, i)
break
end
end
end)
end
end
function DebugDraw.point(parent, position, color, lifetime)
validateParent(parent)
local part = createBase()
part.Shape = Enum.PartType.Ball
part.Size = Vector3.new(0.5, 0.5, 0.5)
part.CFrame = CFrame.new(position)
part.Color = color or Color3.fromRGB(255, 0, 0)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.ray(parent, origin, direction, color, lifetime)
validateParent(parent)
local distance = direction.Magnitude
assert(distance > 0, "direction must be non-zero")
local part = createBase()
part.Size = Vector3.new(0.1, 0.1, distance)
part.CFrame = CFrame.lookAt(origin, origin + direction) * CFrame.new(0, 0, -distance / 2)
part.Color = color or Color3.fromRGB(0, 255, 0)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.box(parent, cframe, size, color, lifetime)
validateParent(parent)
local part = createBase()
part.Size = size
part.CFrame = cframe
part.Transparency = 0.7
part.Color = color or Color3.fromRGB(0, 0, 255)
part.Parent = parent
track(part, lifetime)
return part
end
function DebugDraw.clear()
for i = #registry, 1, -1 do
local part = registry[i]
if part and part.Parent ~= nil then
part:Destroy()
end
registry[i] = nil
end
end
return DebugDraw
--!strict
--[[
Logger.lua
A simple structured logger with level filtering.
Usage:
local Logger = require(path.to.Logger)
local log = Logger.new("InventorySystem", Logger.Levels.Warn)
log:info("Loaded")
log:warn("Missing item %d", 123)
log:error("Failed to save: %s", err)
Notes:
- log:error halts execution by calling Lua's error() function.
- Contextual fields are not supported by this minimal logger.
Include any context directly in the message or use a structured
logging library for field-based output.
]]
local Logger = {}
Logger.Levels = {
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
}
Logger.__index = Logger
function Logger.new(name, minLevel)
local self = setmetatable({}, Logger)
self.name = name or "Logger"
self.minLevel = minLevel or Logger.Levels.Info
return self
end
function Logger:_log(levelName, levelValue, fmt, ...)
if levelValue < self.minLevel then return end
local ok, msg = pcall(string.format, fmt, ...)
if not ok then
msg = "[bad format string: " .. tostring(fmt) .. "]"
end
local output = string.format("[%s][%s] %s", self.name, levelName, msg)
if levelValue >= Logger.Levels.Error then
error(output, 3)
elseif levelValue >= Logger.Levels.Warn then
warn(output)
else
print(output)
end
end
function Logger:debug(fmt, ...)
self:_log("DEBUG", Logger.Levels.Debug, fmt, ...)
end
function Logger:info(fmt, ...)
self:_log("INFO", Logger.Levels.Info, fmt, ...)
end
function Logger:warn(fmt, ...)
self:_log("WARN", Logger.Levels.Warn, fmt, ...)
end
function Logger:error(fmt, ...)
self:_log("ERROR", Logger.Levels.Error, fmt, ...)
end
return Logger
--!strict
--[[
TestRunner.lua
A minimal in-Studio test runner shim.
This is NOT a replacement for TestEZ, which is the standard Roblox testing
framework. Use TestEZ for real projects. This module is a small fallback
for environments where TestEZ is not available.
Usage:
local TestRunner = require(path.to.TestRunner)
local runner = TestRunner.new()
runner:describe("Math", function()
runner:it("adds numbers", function()
runner:expect(1 + 1).toBe(2)
end)
end)
runner:run()
]]
local TestService = game:GetService("TestService")
local TestRunner = {}
TestRunner.__index = TestRunner
function TestRunner.new(options)
options = options or {}
local self = setmetatable({}, TestRunner)
self.suites = {}
self.stack = {}
self.hooks = { beforeEach = {}, afterEach = {} }
self.useTestService = options.useTestService ~= false
self.timeout = options.timeout or 5
return self
end
function TestRunner:_currentSuite()
return self.stack[#self.stack]
end
function TestRunner:describe(name, fn)
local parent = self:_currentSuite()
local suite = {
name = name,
tests = {},
suites = {},
parent = parent,
beforeEach = {},
afterEach = {},
}
if parent then
table.insert(parent.suites, suite)
else
table.insert(self.suites, suite)
end
table.insert(self.stack, suite)
fn()
table.remove(self.stack)
end
function TestRunner:beforeEach(fn)
local suite = self:_currentSuite()
assert(suite, "beforeEach() must be called inside describe()")
table.insert(suite.beforeEach, fn)
end
function TestRunner:afterEach(fn)
local suite = self:_currentSuite()
assert(suite, "afterEach() must be called inside describe()")
table.insert(suite.afterEach, fn)
end
function TestRunner:it(name, fn)
local suite = self:_currentSuite()
assert(suite, "it() must be called inside describe()")
table.insert(suite.tests, { name = name, fn = fn })
end
local function deepEqual(a, b)
if a == b then
return true
end
if typeof(a) ~= "table" or typeof(b) ~= "table" then
return false
end
for k, v in pairs(a) do
if not deepEqual(v, b[k]) then
return false, k
end
end
for k, _ in pairs(b) do
if a[k] == nil then
return false, k
end
end
return true
end
local function isPromiseLike(obj)
return typeof(obj) == "table" and typeof(obj.andThen) == "function"
end
function TestRunner:expect(value)
local matchers = {}
function matchers.toBe(expected)
assert(value == expected, string.format("expected %s, got %s", tostring(expected), tostring(value)))
end
function matchers.toEqual(expected)
local ok, key = deepEqual(value, expected)
if not ok then
if key ~= nil then
assert(false, string.format("deep equality mismatch at key %s", tostring(key)))
else
assert(false, string.format("expected %s, got %s", tostring(expected), tostring(value)))
end
end
end
function matchers.toBeTruthy()
assert(value, "expected truthy value")
end
function matchers.toBeNil()
assert(value == nil, "expected nil")
end
function matchers.toBeType(typeName)
assert(typeof(value) == typeName, string.format("expected type %s, got %s", typeName, typeof(value)))
end
function matchers.toBeGreaterThan(threshold)
assert(typeof(value) == "number", "value must be a number")
assert(value > threshold, string.format("expected value > %s, got %s", tostring(threshold), tostring(value)))
end
function matchers.toBeGreaterThanOrEqual(threshold)
assert(typeof(value) == "number", "value must be a number")
assert(value >= threshold, string.format("expected value >= %s, got %s", tostring(threshold), tostring(value)))
end
function matchers.toBeLessThan(threshold)
assert(typeof(value) == "number", "value must be a number")
assert(value < threshold, string.format("expected value < %s, got %s", tostring(threshold), tostring(value)))
end
function matchers.toBeLessThanOrEqual(threshold)
assert(typeof(value) == "number", "value must be a number")
assert(value <= threshold, string.format("expected value <= %s, got %s", tostring(threshold), tostring(value)))
end
function matchers.toBeCloseTo(expected, tolerance)
tolerance = tolerance or 0.00001
assert(typeof(value) == "number" and typeof(expected) == "number", "values must be numbers")
assert(math.abs(value - expected) <= tolerance, string.format("expected %s to be close to %s (tolerance %s)", tostring(value), tostring(expected), tostring(tolerance)))
end
function matchers.toThrow(expectedPattern)
local ok, err = pcall(value)
assert(not ok, "expected function to throw")
if expectedPattern then
local errStr = tostring(err)
assert(string.find(errStr, expectedPattern, 1, true), string.format("expected error to contain %q, got %q", expectedPattern, errStr))
end
end
return matchers
end
function TestRunner:_runHooks(hooksList)
for _, fn in ipairs(hooksList) do
fn()
end
end
function TestRunner:_collectHooks(suite, kind)
local collected = {}
local current = suite
while current do
for _, fn in ipairs(current[kind]) do
table.insert(collected, fn)
end
current = current.parent
end
if kind == "beforeEach" then
local reversed = {}
for i = #collected, 1, -1 do
table.insert(reversed, collected[i])
end
return reversed
end
return collected
end
function TestRunner:_runTest(test, suite)
local beforeHooks = self:_collectHooks(suite, "beforeEach")
local afterHooks = self:_collectHooks(suite, "afterEach")
local ok, err = pcall(function()
self:_runHooks(beforeHooks)
end)
if not ok then
return false, "beforeEach failed: " .. tostring(err)
end
ok, err = pcall(test.fn)
if ok then
if isPromiseLike(err) then
local resolved = false
local promiseErr
err:andThen(function()
resolved = true
end, function(e)
resolved = true
promiseErr = e
end)
local start = os.clock()
while not resolved and os.clock() - start < self.timeout do
task.wait(0.05)
end
if not resolved then
ok = false
err = "async test timed out"
elseif promiseErr ~= nil then
ok = false
err = promiseErr
end
end
end
local afterOk, afterErr = pcall(function()
self:_runHooks(afterHooks)
end)
if not afterOk then
local prefix = err and (tostring(err) .. "; ") or ""
ok = false
err = prefix .. "afterEach failed: " .. tostring(afterErr)
end
return ok, err
end
function TestRunner:_runSuite(suite, indent, results)
indent = indent or ""
print(indent .. "Suite: " .. suite.name)
for _, test in ipairs(suite.tests) do
results.total += 1
local ok, err = self:_runTest(test, suite)
if ok then
results.passed += 1
print(indent .. " [PASS] " .. test.name)
else
results.failed += 1
local msg = indent .. "[FAIL] " .. suite.name .. " > " .. test.name .. ": " .. tostring(err)
warn(msg)
table.insert(results.errors, msg)
if self.useTestService then
pcall(function()
TestService:Error(msg)
end)
end
end
end
for _, child in ipairs(suite.suites) do
self:_runSuite(child, indent .. " ", results)
end
end
function TestRunner:run()
local results = { total = 0, passed = 0, failed = 0, errors = {} }
for _, suite in ipairs(self.suites) do
self:_runSuite(suite, "", results)
end
print(string.format("\nResults: %d total, %d passed, %d failed", results.total, results.passed, results.failed))
if results.failed > 0 then
warn("Failed tests:")
for _, err in ipairs(results.errors) do
warn(" - " .. err)
end
end
return results.failed == 0, results
end
return TestRunner