Module:ScribuntoUnit

From Commons Wiki
Jump to navigation Jump to search
--  <nowiki>
--------------------------------------------------------------------------------
--  ScribuntoUnit assert-style unit tester for Scribunto modules. The
--  syntax for test assertions is markedly similar to the [LuaUnit
--  framework](https://luaunit.readthedocs.io), [NodeJS assert
--  module](https://nodejs.org/api/assert.html) and the [NodeJS Chai
--  assertion API](https://www.chaijs.com/api/assert/).
--  
--  @classmod       ScribuntoUnit
--  @release        stable
--  @image          ScribuntoUnit screenshot.png
--  @author         [[wikipedia:User:Tgr|Tgr]] (Hungarian Wikipedia)
--  @author         [[wikipedia:User:Mr. Stradivarius|Mr. Stradivarius]]
--  @author         [[wikipedia:Special:PageHistory/Module:ScribuntoUnit|Other Wikipedia contributors]]
--  @author         [[User:DarthKitty|DarthKitty]]
--  @author         [[User:Dessamator|Dessamator]]
--  @attribution    [[wikipedia:Module:ScribuntoUnit|Module:ScribuntoUnit]] (Wikipedia)
--  @see            [[wikipedia:Module:ScribuntoUnit|Original module on Wikipedia]]
--------------------------------------------------------------------------------
local ScribuntoUnit = {}

-- begin dev wiki change
local inspect = require('Dev:Inspect')
-- end dev wiki change

--------------------------------------------------------------------------------
--  Configuration and all localisable strings, to make it easier to port
--  this module to another wiki.
--  @table          cfg
--------------------------------------------------------------------------------
local cfg = mw.loadData('Module:ScribuntoUnit/config')

--------------------------------------------------------------------------------
--  Collection of private debugging subroutines for unit testing.
--  @table          DebugHelper
--  @local
--------------------------------------------------------------------------------
local DebugHelper = {}

--------------------------------------------------------------------------------
--  Concatenates keys and values, ideal for displaying a template argument table.
--  @param          {table} tbl Argument table (map of argument parameter names
--                  to values).
--  @param[opt]     {string} keySeparator Glue between key & value. Default:
--                  `" = "`.
--  @param[opt]     {string} separator Glue between different key-value pairs.
--                  Default: `", "`.
--  @return         Concatenated series of key-value pairs.
--  @usage          DebugHelper.concatWithKeys({a = 1, b = 2, c = 3}, " => ", ", ") == "a => 1, b => 2, c => 3"
--  @private
--------------------------------------------------------------------------------
function DebugHelper.concatWithKeys(tbl, keySeparator, separator)
    keySeparator = keySeparator or ' = '
    separator = separator or ', '
    local concatted = ''
    local i = 1
    local first = true
    local unnamedArguments = true
    for k, v in pairs(tbl) do
        if first then
            first = false
        else
            concatted = concatted .. separator
        end
        if k == i and unnamedArguments then
            i = i + 1
            concatted = concatted .. tostring(v)
        else
            unnamedArguments = false
            concatted = concatted .. tostring(k) .. keySeparator .. tostring(v)
        end
    end
    return concatted
end

--------------------------------------------------------------------------------
--  Compares two tables recursively (non-table values are handled correctly as
--  well).
--  @function       DebugHelper.deepCompare
--  @param          {table} t1 Expected table from testcase, generally hardcoded
--                  into the test suite.
--  @param          {table} t2 Actual table from testcase, generated by code in
--                  the test module or test suite.
--  @param[opt]     {boolean} ignoreMetatable If false, the `__eq` metamethod in
--                  `t1` is used for the comparison.
--  @private
--------------------------------------------------------------------------------
function DebugHelper.deepCompare(t1, t2, ignoreMetatable)
    local type1 = type(t1)
    local type2 = type(t2)

    if type1 ~= type2 then
        return false
    end
    if type1 ~= 'table' then
        return t1 == t2
    end

    local metatable = getmetatable(t1)
    if not ignoreMetatable and metatable and metatable.__eq then
        return t1 == t2
    end

    for k1, v1 in pairs(t1) do
        local v2 = t2[k1]
        if v2 == nil or not DebugHelper.deepCompare(v1, v2) then
            return false
        end
    end
    for k2, v2 in pairs(t2) do
        if t1[k2] == nil then
            return false
        end
    end

    return true
end

--------------------------------------------------------------------------------
--  Raises an error with stack information.
--  @function       DebugHelper.raise
--  @param          {table} details A table with error details.
--  @param          {string} details.text The error message to display.
--  @param          {string} details.trace Traceback information to insert in
--                  stack data.
--  @param          {string} details.source File and line number.
--  @param[opt]     {number} level Zero-indexed traceback level. For example, if
--                  the `level` is 1, @{DebugHelper.raise} will not appear in
--                  the traceback.
--  @error          {table} A table with details for @{ScribuntoUnit:runTest} to
--                  capture and display to the user.
--  @note           In future, a metatable will be added for error handling.
--  @private
--------------------------------------------------------------------------------
function DebugHelper.raise(details, level)
    level = (level or 1) + 1
    details.trace = debug.traceback('', level)
    details.source = string.match(details.trace, '^%s*stack traceback:%s*(%S*: )')

--  setmetatable(details, {
--      __tostring: function() return details.text end
--  })

    error(details, level)
end

--  ScribuntoUnit's package class, with assertions API.

--------------------------------------------------------------------------------
--  Invocation entry point for running the tests.
--  This method can be called without a frame, in which case it will use
--  @{mw.log} for output. However, the following assertion methods require
--  template or wikitext expansions and will be skipped:
--   * @{ScribuntoUnit:assertResultEquals} - requires @{frame:preprocess}
--   * @{ScribuntoUnit:assertSameResult} - requires @{frame:preprocess}
--   * @{ScribuntoUnit:assertTemplateEquals} - requires @{frame:expandTemplate}
--  @param {string} displayMode Accepts:
--                   * `"table"` - shows result report as a wikitext
--                  table that displays which test(s) failed and the
--                  failure message.
--                   * `"log"` - shows result as a console log for
--                  debugging purposes. Skips frame-dependent assertions.
--                   * `"short"` - displays result report as a short sentence.
--  @usage          {{testunit|"scrib"}}
--  @usage          {{#invoke:ScribuntoUnit|run|displayMode = table/log}}
--------------------------------------------------------------------------------
function ScribuntoUnit:run(suite, frame)
    local testData = self:runSuite(suite, frame)
    if frame then
        return self:displayResults(testData, frame.args.displayMode or 'table')
    else
        return self:displayResults(testData, 'log')
    end
end

--------------------------------------------------------------------------------
--  Instantiates a new `ScribuntoUnit` for a test suite module.
--  The developer can call this method with a table constructor/upvalue, or they
--  can attach their methods to the returned instance's table.
--  @param[opt]     {table} o a table with test functions.
--  @return         {ScribuntoUnit} Self-executable instance of ScribuntoUnit.
--  @constructor
--  @usage          local suite = require('Module:ScribuntoUnit'):new()
--  @usage          return require('Module:ScribuntoUnit'):new(suite)
--------------------------------------------------------------------------------
function ScribuntoUnit:new(o)
    o = o or {}
    setmetatable(o, {__index = self})
    o.run = function(frame) return self:run(o, frame) end
    return o
end

--------------------------------------------------------------------------------
--  When used in a test, that test gets ignored, and the skipped count increases
--  by one.
--  @function       ScribuntoUnit:markTestSkipped
--  @param[opt]     {string} message Optional description.
--  @error          {table} ScribuntoUnit error with field `skipped` as `true`.
--------------------------------------------------------------------------------
function ScribuntoUnit:markTestSkipped(
    -- begin dev wiki change
    message
    -- end dev wiki change
)
    DebugHelper.raise({
        ScribuntoUnit = true,
        skipped = true,
        -- begin dev wiki change
        message = message,
        -- end dev wiki change
    }, 3)
end

--------------------------------------------------------------------------------
--  Assertion checking that the input evaluates as `true` as a Lua expression.
--  In Lua, anything that isn't the `false` or `nil` primitives is "truthy".
--  @function       ScribuntoUnit:assertTrue
--  @param          actual A Lua expression - potentially an upvalue or
--                  condition evaluated by the test suite.
--  @param[opt]     {string} message Optional description of the test.
--  @error[opt]     {table} ScribuntoUnit error with assertion failure, if the
--                  `expression` value is falsy.
-- @usage           suite:assertTrue(2 + 2 == 4)
-- @usage           suite:assertTrue('foo')
--------------------------------------------------------------------------------
function ScribuntoUnit:assertTrue(actual, message)
    if not actual then
        DebugHelper.raise({ScribuntoUnit = true, text = string.format("Failed to assert that %s is true", tostring(actual)), message = message}, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion checking that the input evaluates as `false` as a Lua expression.
--  In Lua, only the `false` and `nil` primitives are "falsy".
--  @param          actual A Lua expression - potentially a variable or
--                  condition evaluated by the test suite.
--  @param[opt]     {string} message Optional description of the test.
--  @error[opt]     {table} ScribuntoUnit error with assertion failure, if the
--                  `expression` value is truthy.
-- @usage           suite:assertFalse(2 + 2 == 5)
-- @usage           suite:assertFalse(false)
--------------------------------------------------------------------------------
function ScribuntoUnit:assertFalse(actual, message)
    if actual then
        DebugHelper.raise({ScribuntoUnit = true, text = string.format("Failed to assert that %s is false", tostring(actual)), message = message}, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion testing for the presence of an expected pattern or string in an
--  input string.
--  If the string is not found, the error message shows the values of `pattern`
--  and `str`. If `str` is more than 70 characters long, a truncated version
--  is displayed in the error message. This method is useful for testing
--  specific behaviours in complex wikitext.
--  @param[opt]     {boolean} pattern Pattern or string to perform lookup with.
--  @param[opt]     {boolean} str Target string to perform lookup on.
--  @param[opt]     {boolean} plain Search with a plain string instead of a
--                  ustring pattern. Default: `false`.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertStringContains("foo", "foobar")
--  @usage          suite:assertStringContains("foo[", "foo[bar", true)
--------------------------------------------------------------------------------
function ScribuntoUnit:assertStringContains(pattern, str, plain, message)
    if type(pattern) ~= 'string' then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format("Pattern type error (expected string, got %s)", type(pattern)),
            message = message
        }, 2)
    end
    if type(str) ~= 'string' then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format("String type error (expected string, got %s)", type(str)),
            message = message
        }, 2)
    end
    if not mw.ustring.find(str, pattern, nil, plain) then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format('Failed to find %s "%s" in string "%s"', plain and "plain string" or "pattern", pattern, str),
            message = message
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion testing for the absence of an expected pattern or string in an
--  input string.
--  If the string is not found, the error message shows the values of `pattern`
--  and `str`. This test method has similar validation and rendering behaviour
--  to @{ScribuntoUnit:assertStringContains}.
--  @param[opt]     {boolean} pattern Pattern or string to perform lookup with.
--  @param[opt]     {boolean} str Target string to perform lookup on.
--  @param[opt]     {boolean} plain Search with a plain string instead of a
--                  ustring pattern. Default: `false`.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertNotStringContains("baz", "foobar")
--  @usage          suite:assertNotStringContains("[qux", "baz]qux", true)
--------------------------------------------------------------------------------
function ScribuntoUnit:assertNotStringContains(pattern, str, plain, message)
    if type(pattern) ~= 'string' then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format("Pattern type error (expected string, got %s)", type(pattern)),
            message = message
        }, 2)
    end
    if type(str) ~= 'string' then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format("String type error (expected string, got %s)", type(str)),
            message = message
        }, 2)
    end
    local i, j = mw.ustring.find(str, pattern, nil, plain)
    if i then
        local match = mw.ustring.sub(str, i, j)
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = mw.ustring.format('Found match "%s" for %s "%s"', match, plain and "plain string" or "pattern", pattern),
            message = message
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for the strict equality of an input upvalue against an expected
--  value. This method uses `==` instead of @{rawequal}, thus supporting `__eq`
--  metamethods where applicable. Tables and functions are compared by reference
--  in the assertion (i.e. by whether they reference the same object).
--  
--  If both parameters are numbers, the values are instead compared using
--  @{ScribuntoUnit:assertWithinDelta} with delta `1e-8` (`0.00000001`) since
--  Lua numbers are represented as [[wikipedia:Floating-point arithmetic|
--  floating-point numbers]] with limited precision.
--  
--  @param          expected Target Lua upvalue for comparison, generated by the
--                  test module.
--  @param          actual Value to compare against in the comparison, provided
--                  in the test suite.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertEquals(4, calculator._add(2, 2), "2 + 2 should be 4")
--------------------------------------------------------------------------------
function ScribuntoUnit:assertEquals(expected, actual, message)

    if type(expected) == 'number' and type(actual) == 'number' then
        self:assertWithinDelta(expected, actual, 1e-8, message)

    elseif expected ~= actual then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %s equals expected %s", tostring(actual), tostring(expected)),
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end

end

--------------------------------------------------------------------------------
--  Assertion for the numerical equivalence of a input upvalue against an
--  expected numerical value, within a closed interval or "distance" (`delta`).
--  
--  Lua non-integer numbers are represented as [[wikipedia:Double-precision
--  floating-point format|double-precision floating-point numbers]] with limited
--  precision. Generally, Lua numerical comparisons involve a rounding step by
--  <math>2^{-55}</math> when one of the values is irrational. The slight error
--  between the compared numbers means that Lua does not consider them equal.
--  This necessitates a ranged comparison using a delta when handling these
--  numbers.
--  
--  @param          {number} expected Numerical value to compare against,
--                  provided in the test suite.
--  @param          {number} actual Target Lua upvalue for comparison, generated
--                  by the test module.
--  @param          {number} delta Closed interval range to permit value
--                  difference within. Should range between `1e-8` and `1e-16`.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertWithinDelta(0.1, calculator._subtract(0.3, 0.2), 1e-10)
--------------------------------------------------------------------------------
function ScribuntoUnit:assertWithinDelta(expected, actual, delta, message)
    if type(expected) ~= "number" then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Expected value %s is not a number", tostring(expected)),
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
    if type(actual) ~= "number" then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Actual value %s is not a number", tostring(actual)),
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
    local diff = expected - actual
    if diff < 0 then diff = - diff end  -- instead of importing math.abs
    if diff > delta then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %f is within %f of expected %f", actual, delta, expected),
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for the recursive equivalence of two tables. This function works
--  by computing the path to every primitive in the tables, then comparing them
--  by reference. Respects the `__eq` metamethod of the `expected` table.
--  
--  If a non-table value is provided for `expected` and `actual`, the values
--  are assumed to be primitives and compared by reference.
--  
--  @param          {table} expected Target Lua upvalue for recursive comparison,
--                  provided in the test suite.
--  @param          {table} actual Value to compare against in the recursive
--                  comparison, generated by the test module.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertDeepEquals({ { 1, 3 }, { 2, 4 } }, calculator._partition("odd", { 1, 2, 3, 4 }))
--------------------------------------------------------------------------------
function ScribuntoUnit:assertDeepEquals(expected, actual, message)
    if not DebugHelper.deepCompare(expected, actual) then
        if type(expected) == 'table' then
            -- begin dev wiki change
            expected = inspect(expected)
            -- end dev wiki change
        end
        if type(actual) == 'table' then
            -- begin dev wiki change
            actual = inspect(actual)
            -- end dev wiki change
        end
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %s equals expected %s", tostring(actual), tostring(expected)),
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for processing of unprocessed wikitext against an expected result.
--  @param          {string} expected Processed wikitext output for comparison.
--  @param          {string} text Unprocessed wikitext to compare with.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertResultEquals("4", "{{calculator|addition|2|2}}")
--------------------------------------------------------------------------------
function ScribuntoUnit:assertResultEquals(expected, text, message)
    local frame = self.frame
    -- begin dev wiki change
    if not frame then
        self:markTestSkipped()
    end
    -- end dev wiki change
    local actual = frame:preprocess(text)
    if expected ~= actual then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %s equals expected %s after preprocessing", text, tostring(expected)),
            actual = actual,
            actualRaw = text,
            expected = expected,
            message = message,
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for comparison of two unprocessed wikitext strings.
--  @param          {string} text1 Unprocessed wikitext for comparison.
--  @param          {string} text2 Unprocessed wikitext to compare with.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertSameResult("{{#expr: 2 + 2}}", "{{calculator|addition|2|2}}")
--------------------------------------------------------------------------------
function ScribuntoUnit:assertSameResult(text1, text2, message)
    local frame = self.frame
    -- begin dev wiki change
    if not frame then
        self:markTestSkipped()
    end
    -- end dev wiki change
    local processed1 = frame:preprocess(text1)
    local processed2 = frame:preprocess(text2)
    if processed1 ~= processed2 then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %s equals expected %s after preprocessing", processed1, processed2),
            actual = processed1,
            actualRaw = text1,
            expected = processed2,
            expectedRaw = text2,
            message = message,
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for a template call comparison against expected output.
--  @param          {string} expected Processed output to compare against.
--  @param          {string} template Template name, following MediaWiki
--                  transclusion syntax (e.g. `:` prefix for mainspace pages).
--  @param[opt]     {table} args Table of template arguments to pass.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertTemplateEquals("4", "calculator", {"add", "2", "2"})
function ScribuntoUnit:assertTemplateEquals(expected, template, args, message)
    local frame = self.frame
    -- begin dev wiki change
    if not frame then
        self:markTestSkipped()
    end
    -- end dev wiki change
    local actual = frame:expandTemplate{ title = template, args = args}
    if expected ~= actual then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = string.format("Failed to assert that %s with args %s equals expected %s after preprocessing",
                                 DebugHelper.concatWithKeys(args), template, expected),
            actual = actual,
            actualRaw = template,
            expected = expected,
            message = message,
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Assertion for an uncaught exception for a function call.
--  @param          {function} fn The function to be tested via a protected call
--                  with no arguments.
--  @param[opt]     {boolean} expectedMessage Expected message to compare the
--                  thrown exception against.
--  @param[opt]     {string} message Optional description of the test.
--  @usage          suite:assertThrows(error, "unknown error")
function ScribuntoUnit:assertThrows(fn, expectedMessage, message)
    local succeeded, actualMessage = pcall(fn)
    if succeeded then
        DebugHelper.raise({
            ScribuntoUnit = true,
            text = 'Expected exception but none was thrown',
            message = message,
        }, 2)
    end
    -- For strings, strip the line number added to the error message
    actualMessage = type(actualMessage) == 'string'
        and string.match(actualMessage, 'Module:[^:]*:[0-9]*: (.*)')
        or  actualMessage
    local messagesMatch = DebugHelper.deepCompare(expectedMessage, actualMessage)
    if expectedMessage and not messagesMatch then
        DebugHelper.raise({
            ScribuntoUnit = true,
            expected = expected,
            actual = actual,
            text = string.format('Expected exception with message %s, but got message %s',
                tostring(expectedMessage), tostring(actualMessage)
            ),
            message = message
        }, 2)
    end
end

--------------------------------------------------------------------------------
--  Resets global counters for the test suite execution process.
--  @param[opt]     {table} frame Frame for use in running the tests.
--  @private
--------------------------------------------------------------------------------
function ScribuntoUnit:init(frame)
    self.frame = frame
    self.successCount = 0
    self.failureCount = 0
    self.skipCount = 0
    self.results = {}
end

--------------------------------------------------------------------------------
--  Runs a single testcase (suite test method).
--  @param          {table} suite Suite table the test method is attached to.
--  @param          {string} name Test method name.
--  @param          {function} test Test method itself, containing assertions.
--  @private
--------------------------------------------------------------------------------
function ScribuntoUnit:runTest(suite, name, test)
    local success, details = pcall(test, suite)
    
    if success then
        self.successCount = self.successCount + 1
        table.insert(self.results, {name = name, success = true})
    elseif type(details) ~= 'table' or not details.ScribuntoUnit then -- a real error, not a failed assertion
        self.failureCount = self.failureCount + 1
        table.insert(self.results, {name = name, error = true, message = 'Lua error -- ' .. tostring(details)})
    elseif details.skipped then
        self.skipCount = self.skipCount + 1
        table.insert(self.results, {
            name = name,
            skipped = true,
            -- begin dev wiki change
            message = details.message,
            -- end dev wiki change
        })
    else
        self.failureCount = self.failureCount + 1
        local message = details.source
        if details.message then
            message = message .. details.message .. "\n"
        end
        message = message .. details.text
        table.insert(self.results, {name = name, error = true, message = message, expected = details.expected, actual = details.actual})
    end
end

--------------------------------------------------------------------------------
--  Test engine, running all tests and generating execution data.
--  @param          {table} suite Suite table the test methods are attached to.
--  @param[opt]     {table} frame Frame for use in running the tests.
--  @private
--------------------------------------------------------------------------------
function ScribuntoUnit:runSuite(suite, frame)
    self:init(frame)
    local names = {}
    for name in pairs(suite) do
        if name:find('^test') then
            table.insert(names, name)
        end
    end
    table.sort(names) -- Put tests in alphabetical order.
    for i, name in ipairs(names) do
        local func = suite[name]
        self:runTest(suite, name, func)
    end
    return {
        successCount = self.successCount,
        failureCount = self.failureCount,
        skipCount = self.skipCount,
        results = self.results,
    }
end

--------------------------------------------------------------------------------
-- Displays test results from test engine execution.
-- @param {table} testData Test suite execution data.
-- @param {string} displayMode See @{ScribuntoUnit:run}.
-- @private
--------------------------------------------------------------------------------
function ScribuntoUnit:displayResults(testData, displayMode)
    if displayMode == 'table' then
        return self:displayResultsAsTable(testData)
    elseif displayMode == 'log' then
        return self:displayResultsAsLog(testData)
    elseif displayMode == 'short' then
        return self:displayResultsAsShort(testData)
    else
        error('unknown display mode')
    end
end

--------------------------------------------------------------------------------
--  Generates event trace for test results.
--  @param          {table} testData Test suite execution data to render.
--  @param          {number} testData.successCount Number of successful test cases.
--  @param          {number} testData.failureCount Number of failed test cases.
--  @param          {number} testData.skipCount Number of skipped test cases.
--  @param          {table} testData.results Array of execution results.
--  @private
function ScribuntoUnit:displayResultsAsLog(testData)
    if testData.failureCount > 0 then
        mw.log('FAILURES!!!')
    elseif testData.skipCount > 0 then
        mw.log('Some tests could not be executed without a frame and have been skipped. Invoke this test suite as a template to run all tests.')
    end
    mw.log(string.format('Assertions: success: %d, error: %d, skipped: %d', testData.successCount, testData.failureCount, testData.skipCount))
    mw.log('------------------------------------------------------------------------')
    for _, result in ipairs(testData.results) do
        if result.error
            -- begin dev wiki change
            or (result.skipped and result.message)
            -- end dev wiki change
        then
            mw.log(string.format('%s: %s', result.name, result.message))
        end
    end
end

--------------------------------------------------------------------------------
--  Displays test result data as short statement.
--  @param          {table} testData Test suite execution data to render.
--  @param          {number} testData.successCount Number of successful test cases.
--  @param          {number} testData.failureCount Number of failed test cases.
--  @param          {number} testData.skipCount Number of skipped test cases.
--  @private
--------------------------------------------------------------------------------
function ScribuntoUnit:displayResultsAsShort(testData)
    local text = string.format(cfg.shortResultsFormat, testData.successCount, testData.failureCount, testData.skipCount)
    if testData.failureCount > 0 then
        text = '<span class="error">' .. text .. '</span>'
    end
    return text
end

--------------------------------------------------------------------------------
--  Displays test result data as table.
--  @param          {table} testData Test suite execution data to render.
--  @param          {number} testData.failureCount Number of failed test cases.
--  @param          {table} testData.results Array of execution results.
--  @private
--------------------------------------------------------------------------------
function ScribuntoUnit:displayResultsAsTable(testData)
    local successIcon, failIcon = self.frame:preprocess(cfg.successIndicator), self.frame:preprocess(cfg.failureIndicator)
    -- begin dev wiki change
    local skippedIcon = self.frame:preprocess(cfg.skippedIndicator)
    -- end dev wiki change
    local text = ''
    if testData.failureCount > 0 then
        local msg = mw.message.newRawMessage(cfg.failureSummary, testData.failureCount):plain()
        msg = self.frame:preprocess(msg)
        if cfg.failureCategory then
            msg = cfg.failureCategory .. msg
        end
        text = text .. failIcon .. ' ' .. msg .. '\n'
        -- begin dev wiki change
        if testData.skipCount > 0 then
            local msg = mw.message.newRawMessage(cfg.skippedSummary, testData.skipCount):plain()
            msg = self.frame:preprocess(msg)
            if cfg.skippedCategory then
                msg = cfg.skippedCategory .. msg
            end
            text = text .. skippedIcon .. ' ' .. msg .. '\n'
        end
    elseif testData.skipCount > 0 then
        local msg = mw.message.newRawMessage(cfg.skippedSummary, testData.skipCount):plain()
        msg = self.frame:preprocess(msg)
        if cfg.skippedCategory then
            msg = cfg.skippedCategory .. msg
        end
        text = text .. skippedIcon .. ' ' .. msg .. '\n'
    -- end dev wiki change
    else
        text = text .. successIcon .. ' ' .. cfg.successSummary .. '\n'
    end
    text = text .. '{| class="wikitable scribunto-test-table"\n'
    text = text .. '!\n! ' .. cfg.nameString .. '\n! ' .. cfg.expectedString .. '\n! ' .. cfg.actualString .. '\n'
    for _, result in ipairs(testData.results) do
        text = text .. '|-\n'
        if result.error then
            text = text .. '| ' .. failIcon .. '\n| ' .. result.name .. '\n| '
            if (result.expected and result.actual) then
                text = text .. mw.text.nowiki(tostring(result.expected)) .. '\n| ' .. mw.text.nowiki(tostring(result.actual)) .. '\n'
            else
                text = text .. ' colspan="2" | ' .. mw.text.nowiki(result.message) .. '\n'
            end
        -- begin dev wiki change
        elseif result.skipped then
            text = text .. '| ' .. skippedIcon .. '\n| ' .. result.name .. '\n|'
            if result.message then
                text = text .. ' colspan="2" | ' .. mw.text.nowiki(result.message) .. '\n'
            else
                text = text .. '\n|\n'
            end
        -- end dev wiki change
        else
            text = text .. '| ' .. successIcon .. '\n| ' .. result.name .. '\n|\n|\n'
        end
    end
    text = text .. '|}\n'
    -- begin dev wiki change
    return text .. '[[Category:Lua test suites]]'
    -- end dev wiki change
end

return ScribuntoUnit