This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,5 @@
# Generated by Rotriever. Format subject to change in future releases.
name = "roblox/testez"
version = "0.3.1"
commit = "42f0b3772c90ed9c0cff187fb56d508aca7b6641"
source = "url+https://github.com/roblox/testez"
@@ -0,0 +1,26 @@
--[[
The Context object implements a write-once key-value store. It also allows
for a new Context object to inherit the entries from an existing one.
]]
local Context = {}
function Context.new(parent)
local meta = {}
local index = {}
meta.__index = index
if parent then
for key, value in pairs(getmetatable(parent).__index) do
index[key] = value
end
end
function meta.__newindex(_obj, key, value)
assert(index[key] == nil, string.format("Cannot reassign %s in context", tostring(key)))
index[key] = value
end
return setmetatable({}, meta)
end
return Context
@@ -0,0 +1,252 @@
--[[
Allows creation of expectation statements designed for behavior-driven
testing (BDD). See Chai (JS) or RSpec (Ruby) for examples of other BDD
frameworks.
The Expectation class is exposed to tests as a function called `expect`:
expect(5).to.equal(5)
expect(foo()).to.be.ok()
Expectations can be negated using .never:
expect(true).never.to.equal(false)
Expectations throw errors when their conditions are not met.
]]
local Expectation = {}
--[[
These keys don't do anything except make expectations read more cleanly
]]
local SELF_KEYS = {
to = true,
be = true,
been = true,
have = true,
was = true,
at = true,
}
--[[
These keys invert the condition expressed by the Expectation.
]]
local NEGATION_KEYS = {
never = true,
}
--[[
Extension of Lua's 'assert' that lets you specify an error level.
]]
local function assertLevel(condition, message, level)
message = message or "Assertion failed!"
level = level or 1
if not condition then
error(message, level + 1)
end
end
--[[
Returns a version of the given method that can be called with either . or :
]]
local function bindSelf(self, method)
return function(firstArg, ...)
if firstArg == self then
return method(self, ...)
else
return method(self, firstArg, ...)
end
end
end
local function formatMessage(result, trueMessage, falseMessage)
if result then
return trueMessage
else
return falseMessage
end
end
--[[
Create a new expectation
]]
function Expectation.new(value)
local self = {
value = value,
successCondition = true,
condition = false
}
setmetatable(self, Expectation)
self.a = bindSelf(self, self.a)
self.an = self.a
self.ok = bindSelf(self, self.ok)
self.equal = bindSelf(self, self.equal)
self.throw = bindSelf(self, self.throw)
self.near = bindSelf(self, self.near)
return self
end
function Expectation.__index(self, key)
-- Keys that don't do anything except improve readability
if SELF_KEYS[key] then
return self
end
-- Invert your assertion
if NEGATION_KEYS[key] then
local newExpectation = Expectation.new(self.value)
newExpectation.successCondition = not self.successCondition
return newExpectation
end
-- Fall back to methods provided by Expectation
return Expectation[key]
end
--[[
Called by expectation terminators to reset modifiers in a statement.
This makes chains like:
expect(5)
.never.to.equal(6)
.to.equal(5)
Work as expected.
]]
function Expectation:_resetModifiers()
self.successCondition = true
end
--[[
Assert that the expectation value is the given type.
expect(5).to.be.a("number")
]]
function Expectation:a(typeName)
local result = (type(self.value) == typeName) == self.successCondition
local message = formatMessage(self.successCondition,
("Expected value of type %q, got value %q of type %s"):format(
typeName,
tostring(self.value),
type(self.value)
),
("Expected value not of type %q, got value %q of type %s"):format(
typeName,
tostring(self.value),
type(self.value)
)
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
--[[
Assert that our expectation value is truthy
]]
function Expectation:ok()
local result = (self.value ~= nil) == self.successCondition
local message = formatMessage(self.successCondition,
("Expected value %q to be non-nil"):format(
tostring(self.value)
),
("Expected value %q to be nil"):format(
tostring(self.value)
)
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
--[[
Assert that our expectation value is equal to another value
]]
function Expectation:equal(otherValue)
local result = (self.value == otherValue) == self.successCondition
local message = formatMessage(self.successCondition,
("Expected value %q (%s), got %q (%s) instead"):format(
tostring(otherValue),
type(otherValue),
tostring(self.value),
type(self.value)
),
("Expected anything but value %q (%s)"):format(
tostring(otherValue),
type(otherValue)
)
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
--[[
Assert that our expectation value is equal to another value within some
inclusive limit.
]]
function Expectation:near(otherValue, limit)
assert(type(self.value) == "number", "Expectation value must be a number to use 'near'")
assert(type(otherValue) == "number", "otherValue must be a number")
assert(type(limit) == "number" or limit == nil, "limit must be a number or nil")
limit = limit or 1e-7
local result = (math.abs(self.value - otherValue) <= limit) == self.successCondition
local message = formatMessage(self.successCondition,
("Expected value to be near %f (within %f) but got %f instead"):format(
otherValue,
limit,
self.value
),
("Expected value to not be near %f (within %f) but got %f instead"):format(
otherValue,
limit,
self.value
)
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
--[[
Assert that our functoid expectation value throws an error when called
]]
function Expectation:throw()
local ok, err = pcall(self.value)
local result = ok ~= self.successCondition
local message = formatMessage(self.successCondition,
"Expected function to throw an error, but it did not.",
("Expected function to succeed, but it threw an error: %s"):format(
tostring(err)
)
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
return Expectation
@@ -0,0 +1,89 @@
local TestEnum = require(script.Parent.TestEnum)
local LifecycleHooks = {}
LifecycleHooks.__index = LifecycleHooks
function LifecycleHooks.new()
local self = {
_stack = {},
}
return setmetatable(self, LifecycleHooks)
end
--[[
Returns an array of `beforeEach` hooks in FIFO order
]]
function LifecycleHooks:getBeforeEachHooks()
local key = TestEnum.NodeType.BeforeEach
local hooks = {}
for _, level in ipairs(self._stack) do
for _, hook in ipairs(level[key]) do
table.insert(hooks, hook)
end
end
return hooks
end
--[[
Returns an array of `afterEach` hooks in FILO order
]]
function LifecycleHooks:getAfterEachHooks()
local key = TestEnum.NodeType.AfterEach
local hooks = {}
for _, level in ipairs(self._stack) do
for _, hook in ipairs(level[key]) do
table.insert(hooks, 1, hook)
end
end
return hooks
end
--[[
Pushes uncalled beforeAll and afterAll hooks back up the stack
]]
function LifecycleHooks:popHooks()
table.remove(self._stack, #self._stack)
end
function LifecycleHooks:pushHooksFrom(planNode)
assert(planNode ~= nil)
table.insert(self._stack, {
[TestEnum.NodeType.BeforeAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeAll),
[TestEnum.NodeType.AfterAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterAll),
[TestEnum.NodeType.BeforeEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeEach),
[TestEnum.NodeType.AfterEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterEach),
})
end
--[[
Get the beforeAll hooks from the current level.
]]
function LifecycleHooks:getBeforeAllHooks()
return self._stack[#self._stack][TestEnum.NodeType.BeforeAll]
end
--[[
Get the afterAll hooks from the current level.
]]
function LifecycleHooks:getAfterAllHooks()
return self._stack[#self._stack][TestEnum.NodeType.AfterAll]
end
function LifecycleHooks:_getHooksOfType(nodes, key)
local hooks = {}
for _, node in ipairs(nodes) do
if node.type == key then
table.insert(hooks, node.callback)
end
end
return hooks
end
return LifecycleHooks
@@ -0,0 +1,102 @@
local TestService = game:GetService("TestService")
local TestEnum = require(script.Parent.Parent.TestEnum)
local TeamCityReporter = {}
local function teamCityEscape(str)
str = string.gsub(str, "([]|'[])","|%1")
str = string.gsub(str, "\r", "|r")
str = string.gsub(str, "\n", "|n")
return str
end
local function teamCityEnterSuite(suiteName)
return string.format("##teamcity[testSuiteStarted name='%s']", teamCityEscape(suiteName))
end
local function teamCityLeaveSuite(suiteName)
return string.format("##teamcity[testSuiteFinished name='%s']", teamCityEscape(suiteName))
end
local function teamCityEnterCase(caseName)
return string.format("##teamcity[testStarted name='%s']", teamCityEscape(caseName))
end
local function teamCityLeaveCase(caseName)
return string.format("##teamcity[testFinished name='%s']", teamCityEscape(caseName))
end
local function teamCityFailCase(caseName, errorMessage)
return string.format("##teamcity[testFailed name='%s' message='%s']",
teamCityEscape(caseName), teamCityEscape(errorMessage))
end
local function reportNode(node, buffer, level)
buffer = buffer or {}
level = level or 0
if node.status == TestEnum.TestStatus.Skipped then
return buffer
end
if node.planNode.type == TestEnum.NodeType.Describe then
table.insert(buffer, teamCityEnterSuite(node.planNode.phrase))
for _, child in ipairs(node.children) do
reportNode(child, buffer, level + 1)
end
table.insert(buffer, teamCityLeaveSuite(node.planNode.phrase))
else
table.insert(buffer, teamCityEnterCase(node.planNode.phrase))
if node.status == TestEnum.TestStatus.Failure then
table.insert(buffer, teamCityFailCase(node.planNode.phrase, table.concat(node.errors,"\n")))
end
table.insert(buffer, teamCityLeaveCase(node.planNode.phrase))
end
end
local function reportRoot(node)
local buffer = {}
for _, child in ipairs(node.children) do
reportNode(child, buffer, 0)
end
return buffer
end
local function report(root)
local buffer = reportRoot(root)
return table.concat(buffer, "\n")
end
function TeamCityReporter.report(results)
local resultBuffer = {
"Test results:",
report(results),
("%d passed, %d failed, %d skipped"):format(
results.successCount,
results.failureCount,
results.skippedCount
)
}
print(table.concat(resultBuffer, "\n"))
if results.failureCount > 0 then
print(("%d test nodes reported failures."):format(results.failureCount))
end
if #results.errors > 0 then
print("Errors reported by tests:")
print("")
for _, message in ipairs(results.errors) do
TestService:Error(message)
-- Insert a blank line after each error
print("")
end
end
end
return TeamCityReporter
@@ -0,0 +1,106 @@
--[[
The TextReporter uses the results from a completed test to output text to
standard output and TestService.
]]
local TestService = game:GetService("TestService")
local TestEnum = require(script.Parent.Parent.TestEnum)
local INDENT = (" "):rep(3)
local STATUS_SYMBOLS = {
[TestEnum.TestStatus.Success] = "+",
[TestEnum.TestStatus.Failure] = "-",
[TestEnum.TestStatus.Skipped] = "~"
}
local UNKNOWN_STATUS_SYMBOL = "?"
local TextReporter = {}
local function compareNodes(a, b)
return a.planNode.phrase:lower() < b.planNode.phrase:lower()
end
local function reportNode(node, buffer, level)
buffer = buffer or {}
level = level or 0
if node.status == TestEnum.TestStatus.Skipped then
return buffer
end
local line
if node.status then
local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL
line = ("%s[%s] %s"):format(
INDENT:rep(level),
symbol,
node.planNode.phrase
)
else
line = ("%s%s"):format(
INDENT:rep(level),
node.planNode.phrase
)
end
table.insert(buffer, line)
table.sort(node.children, compareNodes)
for _, child in ipairs(node.children) do
reportNode(child, buffer, level + 1)
end
return buffer
end
local function reportRoot(node)
local buffer = {}
table.sort(node.children, compareNodes)
for _, child in ipairs(node.children) do
reportNode(child, buffer, 0)
end
return buffer
end
local function report(root)
local buffer = reportRoot(root)
return table.concat(buffer, "\n")
end
function TextReporter.report(results)
local resultBuffer = {
"Test results:",
report(results),
("%d passed, %d failed, %d skipped"):format(
results.successCount,
results.failureCount,
results.skippedCount
)
}
print(table.concat(resultBuffer, "\n"))
if results.failureCount > 0 then
print(("%d test nodes reported failures."):format(results.failureCount))
end
if #results.errors > 0 then
print("Errors reported by tests:")
print("")
for _, message in ipairs(results.errors) do
TestService:Error(message)
-- Insert a blank line after each error
print("")
end
end
end
return TextReporter
@@ -0,0 +1,97 @@
--[[
Copy of TextReporter that doesn't output successful tests.
This should be temporary, it's just a workaround to make CI environments
happy in the short-term.
]]
local TestService = game:GetService("TestService")
local TestEnum = require(script.Parent.Parent.TestEnum)
local INDENT = (" "):rep(3)
local STATUS_SYMBOLS = {
[TestEnum.TestStatus.Success] = "+",
[TestEnum.TestStatus.Failure] = "-",
[TestEnum.TestStatus.Skipped] = "~"
}
local UNKNOWN_STATUS_SYMBOL = "?"
local TextReporterQuiet = {}
local function reportNode(node, buffer, level)
buffer = buffer or {}
level = level or 0
if node.status == TestEnum.TestStatus.Skipped then
return buffer
end
local line
if node.status ~= TestEnum.TestStatus.Success then
local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL
line = ("%s[%s] %s"):format(
INDENT:rep(level),
symbol,
node.planNode.phrase
)
end
table.insert(buffer, line)
for _, child in ipairs(node.children) do
reportNode(child, buffer, level + 1)
end
return buffer
end
local function reportRoot(node)
local buffer = {}
for _, child in ipairs(node.children) do
reportNode(child, buffer, 0)
end
return buffer
end
local function report(root)
local buffer = reportRoot(root)
return table.concat(buffer, "\n")
end
function TextReporterQuiet.report(results)
local resultBuffer = {
"Test results:",
report(results),
("%d passed, %d failed, %d skipped"):format(
results.successCount,
results.failureCount,
results.skippedCount
)
}
print(table.concat(resultBuffer, "\n"))
if results.failureCount > 0 then
print(("%d test nodes reported failures."):format(results.failureCount))
end
if #results.errors > 0 then
print("Errors reported by tests:")
print("")
for _, message in ipairs(results.errors) do
TestService:Error(message)
-- Insert a blank line after each error
print("")
end
end
end
return TextReporterQuiet
@@ -0,0 +1,147 @@
--[[
Provides an interface to quickly run and report tests from a given object.
]]
local TestPlanner = require(script.Parent.TestPlanner)
local TestRunner = require(script.Parent.TestRunner)
local TextReporter = require(script.Parent.Reporters.TextReporter)
local TestBootstrap = {}
local function stripSpecSuffix(name)
return (name:gsub("%.spec$", ""))
end
local function isSpecScript(aScript)
return aScript:IsA("ModuleScript") and aScript.Name:match("%.spec$")
end
local function getPath(module, root)
root = root or game
local path = {}
local last = module
if last.Name == "init.spec" then
-- Use the directory's node for init.spec files.
last = last.Parent
end
while last ~= nil and last ~= root do
table.insert(path, stripSpecSuffix(last.Name))
last = last.Parent
end
table.insert(path, stripSpecSuffix(root.Name))
return path
end
local function toStringPath(tablePath)
local stringPath = ""
local first = true
for _, element in ipairs(tablePath) do
if first then
stringPath = element
first = false
else
stringPath = element .. " " .. stringPath
end
end
return stringPath
end
function TestBootstrap:getModulesImpl(root, modules, current)
modules = modules or {}
current = current or root
if isSpecScript(current) then
local method = require(current)
local path = getPath(current, root)
local pathString = toStringPath(path)
table.insert(modules, {
method = method,
path = path,
pathStringForSorting = pathString:lower()
})
end
end
--[[
Find all the ModuleScripts in this tree that are tests.
]]
function TestBootstrap:getModules(root)
local modules = {}
self:getModulesImpl(root, modules)
for _, child in ipairs(root:GetDescendants()) do
self:getModulesImpl(root, modules, child)
end
return modules
end
--[[
Runs all test and reports the results using the given test reporter.
If no reporter is specified, a reasonable default is provided.
This function demonstrates the expected workflow with this testing system:
1. Locate test modules
2. Generate test plan
3. Run test plan
4. Report test results
This means we could hypothetically present a GUI to the developer that shows
the test plan before we execute it, allowing them to toggle specific tests
before they're run, but after they've been identified!
]]
function TestBootstrap:run(roots, reporter, otherOptions)
reporter = reporter or TextReporter
otherOptions = otherOptions or {}
local showTimingInfo = otherOptions["showTimingInfo"] or false
local testNamePattern = otherOptions["testNamePattern"]
local extraEnvironment = otherOptions["extraEnvironment"] or {}
if type(roots) ~= "table" then
error(("Bad argument #1 to TestBootstrap:run. Expected table, got %s"):format(typeof(roots)), 2)
end
local startTime = tick()
local modules = {}
for _, subRoot in ipairs(roots) do
local newModules = self:getModules(subRoot)
for _, newModule in ipairs(newModules) do
table.insert(modules, newModule)
end
end
local afterModules = tick()
local plan = TestPlanner.createPlan(modules, testNamePattern, extraEnvironment)
local afterPlan = tick()
local results = TestRunner.runPlan(plan)
local afterRun = tick()
reporter.report(results)
local afterReport = tick()
if showTimingInfo then
local timing = {
("Took %f seconds to locate test modules"):format(afterModules - startTime),
("Took %f seconds to create test plan"):format(afterPlan - afterModules),
("Took %f seconds to run tests"):format(afterRun - afterPlan),
("Took %f seconds to report tests"):format(afterReport - afterRun),
}
print(table.concat(timing, "\n"))
end
return results
end
return TestBootstrap
@@ -0,0 +1,28 @@
--[[
Constants used throughout the testing framework.
]]
local TestEnum = {}
TestEnum.TestStatus = {
Success = "Success",
Failure = "Failure",
Skipped = "Skipped"
}
TestEnum.NodeType = {
Describe = "Describe",
It = "It",
BeforeAll = "BeforeAll",
AfterAll = "AfterAll",
BeforeEach = "BeforeEach",
AfterEach = "AfterEach"
}
TestEnum.NodeModifier = {
None = "None",
Skip = "Skip",
Focus = "Focus"
}
return TestEnum
@@ -0,0 +1,291 @@
--[[
Represents a tree of tests that have been loaded but not necessarily
executed yet.
TestPlan objects are produced by TestPlanner.
]]
local TestEnum = require(script.Parent.TestEnum)
local Expectation = require(script.Parent.Expectation)
local function newEnvironment(currentNode, extraEnvironment)
local env = {}
if extraEnvironment then
if type(extraEnvironment) ~= "table" then
error(("Bad argument #2 to newEnvironment. Expected table, got %s"):format(
typeof(extraEnvironment)), 2)
end
for key, value in pairs(extraEnvironment) do
env[key] = value
end
end
local function addChild(phrase, callback, nodeType, nodeModifier)
local node = currentNode:addChild(phrase, nodeType, nodeModifier)
node.callback = callback
if nodeType == TestEnum.NodeType.Describe then
node:expand()
end
return node
end
function env.describeFOCUS(phrase, callback)
addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Focus)
end
function env.describeSKIP(phrase, callback)
addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Skip)
end
function env.describe(phrase, callback, nodeModifier)
addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.None)
end
function env.itFOCUS(phrase, callback)
addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Focus)
end
function env.itSKIP(phrase, callback)
addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip)
end
function env.itFIXME(phrase, callback)
local node = addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip)
warn("FIXME: broken test", node:getFullName())
end
function env.it(phrase, callback, nodeModifier)
addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.None)
end
-- Incrementing counter used to ensure that beforeAll, afterAll, beforeEach, afterEach have unique phrases
local lifecyclePhaseId = 0
local lifecycleHooks = {
[TestEnum.NodeType.BeforeAll] = "beforeAll",
[TestEnum.NodeType.AfterAll] = "afterAll",
[TestEnum.NodeType.BeforeEach] = "beforeEach",
[TestEnum.NodeType.AfterEach] = "afterEach"
}
for nodeType, name in pairs(lifecycleHooks) do
env[name] = function(callback)
addChild(name .. "_" .. tostring(lifecyclePhaseId), callback, nodeType, TestEnum.NodeModifier.None)
lifecyclePhaseId = lifecyclePhaseId + 1
end
end
function env.FIXME(optionalMessage)
warn("FIXME: broken test", currentNode:getFullName(), optionalMessage or "")
currentNode.modifier = TestEnum.NodeModifier.Skip
end
function env.FOCUS()
currentNode.modifier = TestEnum.NodeModifier.Focus
end
function env.SKIP()
currentNode.modifier = TestEnum.NodeModifier.Skip
end
--[[
This function is deprecated. Calling it is a no-op beyond generating a
warning.
]]
function env.HACK_NO_XPCALL()
warn("HACK_NO_XPCALL is deprecated. It is now safe to yield in an " ..
"xpcall, so this is no longer necessary. It can be safely deleted.")
end
env.fit = env.itFOCUS
env.xit = env.itSKIP
env.fdescribe = env.describeFOCUS
env.xdescribe = env.describeSKIP
env.expect = Expectation.new
return env
end
local TestNode = {}
TestNode.__index = TestNode
--[[
Create a new test node. A pointer to the test plan, a phrase to describe it
and the type of node it is are required. The modifier is optional and will
be None if left blank.
]]
function TestNode.new(plan, phrase, nodeType, nodeModifier)
nodeModifier = nodeModifier or TestEnum.NodeModifier.None
local node = {
plan = plan,
phrase = phrase,
type = nodeType,
modifier = nodeModifier,
children = {},
callback = nil,
parent = nil,
}
node.environment = newEnvironment(node, plan.extraEnvironment)
return setmetatable(node, TestNode)
end
local function getModifier(name, pattern, modifier)
if pattern and (modifier == nil or modifier == TestEnum.NodeModifier.None) then
if name:match(pattern) then
return TestEnum.NodeModifier.Focus
else
return TestEnum.NodeModifier.Skip
end
end
return modifier
end
function TestNode:addChild(phrase, nodeType, nodeModifier)
if nodeType == TestEnum.NodeType.It then
for _, child in pairs(self.children) do
if child.phrase == phrase then
error("Duplicate it block found: " .. child:getFullName())
end
end
end
local childName = self:getFullName() .. " " .. phrase
nodeModifier = getModifier(childName, self.plan.testNamePattern, nodeModifier)
local child = TestNode.new(self.plan, phrase, nodeType, nodeModifier)
child.parent = self
table.insert(self.children, child)
return child
end
--[[
Join the names of all the nodes back to the parent.
]]
function TestNode:getFullName()
if self.parent then
local parentPhrase = self.parent:getFullName()
if parentPhrase then
return parentPhrase .. " " .. self.phrase
end
end
return self.phrase
end
--[[
Expand a node by setting its callback environment and then calling it. Any
further it and describe calls within the callback will be added to the tree.
]]
function TestNode:expand()
local originalEnv = getfenv(self.callback)
local callbackEnv = setmetatable({}, { __index = originalEnv })
for key, value in pairs(self.environment) do
callbackEnv[key] = value
end
setfenv(self.callback, callbackEnv)
local success, result = xpcall(self.callback, debug.traceback)
if not success then
self.loadError = result
end
end
local TestPlan = {}
TestPlan.__index = TestPlan
--[[
Create a new, empty TestPlan.
]]
function TestPlan.new(testNamePattern, extraEnvironment)
local plan = {
children = {},
testNamePattern = testNamePattern,
extraEnvironment = extraEnvironment,
}
return setmetatable(plan, TestPlan)
end
--[[
Add a new child under the test plan's root node.
]]
function TestPlan:addChild(phrase, nodeType, nodeModifier)
nodeModifier = getModifier(phrase, self.testNamePattern, nodeModifier)
local child = TestNode.new(self, phrase, nodeType, nodeModifier)
table.insert(self.children, child)
return child
end
--[[
Add a new describe node with the given method as a callback. Generates or
reuses all the describe nodes along the path.
]]
function TestPlan:addRoot(path, method)
local curNode = self
for i = #path, 1, -1 do
local nextNode = nil
for _, child in ipairs(curNode.children) do
if child.phrase == path[i] then
nextNode = child
break
end
end
if nextNode == nil then
nextNode = curNode:addChild(path[i], TestEnum.NodeType.Describe)
end
curNode = nextNode
end
curNode.callback = method
curNode:expand()
end
--[[
Calls the given callback on all nodes in the tree, traversed depth-first.
]]
function TestPlan:visitAllNodes(callback, root, level)
root = root or self
level = level or 0
for _, child in ipairs(root.children) do
callback(child, level)
self:visitAllNodes(callback, child, level + 1)
end
end
--[[
Visualizes the test plan in a simple format, suitable for debugging the test
plan's structure.
]]
function TestPlan:visualize()
local buffer = {}
self:visitAllNodes(function(node, level)
table.insert(buffer, (" "):rep(3 * level) .. node.phrase)
end)
return table.concat(buffer, "\n")
end
--[[
Gets a list of all nodes in the tree for which the given callback returns
true.
]]
function TestPlan:findNodes(callback)
local results = {}
self:visitAllNodes(function(node)
if callback(node) then
table.insert(results, node)
end
end)
return results
end
return TestPlan
@@ -0,0 +1,40 @@
--[[
Turns a series of specification functions into a test plan.
Uses a TestPlanBuilder to keep track of the state of the tree being built.
]]
local TestPlan = require(script.Parent.TestPlan)
local TestPlanner = {}
--[[
Create a new TestPlan from a list of specification functions.
These functions should call a combination of `describe` and `it` (and their
variants), which will be turned into a test plan to be executed.
Parameters:
- modulesList - list of tables describing test modules {
method, -- specification function described above
path, -- array of parent entires, first element is the leaf that owns `method`
pathStringForSorting -- a string representation of `path`, used for sorting of the test plan
}
- testNamePattern - Only tests matching this Lua pattern string will run. Pass empty or nil to run all tests
- extraEnvironment - Lua table holding additional functions and variables to be injected into the specification
function during execution
]]
function TestPlanner.createPlan(modulesList, testNamePattern, extraEnvironment)
local plan = TestPlan.new(testNamePattern, extraEnvironment)
table.sort(modulesList, function(a, b)
return a.pathStringForSorting < b.pathStringForSorting
end)
for _, module in ipairs(modulesList) do
plan:addRoot(module.path, module.method)
end
return plan
end
return TestPlanner
@@ -0,0 +1,112 @@
--[[
Represents a tree of test results.
Each node in the tree corresponds directly to a node in a corresponding
TestPlan, accessible via the 'planNode' field.
TestResults objects are produced by TestRunner using TestSession as state.
]]
local TestEnum = require(script.Parent.TestEnum)
local STATUS_SYMBOLS = {
[TestEnum.TestStatus.Success] = "+",
[TestEnum.TestStatus.Failure] = "-",
[TestEnum.TestStatus.Skipped] = "~"
}
local TestResults = {}
TestResults.__index = TestResults
--[[
Create a new TestResults tree that's linked to the given TestPlan.
]]
function TestResults.new(plan)
local self = {
successCount = 0,
failureCount = 0,
skippedCount = 0,
planNode = plan,
children = {},
errors = {}
}
setmetatable(self, TestResults)
return self
end
--[[
Create a new result node that can be inserted into a TestResult tree.
]]
function TestResults.createNode(planNode)
local node = {
planNode = planNode,
children = {},
errors = {},
status = nil
}
return node
end
--[[
Visit all test result nodes, depth-first.
]]
function TestResults:visitAllNodes(callback, root)
root = root or self
for _, child in ipairs(root.children) do
callback(child)
self:visitAllNodes(callback, child)
end
end
--[[
Creates a debug visualization of the test results.
]]
function TestResults:visualize(root, level)
root = root or self
level = level or 0
local buffer = {}
for _, child in ipairs(root.children) do
if child.planNode.type == TestEnum.NodeType.It then
local symbol = STATUS_SYMBOLS[child.status] or "?"
local str = ("%s[%s] %s"):format(
(" "):rep(3 * level),
symbol,
child.planNode.phrase
)
if child.messages and #child.messages > 0 then
str = str .. "\n " .. (" "):rep(3 * level) .. table.concat(child.messages, "\n " .. (" "):rep(3 * level))
end
table.insert(buffer, str)
else
local str = ("%s%s"):format(
(" "):rep(3 * level),
child.planNode.phrase or ""
)
if child.status then
str = str .. (" (%s)"):format(child.status)
end
table.insert(buffer, str)
if #child.children > 0 then
local text = self:visualize(child, level + 1)
table.insert(buffer, text)
end
end
end
return table.concat(buffer, "\n")
end
return TestResults
@@ -0,0 +1,177 @@
--[[
Contains the logic to run a test plan and gather test results from it.
TestRunner accepts a TestPlan object, executes the planned tests, and
produces a TestResults object. While the tests are running, the system's
state is contained inside a TestSession object.
]]
local Expectation = require(script.Parent.Expectation)
local TestEnum = require(script.Parent.TestEnum)
local TestSession = require(script.Parent.TestSession)
local LifecycleHooks = require(script.Parent.LifecycleHooks)
local RUNNING_GLOBAL = "__TESTEZ_RUNNING_TEST__"
local TestRunner = {
environment = {}
}
function TestRunner.environment.expect(...)
return Expectation.new(...)
end
--[[
Runs the given TestPlan and returns a TestResults object representing the
results of the run.
]]
function TestRunner.runPlan(plan)
local session = TestSession.new(plan)
local lifecycleHooks = LifecycleHooks.new()
local exclusiveNodes = plan:findNodes(function(node)
return node.modifier == TestEnum.NodeModifier.Focus
end)
session.hasFocusNodes = #exclusiveNodes > 0
TestRunner.runPlanNode(session, plan, lifecycleHooks)
return session:finalize()
end
--[[
Run the given test plan node and its descendants, using the given test
session to store all of the results.
]]
function TestRunner.runPlanNode(session, planNode, lifecycleHooks)
local function runCallback(callback, messagePrefix)
local success = true
local errorMessage
-- Any code can check RUNNING_GLOBAL to fork behavior based on
-- whether a test is running. We use this to avoid accessing
-- protected APIs; it's a workaround that will go away someday.
_G[RUNNING_GLOBAL] = true
messagePrefix = messagePrefix or ""
local testEnvironment = getfenv(callback)
for key, value in pairs(TestRunner.environment) do
testEnvironment[key] = value
end
testEnvironment.fail = function(message)
if message == nil then
message = "fail() was called."
end
success = false
errorMessage = messagePrefix .. message .. "\n" .. debug.traceback()
end
local context = session:getContext()
local nodeSuccess, nodeResult = xpcall(
function()
callback(context)
end,
function(message)
return messagePrefix .. message .. "\n" .. debug.traceback()
end
)
-- If a node threw an error, we prefer to use that message over
-- one created by fail() if it was set.
if not nodeSuccess then
success = false
errorMessage = nodeResult
end
_G[RUNNING_GLOBAL] = nil
return success, errorMessage
end
local function runNode(childPlanNode)
-- Errors can be set either via `error` propagating upwards or
-- by a test calling fail([message]).
for _, hook in ipairs(lifecycleHooks:getBeforeEachHooks()) do
local success, errorMessage = runCallback(hook, "beforeEach hook: ")
if not success then
return false, errorMessage
end
end
do
local success, errorMessage = runCallback(childPlanNode.callback)
if not success then
return false, errorMessage
end
end
for _, hook in ipairs(lifecycleHooks:getAfterEachHooks()) do
local success, errorMessage = runCallback(hook, "afterEach hook: ")
if not success then
return false, errorMessage
end
end
return true, nil
end
lifecycleHooks:pushHooksFrom(planNode)
local halt = false
for _, hook in ipairs(lifecycleHooks:getBeforeAllHooks()) do
local success, errorMessage = runCallback(hook, "beforeAll hook: ")
if not success then
session:addDummyError("beforeAll", errorMessage)
halt = true
end
end
if not halt then
for _, childPlanNode in ipairs(planNode.children) do
session:pushNode(childPlanNode)
if childPlanNode.type == TestEnum.NodeType.It then
if session:shouldSkip() then
session:setSkipped()
else
local success, errorMessage = runNode(childPlanNode)
if success then
session:setSuccess()
else
session:setError(errorMessage)
end
end
elseif childPlanNode.type == TestEnum.NodeType.Describe then
TestRunner.runPlanNode(session, childPlanNode, lifecycleHooks)
-- Did we have an error trying build a test plan?
if childPlanNode.loadError then
local message = "Error during planning: " .. childPlanNode.loadError
session:setError(message)
else
session:setStatusFromChildren()
end
end
session:popNode()
end
end
for _, hook in ipairs(lifecycleHooks:getAfterAllHooks()) do
local success, errorMessage = runCallback(hook, "afterAll hook: ")
if not success then
session:addDummyError("afterAll", errorMessage)
end
end
lifecycleHooks:popHooks()
end
return TestRunner
@@ -0,0 +1,230 @@
--[[
Represents the state relevant while executing a test plan.
Used by TestRunner to produce a TestResults object.
Uses the same tree building structure as TestPlanBuilder; TestSession keeps
track of a stack of nodes that represent the current path through the tree.
]]
local TestEnum = require(script.Parent.TestEnum)
local TestResults = require(script.Parent.TestResults)
local Context = require(script.Parent.Context)
local TestSession = {}
TestSession.__index = TestSession
--[[
Create a TestSession related to the given TestPlan.
The resulting TestResults object will be linked to this TestPlan.
]]
function TestSession.new(plan)
local self = {
results = TestResults.new(plan),
nodeStack = {},
contextStack = {},
hasFocusNodes = false
}
setmetatable(self, TestSession)
return self
end
--[[
Calculate success, failure, and skipped test counts in the tree at the
current point in the execution.
]]
function TestSession:calculateTotals()
local results = self.results
results.successCount = 0
results.failureCount = 0
results.skippedCount = 0
results:visitAllNodes(function(node)
local status = node.status
local nodeType = node.planNode.type
if nodeType == TestEnum.NodeType.It then
if status == TestEnum.TestStatus.Success then
results.successCount = results.successCount + 1
elseif status == TestEnum.TestStatus.Failure then
results.failureCount = results.failureCount + 1
elseif status == TestEnum.TestStatus.Skipped then
results.skippedCount = results.skippedCount + 1
end
end
end)
end
--[[
Gathers all of the errors reported by tests and puts them at the top level
of the TestResults object.
]]
function TestSession:gatherErrors()
local results = self.results
results.errors = {}
results:visitAllNodes(function(node)
if #node.errors > 0 then
for _, message in ipairs(node.errors) do
table.insert(results.errors, message)
end
end
end)
end
--[[
Calculates test totals, verifies the tree is valid, and returns results.
]]
function TestSession:finalize()
if #self.nodeStack ~= 0 then
error("Cannot finalize TestResults with nodes still on the stack!", 2)
end
self:calculateTotals()
self:gatherErrors()
return self.results
end
--[[
Create a new test result node and push it onto the navigation stack.
]]
function TestSession:pushNode(planNode)
local node = TestResults.createNode(planNode)
local lastNode = self.nodeStack[#self.nodeStack] or self.results
local lastContext = self.contextStack[#self.contextStack]
local context = Context.new(lastContext)
table.insert(lastNode.children, node)
table.insert(self.nodeStack, node)
table.insert(self.contextStack, context)
end
--[[
Pops a node off of the navigation stack.
]]
function TestSession:popNode()
assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!")
table.remove(self.nodeStack, #self.nodeStack)
table.remove(self.contextStack, #self.contextStack)
end
--[[
Gets the Context object for the current node.
]]
function TestSession:getContext()
assert(#self.contextStack > 0, "Tried to get context from an empty stack!")
return self.contextStack[#self.contextStack]
end
--[[
Tells whether the current test we're in should be skipped.
]]
function TestSession:shouldSkip()
-- If our test tree had any exclusive tests, then normal tests are skipped!
if self.hasFocusNodes then
for i = #self.nodeStack, 1, -1 do
local node = self.nodeStack[i]
-- Skipped tests are still skipped
if node.planNode.modifier == TestEnum.NodeModifier.Skip then
return true
end
-- Focused tests are the only ones that aren't skipped
if node.planNode.modifier == TestEnum.NodeModifier.Focus then
return false
end
end
return true
else
for i = #self.nodeStack, 1, -1 do
local node = self.nodeStack[i]
if node.planNode.modifier == TestEnum.NodeModifier.Skip then
return true
end
end
end
return false
end
--[[
Set the current node's status to Success.
]]
function TestSession:setSuccess()
assert(#self.nodeStack > 0, "Attempting to set success status on empty stack")
self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Success
end
--[[
Set the current node's status to Skipped.
]]
function TestSession:setSkipped()
assert(#self.nodeStack > 0, "Attempting to set skipped status on empty stack")
self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Skipped
end
--[[
Set the current node's status to Failure and adds a message to its list of
errors.
]]
function TestSession:setError(message)
assert(#self.nodeStack > 0, "Attempting to set error status on empty stack")
local last = self.nodeStack[#self.nodeStack]
last.status = TestEnum.TestStatus.Failure
table.insert(last.errors, message)
end
--[[
Add a dummy child node to the current node to hold the given error. This
allows an otherwise empty describe node to report an error in a more natural
way.
]]
function TestSession:addDummyError(phrase, message)
self:pushNode({type = TestEnum.NodeType.It, phrase = phrase})
self:setError(message)
self:popNode()
self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Failure
end
--[[
Set the current node's status based on that of its children. If all children
are skipped, mark it as skipped. If any are fails, mark it as failed.
Otherwise, mark it as success.
]]
function TestSession:setStatusFromChildren()
assert(#self.nodeStack > 0, "Attempting to set status from children on empty stack")
local last = self.nodeStack[#self.nodeStack]
local status = TestEnum.TestStatus.Success
local skipped = true
-- If all children were skipped, then we were skipped
-- If any child failed, then we failed!
for _, child in ipairs(last.children) do
if child.status ~= TestEnum.TestStatus.Skipped then
skipped = false
if child.status == TestEnum.TestStatus.Failure then
status = TestEnum.TestStatus.Failure
end
end
end
if skipped then
status = TestEnum.TestStatus.Skipped
end
last.status = status
end
return TestSession
@@ -0,0 +1,40 @@
local Expectation = require(script.Expectation)
local TestBootstrap = require(script.TestBootstrap)
local TestEnum = require(script.TestEnum)
local TestPlan = require(script.TestPlan)
local TestPlanner = require(script.TestPlanner)
local TestResults = require(script.TestResults)
local TestRunner = require(script.TestRunner)
local TestSession = require(script.TestSession)
local TextReporter = require(script.Reporters.TextReporter)
local TextReporterQuiet = require(script.Reporters.TextReporterQuiet)
local TeamCityReporter = require(script.Reporters.TeamCityReporter)
local function run(testRoot, callback)
local modules = TestBootstrap:getModules(testRoot)
local plan = TestPlanner.createPlan(modules)
local results = TestRunner.runPlan(plan)
callback(results)
end
local TestEZ = {
run = run,
Expectation = Expectation,
TestBootstrap = TestBootstrap,
TestEnum = TestEnum,
TestPlan = TestPlan,
TestPlanner = TestPlanner,
TestResults = TestResults,
TestRunner = TestRunner,
TestSession = TestSession,
Reporters = {
TextReporter = TextReporter,
TextReporterQuiet = TextReporterQuiet,
TeamCityReporter = TeamCityReporter,
},
}
return TestEZ