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,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 succeed, but it threw an error: %s"):format(
tostring(err)
),
"Expected function to throw an error, but it did not."
)
assertLevel(result, message, 3)
self:_resetModifiers()
return self
end
return Expectation
@@ -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,100 @@
--[[
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 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)
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 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,38 @@
local Stack = {}
Stack.__index = Stack
function Stack.new()
local self = {}
setmetatable(self, Stack)
self.data = {}
return self
end
function Stack:size()
return #self.data
end
function Stack:push(obj)
self.data[self:size()+1] = obj
return self
end
function Stack:pop()
local result = self:getBack()
table.remove(self.data,self:size())
return result
end
function Stack:getBack()
if self:size() == 0 then error("stack is empty") end
local result = self.data[self:size()]
return result
end
function Stack:setBack(obj)
if self:size() == 0 then error("stack is empty") end
self.data[self:size()] = obj
return self
end
return Stack
@@ -0,0 +1,120 @@
--[[
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
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
--[[
Find all the ModuleScripts in this tree that are tests.
]]
function TestBootstrap:getModules(root, modules, current)
modules = modules or {}
current = current or root
if isSpecScript(current) then
local method = require(current)
local path = getPath(current, root)
table.insert(modules, {
method = method,
path = path
})
end
for _, child in ipairs(current:GetChildren()) do
self:getModules(root, modules, child)
end
table.sort(modules, function(a, b)
return a.path[#a.path]:lower() < b.path[#b.path]:lower()
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 noXpcallByDefault = otherOptions["noXpcallByDefault"] or false
local testNamePattern = otherOptions["testNamePattern"]
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
modules = self:getModules(subRoot, modules)
end
local afterModules = tick()
local plan = TestPlanner.createPlan(modules, noXpcallByDefault, testNamePattern)
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,25 @@
--[[
Constants used throughout the testing framework.
]]
local TestEnum = {}
TestEnum.TestStatus = {
Success = "Success",
Failure = "Failure",
Skipped = "Skipped"
}
TestEnum.NodeType = {
Try = "Try",
Describe = "Describe",
It = "It"
}
TestEnum.NodeModifier = {
None = "None",
Skip = "Skip",
Focus = "Focus"
}
return TestEnum
@@ -0,0 +1,113 @@
--[[
Represents a tree of tests that have been loaded but not necessarily
executed yet.
TestPlan objects are produced by TestPlanner and TestPlanBuilder.
]]
local TestEnum = require(script.Parent.TestEnum)
local TestPlan = {}
TestPlan.__index = TestPlan
--[[
Create a new, empty TestPlan.
]]
function TestPlan.new()
local self = {
children = {}
}
setmetatable(self, TestPlan)
return self
end
--[[
Calls the given callback on all nodes in the tree, traversed depth-first.
]]
function TestPlan:visitAllNodes(callback, root)
root = root or self
for _, child in ipairs(root.children) do
callback(child)
self:visitAllNodes(callback, child)
end
end
local function constructNodeFullName(node)
if node.parent then
local parentPhrase = constructNodeFullName(node.parent)
if parentPhrase then
return parentPhrase .. " " .. node.phrase
end
end
return node.phrase
end
--[[
Creates a new node that would be suitable to insert into the TestPlan.
]]
function TestPlan.createNode(phrase, nodeType, nodeModifier)
nodeModifier = nodeModifier or TestEnum.NodeModifier.None
local node = {
phrase = phrase,
type = nodeType,
modifier = nodeModifier,
children = {},
callback = nil,
getFullName = constructNodeFullName
}
return node
end
--[[
Visualizes the test plan in a simple format, suitable for debugging the test
plan's structure.
]]
function TestPlan:visualize(root, level)
root = root or self
level = level or 0
local buffer = {}
for _, child in ipairs(root.children) do
if child.type == TestEnum.NodeType.It then
table.insert(buffer, (" "):rep(3 * level) .. child.phrase)
else
table.insert(buffer, (" "):rep(3 * level) .. child.phrase)
end
if #child.children > 0 then
local text = self:visualize(child, level + 1)
table.insert(buffer, text)
end
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, results, node)
node = node or self
results = results or {}
for _, childNode in ipairs(node.children) do
if callback(childNode) then
table.insert(results, childNode)
end
self:findNodes(callback, results, childNode)
end
return results
end
return TestPlan
@@ -0,0 +1,98 @@
--[[
Represents the ephermal state used for building a TestPlan from some other
representation.
TestPlanBuilder keeps track of a stack of nodes that represents the current
position in the hierarchy, allowing the consumer to move up and down the
tree as new nodes are discovered.
]]
local TestPlan = require(script.Parent.TestPlan)
local TestEnum = require(script.Parent.TestEnum)
local TestPlanBuilder = {}
TestPlanBuilder.__index = TestPlanBuilder
--[[
Create a new TestPlanBuilder, used for creating a TestPlan.
]]
function TestPlanBuilder.new()
local self = {
plan = TestPlan.new(),
nodeStack = {},
noXpcallByDefault = false,
testNamePattern = nil,
}
setmetatable(self, TestPlanBuilder)
return self
end
--[[
Verify that the TestPlanBuilder's state is valid and get a TestPlan from it.
]]
function TestPlanBuilder:finalize()
if #self.nodeStack ~= 0 then
error("Cannot finalize a TestPlan with nodes still on the stack!", 2)
end
return self.plan
end
--[[
Grab the current node being worked on by the TestPlanBuilder.
]]
function TestPlanBuilder:getCurrentNode()
return self.nodeStack[#self.nodeStack] or self.plan
end
--[[
Creates and pushes a node onto the navigation stack.
]]
function TestPlanBuilder:pushNode(phrase, nodeType, nodeModifier)
local lastNode = self.nodeStack[#self.nodeStack] or self.plan
-- Find an existing node with this phrase to use
local useNode
for _, child in ipairs(lastNode.children) do
if child.phrase == phrase then
useNode = child
break
end
end
-- Didn't find one, create a new node
if not useNode then
useNode = TestPlan.createNode(phrase, nodeType, nodeModifier)
useNode.parent = lastNode
table.insert(lastNode.children, useNode)
end
table.insert(self.nodeStack, useNode)
local nodeModifierNotSet = useNode.modifier == nil or useNode.modifier == TestEnum.NodeModifier.None
if self.testNamePattern and nodeModifierNotSet then
local fullName = useNode:getFullName()
if fullName:match(self.testNamePattern) then
useNode.modifier = TestEnum.NodeModifier.Focus
else
useNode.modifier = TestEnum.NodeModifier.Skip
end
end
useNode.HACK_NO_XPCALL = self.noXpcallByDefault
return useNode
end
--[[
Pops a node off of the node navigation stack.
]]
function TestPlanBuilder:popNode()
assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!")
return table.remove(self.nodeStack, #self.nodeStack)
end
return TestPlanBuilder
@@ -0,0 +1,198 @@
--[[
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 TestEnum = require(script.Parent.TestEnum)
local TestPlanBuilder = require(script.Parent.TestPlanBuilder)
local TestPlanner = {}
local function buildPlan(builder, module, env)
local currentEnv = getfenv(module.method)
for key, value in pairs(env) do
currentEnv[key] = value
end
local nodeCount = #module.path
-- Dive into auto-named nodes for this module
for i = nodeCount, 1, -1 do
local name = module.path[i]
builder:pushNode(name, TestEnum.NodeType.Describe)
end
local ok, err = xpcall(module.method, function(err)
return err .. "\n" .. debug.traceback()
end)
-- This is an error outside of any describe/it blocks.
-- We attach it to the node we generate automatically per-file.
if not ok then
local node = builder:getCurrentNode()
node.loadError = err
end
-- Back out of auto-named nodes
for _ = 1, nodeCount do
builder:popNode()
end
end
--[[
Create a new environment with functions for defining the test plan structure
using the given TestPlanBuilder.
These functions illustrate the advantage of the stack-style tree navigation
as state doesn't need to be passed around between functions or explicitly
global.
]]
function TestPlanner.createEnvironment(builder)
local env = {}
function env.describeFOCUS(phrase, callback)
return env.describe(phrase, callback, TestEnum.NodeModifier.Focus)
end
function env.describeSKIP(phrase, callback)
return env.describe(phrase, callback, TestEnum.NodeModifier.Skip)
end
function env.describe(phrase, callback, nodeModifier)
local node = builder:pushNode(phrase, TestEnum.NodeType.Describe, nodeModifier)
local ok, err = pcall(callback)
-- loadError on a TestPlan node is an automatic failure
if not ok then
node.loadError = err
end
builder:popNode()
end
function env.try(phrase, callback)
local node = builder:pushNode(phrase, TestEnum.NodeType.Try)
local ok, err = pcall(callback)
-- loadError on a TestPlan node is an automatic failure
if not ok then
node.loadError = err
end
builder:popNode()
end
function env.it(phrase, callback)
local node = builder:pushNode(phrase, TestEnum.NodeType.It)
node.callback = callback
builder:popNode()
end
function env.itFOCUS(phrase, callback)
local node = builder:pushNode(phrase, TestEnum.NodeType.It, TestEnum.NodeModifier.Focus)
node.callback = callback
builder:popNode()
end
function env.itSKIP(phrase, callback)
local node = builder:pushNode(phrase, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip)
node.callback = callback
builder:popNode()
end
function env.itFIXME(phrase, callback)
local node = builder:pushNode(phrase, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip)
warn("FIXME: broken test", node:getFullName())
node.callback = callback
builder:popNode()
end
function env.FIXME(optionalMessage)
local currentNode = builder:getCurrentNode()
warn("FIXME: broken test", currentNode:getFullName(), optionalMessage or "")
currentNode.modifier = TestEnum.NodeModifier.Skip
end
function env.FOCUS()
local currentNode = builder:getCurrentNode()
currentNode.modifier = TestEnum.NodeModifier.Focus
end
function env.SKIP()
local currentNode = builder:getCurrentNode()
currentNode.modifier = TestEnum.NodeModifier.Skip
end
--[[
These method is intended to disable the use of xpcall when running
nodes contained in the same node that this function is called in.
This is because xpcall breaks badly if the method passed yields.
This function is intended to be hideous and seldom called.
Once xpcall is able to yield, this function is obsolete.
]]
function env.HACK_NO_XPCALL()
local currentNode = builder:getCurrentNode()
currentNode.HACK_NO_XPCALL = true
end
env.step = env.it
env.fit = env.itFOCUS
env.xit = env.itSKIP
env.fdescribe = env.describeFOCUS
env.xdescribe = env.describeSKIP
function env.include(...)
local args = {...}
local method, path
if #args == 1 then
method = args[1]
path = {}
elseif #args == 2 then
method = args[2]
path = {args[1]}
end
buildPlan(builder, {path = path, method = method}, env)
end
return env
end
--[[
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.
]]
function TestPlanner.createPlan(specFunctions, noXpcallByDefault, testNamePattern)
local builder = TestPlanBuilder.new()
builder.noXpcallByDefault = noXpcallByDefault
builder.testNamePattern = testNamePattern
local env = TestPlanner.createEnvironment(builder)
for _, module in ipairs(specFunctions) do
buildPlan(builder, module, env)
end
return builder:finalize()
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 > 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
)
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,152 @@
--[[
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 Stack = require(script.Parent.Stack)
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 tryStack = Stack.new()
local exclusiveNodes = plan:findNodes(function(node)
return node.modifier == TestEnum.NodeModifier.Focus
end)
session.hasFocusNodes = #exclusiveNodes > 0
TestRunner.runPlanNode(session, plan, tryStack)
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, tryStack, noXpcall)
for _, childPlanNode in ipairs(planNode.children) do
local childResultNode = session:pushNode(childPlanNode)
if childPlanNode.type == TestEnum.NodeType.It then
if session:shouldSkip() then
childResultNode.status = TestEnum.TestStatus.Skipped
else
if tryStack:size() > 0 and tryStack:getBack().isOk == false then
childResultNode.status = TestEnum.TestStatus.Failure
table.insert(childResultNode.errors,
string.format("%q failed without trying, because test case %q failed",
childPlanNode.phrase, tryStack:getBack().failedNode.phrase))
else
-- Errors can be set either via `error` propagating upwards or
-- by a test calling fail([message]).
local success = true
local errorMessage
local testEnvironment = getfenv(childPlanNode.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 = message .. "\n" .. debug.traceback()
end
-- We prefer xpcall, but yielding doesn't work from xpcall.
-- As a workaround, you can mark nodes as "not xpcallable"
local call = noXpcall and pcall or xpcall
-- 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
local nodeSuccess, nodeResult = call(childPlanNode.callback, function(message)
return message .. "\n" .. debug.traceback()
end)
_G[RUNNING_GLOBAL] = nil
-- 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
if success then
childResultNode.status = TestEnum.TestStatus.Success
else
childResultNode.status = TestEnum.TestStatus.Failure
table.insert(childResultNode.errors, errorMessage)
end
end
end
elseif childPlanNode.type == TestEnum.NodeType.Describe or childPlanNode.type == TestEnum.NodeType.Try then
if childPlanNode.type == TestEnum.NodeType.Try then tryStack:push({isOk = true, failedNode = nil}) end
TestRunner.runPlanNode(session, childPlanNode, tryStack, childPlanNode.HACK_NO_XPCALL)
if childPlanNode.type == TestEnum.NodeType.Try then tryStack:pop() end
local status = TestEnum.TestStatus.Success
-- Did we have an error trying build a test plan?
if childPlanNode.loadError then
status = TestEnum.TestStatus.Failure
local message = "Error during planning: " .. childPlanNode.loadError
table.insert(childResultNode.errors, message)
else
local skipped = true
-- If all children were skipped, then we were skipped
-- If any child failed, then we failed!
for _, child in ipairs(childResultNode.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
end
childResultNode.status = status
end
session:popNode()
end
end
return TestRunner
@@ -0,0 +1,149 @@
--[[
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 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 = {},
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
table.insert(lastNode.children, node)
table.insert(self.nodeStack, node)
return node
end
--[[
Pops a node off of the navigation stack.
]]
function TestSession:popNode()
assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!")
return table.remove(self.nodeStack, #self.nodeStack)
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
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 TestPlanBuilder = require(script.TestPlanBuilder)
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 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,
TestPlanBuilder = TestPlanBuilder,
TestPlanner = TestPlanner,
TestResults = TestResults,
TestRunner = TestRunner,
TestSession = TestSession,
Reporters = {
TextReporter = TextReporter,
TeamCityReporter = TeamCityReporter,
},
}
return TestEZ