Last active
July 3, 2025 04:57
-
-
Save littensy/fa9afcc5d66d25e86538634982be4ee4 to your computer and use it in GitHub Desktop.
Test suite submodule with support for Roblox and Luau runtimes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type Config = { | |
colors: boolean?, | |
verbose: boolean?, | |
} | |
type Description = { | |
status: number, | |
name: string, | |
only: boolean, | |
skip: boolean, | |
parent: Description?, | |
children: { Description | Test }, | |
passes: number, | |
fails: number, | |
skips: number, | |
runtime: number, | |
afterEach: () -> (), | |
beforeEach: () -> (), | |
} | |
type Test = { | |
status: number, | |
name: string, | |
only: boolean, | |
skip: boolean, | |
parent: Description, | |
children: nil, | |
run: () -> (), | |
result: unknown?, | |
line: number?, | |
runtime: number, | |
} | |
local options = { | |
colors = game :: any == nil, | |
verbose = game :: any ~= nil, | |
} | |
local color = { | |
white = function(text: string) | |
return if options.colors then `\27[37;1m{text}\27[0m` else text | |
end, | |
gray = function(text: string) | |
return if options.colors then `\27[90;1m{text}\27[0m` else text | |
end, | |
green = function(text: string) | |
return if options.colors then `\27[32;1m{text}\27[0m` else `\u{1F7E2} {text}` | |
end, | |
red = function(text: string) | |
return if options.colors then `\27[31;1m{text}\27[0m` else `\u{1F534} {text}` | |
end, | |
blue = function(text: string) | |
return if options.colors then `\27[34;1m{text}\27[0m` else `\u{1F535} {text}` | |
end, | |
} | |
local PENDING = 0 | |
local PASS = 1 | |
local FAIL = 2 | |
local SKIP = 3 | |
local ERROR = 4 | |
local tests: { Test } = {} | |
local descriptions: { Description } = {} | |
local root: Description = { | |
status = PENDING, | |
name = "root", | |
only = false, | |
skip = false, | |
children = {}, | |
passes = 0, | |
fails = 0, | |
skips = 0, | |
runtime = 0, | |
afterEach = function() end, | |
beforeEach = function() end, | |
} | |
local parent: Description = root | |
local function describeFn(_, name: string, run: () -> ()): Description | |
local thisParent = parent | |
local this: Description = { | |
status = PENDING, | |
name = name, | |
only = false, | |
skip = false, | |
parent = parent, | |
children = {}, | |
passes = 0, | |
fails = 0, | |
skips = 0, | |
runtime = 0, | |
afterEach = thisParent.afterEach, | |
beforeEach = thisParent.beforeEach, | |
} | |
local prevDescription = parent | |
parent = this | |
run() | |
parent = prevDescription | |
table.insert(parent.children, this) | |
table.insert(descriptions, this) | |
return this | |
end | |
local function testFn(_, name: string, run: () -> ()): Test | |
local this: Test = { | |
status = PENDING, | |
name = name, | |
only = false, | |
skip = false, | |
parent = parent, | |
run = run, | |
runtime = 0, | |
} | |
table.insert(parent.children, this) | |
table.insert(tests, this) | |
return this | |
end | |
local function skip() | |
parent.skip = true | |
end | |
local function only() | |
parent.only = true | |
root.only = true | |
end | |
local function testSkip(name: string, run: () -> ()): Test | |
local this = testFn(nil, name, run) | |
this.skip = true | |
return this | |
end | |
local function testOnly(name: string, run: () -> ()): Test | |
local this = testFn(nil, name, run) | |
this.only = true | |
root.only = true | |
return this | |
end | |
local function describeSkip(name: string, run: () -> ()): Description | |
local this = describeFn(nil, name, run) | |
this.skip = true | |
return this | |
end | |
local function describeOnly(name: string, run: () -> ()): Description | |
local this = describeFn(nil, name, run) | |
this.only = true | |
root.only = true | |
return this | |
end | |
local function describeInGame(name: string, run: () -> ()): Description | |
local this = describeFn(nil, name, if game then run else function() end) | |
this.skip = not game | |
return this | |
end | |
local function afterEach(callback: () -> ()) | |
local lastCallback = parent.afterEach | |
function parent.afterEach() | |
lastCallback() | |
callback() | |
end | |
end | |
local function beforeEach(callback: () -> ()) | |
local lastCallback = parent.beforeEach | |
function parent.beforeEach() | |
lastCallback() | |
callback() | |
end | |
end | |
local function isOnly(test: Test): boolean | |
if not root.only or test.only then | |
return true | |
end | |
local description: Description? = test.parent | |
while description and description ~= root do | |
if description.only then | |
return true | |
elseif description.skip then | |
return false | |
end | |
description = description.parent | |
end | |
return false | |
end | |
local function isSkip(test: Test): boolean | |
if test.only then | |
return false | |
elseif test.skip then | |
return true | |
end | |
local description: Description? = test.parent | |
while description do | |
if description.only and description ~= root then | |
return false | |
elseif description.skip then | |
return true | |
end | |
description = description.parent | |
end | |
return false | |
end | |
local function runTests() | |
for _, test in tests do | |
if isSkip(test) or not isOnly(test) then | |
test.status = SKIP | |
continue | |
end | |
test.parent.beforeEach() | |
local start = os.clock() | |
local success = xpcall(test.run, function(result) | |
test.status = FAIL | |
test.result = debug.traceback(tostring(result), 2) | |
test.line = debug.info(2, "l") | |
if test.line == -1 then | |
test.line = debug.info(3, "l") | |
end | |
end) | |
test.runtime = os.clock() - start | |
if success then | |
test.status = PASS | |
end | |
test.parent.afterEach() | |
end | |
end | |
local function updateDescription(description: Description) | |
if not description.children[1] then | |
description.status = if description.skip then SKIP else PASS | |
return | |
end | |
description.status = SKIP | |
for _, child in description.children do | |
if child.children then | |
updateDescription(child) | |
description.passes += child.passes | |
description.fails += child.fails | |
elseif child.status == PASS then | |
description.passes += 1 | |
elseif child.status == FAIL then | |
description.fails += 1 | |
end | |
-- Only count skips in direct children | |
if child.status == SKIP then | |
description.skips += 1 | |
end | |
description.runtime += child.runtime | |
if child.status == FAIL then | |
description.status = FAIL | |
elseif description.status == SKIP and child.status == PASS then | |
description.status = PASS | |
end | |
end | |
end | |
local function getIndent(node: Description | Test): string | |
local depth = 0 | |
local description = node.parent | |
while description and description ~= root do | |
depth += 1 | |
description = description.parent | |
end | |
return if depth > 0 then string.rep(" ", depth - 1) .. color.gray("· ") else "" | |
end | |
local function getPath(node: Description | Test): string | |
local path = { "" } | |
local description = node.parent | |
while description and description ~= root do | |
table.insert(path, 1, description.name) | |
description = description.parent | |
end | |
return table.concat(path, " > ") | |
end | |
local function greenOrRed(text: string, good: boolean): string | |
return if good then color.green(text) else color.red(text) | |
end | |
local function colorStatus(status: number): string | |
local wall = color.gray("│") | |
if status == PASS then | |
return color.green("PASS") .. wall | |
elseif status == FAIL then | |
return color.red("FAIL") .. wall | |
elseif status == SKIP then | |
return color.blue("SKIP") .. wall | |
else | |
return color.red("ERROR") .. wall | |
end | |
end | |
local function report() | |
local output = {} | |
local function reportDescription(description: Description) | |
local name = color.white(description.name) | |
local text = `{colorStatus(description.status)} {getIndent(description)}{name}` | |
if description.fails > 0 then | |
text ..= " " .. color.red(`({description.fails})`) | |
end | |
if description.skips > 0 then | |
text ..= " " .. color.blue(`({description.skips} skipped)`) | |
elseif description.fails == 0 then | |
text ..= " " .. color.gray(string.format("%.2fms", description.runtime * 1000)) | |
end | |
table.insert(output, text) | |
end | |
local function reportTest(test: Test, reportError: boolean?) | |
if test.status ~= FAIL and not options.verbose then | |
return | |
end | |
local line = color.red(`{test.line}:`) | |
if reportError then | |
local name = color.white(test.name) | |
local path = color.gray(getPath(test)) | |
table.insert(output, `{colorStatus(ERROR)} {path}{line}{name}`) | |
table.insert(output, `{test.result}`) | |
else | |
local name = color.gray(test.name) | |
local indent = getIndent(test) | |
if test.status == FAIL then | |
table.insert(output, `{colorStatus(FAIL)} {indent}{line}{name}`) | |
else | |
table.insert(output, `{colorStatus(test.status)} {indent}{name}`) | |
end | |
end | |
end | |
local function reportNode(node: Description | Test) | |
if not node.children then | |
return reportTest(node :: Test) | |
end | |
reportDescription(node) | |
for _, child in node.children do | |
reportNode(child) | |
end | |
end | |
local function reportSummary() | |
local describePasses = 0 | |
local describeFails = 0 | |
for _, description in descriptions do | |
if description.status == PASS then | |
describePasses += 1 | |
elseif description.status == FAIL then | |
describeFails += 1 | |
end | |
end | |
local describeText = | |
`{greenOrRed(`{describePasses} passed`, describeFails == 0)} {color.gray(`({#descriptions})`)}` | |
local testText = `{greenOrRed(`{root.passes} passed`, root.fails == 0)} {color.gray(`({#tests})`)}` | |
local duration = `{color.blue(`{string.format("%.2fms", root.runtime * 1000)}`)}` | |
table.insert(output, "") | |
table.insert(output, `Describe {describeText}`) | |
table.insert(output, ` Tests {testText}`) | |
table.insert(output, `Duration {duration}`) | |
table.insert(output, "") | |
end | |
local function reportErrors() | |
if root.fails == 0 then | |
return | |
end | |
for _, test in tests do | |
if test.status == FAIL then | |
reportTest(test, true) | |
table.insert(output, "") | |
end | |
end | |
end | |
table.insert(output, "") | |
table.insert(output, color.blue("Test Results")) | |
table.insert(output, "") | |
for _, child in root.children do | |
reportNode(child) | |
end | |
reportSummary() | |
reportErrors() | |
print(table.concat(output, "\n")) | |
if root.fails > 0 then | |
error(color.red(`{root.fails} test(s) failed`)) | |
end | |
end | |
local function finish(config: Config?) | |
if config then | |
options.colors = if config.colors ~= nil then config.colors else options.colors | |
options.verbose = if config.verbose ~= nil then config.verbose else options.verbose | |
end | |
runTests() | |
updateDescription(root) | |
report() | |
end | |
local test = setmetatable({ | |
skip = testSkip, | |
only = testOnly, | |
}, { __call = testFn }) | |
local describe = setmetatable({ | |
skip = describeSkip, | |
only = describeOnly, | |
ingame = describeInGame, | |
}, { __call = describeFn }) | |
return { | |
test = test, | |
describe = describe, | |
skip = skip, | |
only = only, | |
afterEach = afterEach, | |
beforeEach = beforeEach, | |
finish = finish, | |
} |
Author
littensy
commented
May 15, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment