モジュール:ScribuntoUnit

提供:Wikisource
モジュールの解説[表示] [編集] [履歴] [キャッシュを破棄]

このモジュールは、他のLuaモジュールのためのユニットテストを提供します。モジュールをテストする際には、通常、Module:Module name/testcasesの場所にテスト用の専用モジュールを作成します。テストはScribuntoUnitモジュールによって行われ、テストモジュールで定義された動作が期待通りの結果を出力するかを検証します。

テストモジュールの構造[編集]

テストモジュール(テストスイート)を作成するには、次のコードから始めます。

local myModule = require('Module:MyModule') -- テストするモジュール
local ScribuntoUnit = require('Module:ScribuntoUnit')
local suite = ScribuntoUnit:new()

これを行った後、各テスト関数をsuiteオブジェクトに追加することができます。testで始まる関数はテストとして認識されます。(一方、その他の関数はScribuntoUnitには無視されますが、テストの中で利用は可能です。)

function suite:testSomeCall()
    self:assertEquals('expected value', myModule.someCall(123))
    self:assertEquals('other expected value', myModule.someCall(456))
end

function suite:testSomeOtherCall()
    self:assertEquals('expected value', myModule.someOtherCall(123))
    self:assertEquals('other expected value', myModule.someOtherCall(456))
end

作成するテストにはアサーションの設定が必要で、ScribuntoUnitはそのアサーションが真かどうかを検証します。例えば、assertEqualsは与えられた二つの引数が等しいかを確認します。ScribuntoUnitがアサーションを真と判断できない場合、テストは失敗となり、エラーメッセージが表示されます。このエラーメッセージは、問題のあるアサーションを示すものです。(その時点では、他のアサーションの検証は行われません)

テストモジュールを完了するには、suiteオブジェクトを返す必要があります。

return suite

テストの実行[編集]

テストは2つの方法で実行できます。一つ目はLuaデバッグコンソールを使用する方法、もう一つは#invokeを利用してWikiページから実行する方法です。デバッグコンソールでの実行には、require('Module:MyModule/testcases').run()というコードを使います。一方、Wikiページからの実行では、{{#invoke:MyModule/testcases|run}}を使用します。これにより、結果を示す表が表示されます。もし結果をよりコンパクトな形式で表示したい場合は、{{#invoke:MyModule/testcases|run displayMode=short}}を利用します。

テスト[編集]

エラーメッセージ[編集]

全てのテストメソッドにおいて、最後のパラメータは検証失敗時に表示されるメッセージとなります。

self:assertEquals("expected value", myModule.someCall(123), "The call to myModule.someCall(123) didn't return the expected value.")

fail[編集]

self:fail(message)

無条件に検証に失敗します。

self:fail("Test failed because of X.")

assertTrue, assertFalse[編集]

self:assertTrue(expression, message)
self:assertFalse(expression, message)

これらは、指定された式がtruefalseに評価されるかを検証します。Luaでは、falsenilだけがfalseとして評価され、それ以外の値は全てtrueとして評価されることに注意してください。

self:assertTrue(2 + 2 == 4)
self:assertTrue('foo')
self:assertFalse(2 + 2 == 5)
self:assertFalse(nil)

assertStringContains[編集]

self:assertStringContains(pattern, s, plain, message)

これは、文字列s内にpatternが存在するかどうかを検証します。plainがtrueである場合、patternはリテラルテキストとして扱われます。そうでない場合は、patternustring パターンとして解釈されます。

self:assertStringContains("foo", "foobar") -- passes
self:assertStringContains("foo", "fobar") -- fails
self:assertStringContains(".oo", "foobar") -- passes: matches "foo"
self:assertStringContains(".oo", "foobar", true) -- fails: . is interpreted as a literal character

assertNotStringContains[編集]

self:assertNotStringContains(pattern, s, plain, message)

これはassertStringContainsの逆の動作をします。文字列s内にpatternが存在する場合、検証は失敗となります。plainがtrueである場合、patternはリテラルテキストとして扱われます。そうでない場合は、patternustring パターンとして解釈されます。

self:assertNotStringContains("foo", "foobar") -- fails
self:assertNotStringContains("foo", "fobar") -- passes
self:assertNotStringContains(".oo", "foobar") -- fails: matches "foo"
self:assertNotStringContains(".oo", "foobar", true) -- passes: . is interpreted as a literal character

assertEquals[編集]

self:assertEquals(expected, actual, message)

これは、最初のパラメータと2番目のパラメータが等しいかどうかを検証します。両方のパラメータが数値である場合、数値は限られた精度で浮動小数点数として表されるため、assertWithinDeltaを使用して、デルタとして1e-8(0.00000001)を指定し、値を比較します。

self:assertEquals(4, calculator.add(2, 2))

assertNotEquals[編集]

self:assertNotEquals(expected, actual, message)

これは、最初のパラメータが2番目のパラメータと等しくないかどうかをテストします。両方のパラメータが数値である場合、数値は限られた精度で浮動小数点数として表されるため、assertWithinDeltaを使用して、デルタとして1e-8(0.00000001)を指定し、値を比較します。

self:assertNotEquals(5, calculator.add(2, 2))

assertWithinDelta[編集]

self:assertWithinDelta(expected, actual, delta, message)

2つの数値を比較する際、最初の数値が2番目の数値から指定した距離(デルタ)以内にあるかどうかを検証します。これは、Luaで数値を倍精度浮動小数点数として表現する際の誤差を考慮してのことです。例えば、英語版ウィキペディアのScribtoでは、式0.3-0.2==0.1falseと評価されるのは、0.3-0.2が実際には0.09999999999999997780…0.10.10000000000000000555…と評価されるためです。この微小な誤差のため、2つの浮動小数点数を比較する際には、完全に一致するだけでなく、一定のデルタ内で近いかどうかを基準にする必要があります。なお、この問題は整数には適用されません。整数は、2^53の値までの倍精度浮動小数点数を使用して正確に表すことができます。

self:assertWithinDelta(0.1, calculator.subtract(0.3, 0.2), 1e-10)

assertNotWithinDelta[編集]

self:assertNotWithinDelta(expected, actual, delta, message)

2つの数値について、最初の数値が2番目の数値から指定された距離内に位置していないかを検証します。このテストは、assertWithinDeltaの反対の動作をします。

self:assertNotWithinDelta(0.1, calculator.subtract(0.3, 0.1), 1e-10)

assertDeepEquals[編集]

self:assertDeepEquals(expected, actual, message)

最初のパラメータと2番目のパラメータが等しいかどうかを検証します。パラメータがテーブルである場合、再帰的に比較が行われ、その際に__eq メタメソッドが考慮されます。

self:assertDeepEquals(table1, table2)

assertTemplateEquals[編集]

self:assertTemplateEquals(expected, template, args, message)

この検証は、最初のパラメータがテンプレート呼び出しと一致するかどうかを確認します。2番目のパラメータはテンプレートの名前を指定し、3番目のパラメータはテンプレートの引数をテーブル形式で指定します。

self:assertTemplateEquals(4, 'add', {2, 2}) -- true if {{add|2|2}} equals 4

XML表記で書かれたタグの中には、正しく検証できないものがあることに注意してください。以下のassertResultEquals関数の注意を参照してください。


assertResultEquals[編集]

self:assertResultEquals(expected, text, message)

これは、最初のパラメータが特定のウィキテキストの展開結果と一致するかどうかを検証します。2番目のパラメータには任意のウィキテキストを指定できます。

self:assertResultEquals(4, '{{#invoke:Calculator|add|2|2}}')

<pre><nowiki><gallery><ref>などのXML形式で記述される特定のタグは、正しく比較するのが難しい点に注意してください。これらのタグは、Luaで処理される前にストリップマーカーに変換されるためです。ストリップマーカーは、同じ入力からでも一意の値が生成されるので、これらのタグの等価性を検証するテストは失敗します。この特性は、assertTemplateEquals関数とassertSameResult関数にも影響します。

assertSameResult[編集]

self:assertSameResult(text1, text2, message)

これは、指定されたwikitextの文字列が展開された結果が、別のwikitextの展開結果と一致するかどうかを検証します。これは、モジュールが置き換える予定のテンプレートが期待通りに動作しているかを確認する際に役立ちます

self:assertSameResult('{{add|2|2}}', '{{#invoke:Calculator|add|2|2}}')

XML表記で書かれたタグの中には、正しく検証できないものがあることに注意してください。上記のassertResultEquals関数の注意点を参照してください。

assertThrows[編集]

self:assertThrows(fn, expectedMessage, message)

これは、指定された関数が例外を投げるかどうかを検証します。expectedMessagenilでない場合、そのエラーメッセージが例外として投げられたかどうかもチェックされます。

関連項目[編集]

-------------------------------------------------------------------------------
-- Unit tests for Scribunto.
-------------------------------------------------------------------------------
require('strict')

local DebugHelper = {}
local ScribuntoUnit = {}

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

-------------------------------------------------------------------------------
-- Concatenates keys and values, ideal for displaying a template or parser function argument table.
-- @param keySeparator glue between key and value (defaults to " = ")
-- @param separator glue between different key-value pairs (defaults to ", ")
-- @example concatWithKeys({a = 1, b = 2, c = 3}, ' => ', ', ') => "a => 1, b => 2, c => 3"
-- 
function DebugHelper.concatWithKeys(table, keySeparator, separator)
    keySeparator = keySeparator or ' = '
    separator = separator or ', '
    local concatted = ''
    local i = 1
    local first = true
    local unnamedArguments = true
    for k, v in pairs(table) 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).
-- @param ignoreMetatable if false, t1.__eq is used for the comparison
-- 
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
-- @param details a table with error details
--        - should have a 'text' key which is the error message to display
--        - a 'trace' key will be added with the stack data
--        - and a 'source' key with file/line number
--        - a metatable will be added for error handling
-- 
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

-------------------------------------------------------------------------------
-- when used in a test, that test gets ignored, and the skipped count increases by one.
-- 
function ScribuntoUnit:markTestSkipped()
    DebugHelper.raise({ScribuntoUnit = true, skipped = true}, 3)
end

-------------------------------------------------------------------------------
-- Unconditionally fail a test
-- @param message optional description of the test
-- 
function ScribuntoUnit:fail(message)
    DebugHelper.raise({ScribuntoUnit = true, text = "Test failed", message = message}, 2)
end

-------------------------------------------------------------------------------
-- Checks that the input is true
-- @param message optional description of the test
-- 
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

-------------------------------------------------------------------------------
-- Checks that the input is false
-- @param message optional description of the test
-- 
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

-------------------------------------------------------------------------------
-- Checks an input string contains the expected string
-- @param message optional description of the test
-- @param plain search is made with a plain string instead of a ustring pattern
-- 
function ScribuntoUnit:assertStringContains(pattern, s, 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(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("String type error (expected string, got %s)", type(s)),
			message = message
		}, 2)
	end
	if not mw.ustring.find(s, 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, s),
			message = message
		}, 2)
	end
end

-------------------------------------------------------------------------------
-- Checks an input string doesn't contain the expected string
-- @param message optional description of the test
-- @param plain search is made with a plain string instead of a ustring pattern
-- 
function ScribuntoUnit:assertNotStringContains(pattern, s, 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(s) ~= 'string' then
		DebugHelper.raise({
			ScribuntoUnit = true,
			text = mw.ustring.format("String type error (expected string, got %s)", type(s)),
			message = message
		}, 2)
	end
	local i, j = mw.ustring.find(s, pattern, nil, plain)
	if i then
		local match = mw.ustring.sub(s, 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

-------------------------------------------------------------------------------
-- Checks that an input has the expected value.
-- @param message optional description of the test
-- @example assertEquals(4, 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

-------------------------------------------------------------------------------
-- Checks that an input does not have the expected value.
-- @param message optional description of the test
-- @example assertNotEquals(5, add(2,2), "2+2 should not be 5")
-- 
function ScribuntoUnit:assertNotEquals(expected, actual, message)
	if type(expected) == 'number' and type(actual) == 'number' then
        self:assertNotWithinDelta(expected, actual, 1e-8, message)
	elseif expected == actual then
        DebugHelper.raise({
            ScribuntoUnit = true, 
            text = string.format("Failed to assert that %s does not equal expected %s", tostring(actual), tostring(expected)), 
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
end

-------------------------------------------------------------------------------
-- Validates that both the expected and actual values are numbers
-- @param message optional description of the test
-- 
local function validateNumbers(expected, actual, 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,
        }, 3)
    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,
        }, 3)
    end
end

-------------------------------------------------------------------------------
-- Checks that 'actual' is within 'delta' of 'expected'.
-- @param message optional description of the test
-- @example assertWithinDelta(1/3, 3/9, 0.000001, "3/9 should be 1/3")
function ScribuntoUnit:assertWithinDelta(expected, actual, delta, message)
    validateNumbers(expected, actual, message)
    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

-------------------------------------------------------------------------------
-- Checks that 'actual' is not within 'delta' of 'expected'.
-- @param message optional description of the test
-- @example assertNotWithinDelta(1/3, 2/3, 0.000001, "1/3 should not be 2/3")
function ScribuntoUnit:assertNotWithinDelta(expected, actual, delta, message)
    validateNumbers(expected, actual, message)
    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 not within %f of expected %f", actual, delta, expected), 
            actual = actual,
            expected = expected,
            message = message,
        }, 2)
    end
end

-------------------------------------------------------------------------------
-- Checks that a table has the expected value (including sub-tables).
-- @param message optional description of the test
-- @example assertDeepEquals({{1,3}, {2,4}}, partition(odd, {1,2,3,4}))
function ScribuntoUnit:assertDeepEquals(expected, actual, message)
    if not DebugHelper.deepCompare(expected, actual) then
        if type(expected) == 'table' then
            expected = mw.dumpObject(expected)
        end
        if type(actual) == 'table' then
            actual = mw.dumpObject(actual)
        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

-------------------------------------------------------------------------------
-- Checks that a wikitext gives the expected result after processing.
-- @param message optional description of the test
-- @example assertResultEquals("Hello world", "{{concat|Hello|world}}")
function ScribuntoUnit:assertResultEquals(expected, text, message)
    local frame = self.frame
    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

-------------------------------------------------------------------------------
-- Checks that two wikitexts give the same result after processing.
-- @param message optional description of the test
-- @example assertSameResult("{{concat|Hello|world}}", "{{deleteLastChar|Hello world!}}")
function ScribuntoUnit:assertSameResult(text1, text2, message)
    local frame = self.frame
    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

-------------------------------------------------------------------------------
-- Checks that a parser function gives the expected output.
-- @param message optional description of the test
-- @example assertParserFunctionEquals("Hello world", "msg:concat", {"Hello", " world"})
function ScribuntoUnit:assertParserFunctionEquals(expected, pfname, args, message)
    local frame = self.frame
    local actual = frame:callParserFunction{ name = pfname, 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), pfname, expected),
            actual = actual,
            actualRaw = pfname,
            expected = expected,
            message = message,
        }, 2)
    end
end

-------------------------------------------------------------------------------
-- Checks that a template gives the expected output.
-- @param message optional description of the test
-- @example assertTemplateEquals("Hello world", "concat", {"Hello", " world"})
function ScribuntoUnit:assertTemplateEquals(expected, template, args, message)
    local frame = self.frame
    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

-------------------------------------------------------------------------------
-- Checks whether a function throws an error
-- @param fn the function to test
-- @param expectedMessage optional the expected error message
-- @param message optional description of the test
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 = expectedMessage,
            actual = actualMessage,
            text = string.format('Expected exception with message %s, but got message %s', 
                tostring(expectedMessage), tostring(actualMessage)
            ),
            message = message
        }, 2)
    end
end

-------------------------------------------------------------------------------
-- Checks whether a function doesn't throw an error
-- @param fn the function to test
-- @param message optional description of the test
function ScribuntoUnit:assertDoesNotThrow(fn, message)
	local succeeded, actualMessage = pcall(fn)
	if succeeded then
	    return
	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
	DebugHelper.raise({
		ScribuntoUnit = true,
		actual = actualMessage,
		text = string.format('Expected no exception, but got exception with message %s',
			tostring(actualMessage)
		),
		message = message
	}, 2)
end

-------------------------------------------------------------------------------
-- Creates a new test suite.
-- @param o a table with test functions (alternatively, the functions can be added later to the returned suite)
-- 
function ScribuntoUnit:new(o)
    o = o or {}
    o._tests = {}
    setmetatable(o, {
    	__index = self,
    	__newindex = function (t, k, v)
    		if type(k) == "string" and k:find('^test') and type(v) == "function" then
    			-- Store test functions in the order they were defined
    			table.insert(o._tests, {name = k, test = v})
    		else
    			rawset(t, k, v)
    		end
    	end
    })
    o.run = function(frame) return self:run(o, frame) end
    return o
end

-------------------------------------------------------------------------------
-- Resets global counters
-- 
function ScribuntoUnit:init(frame)
    self.frame = frame or mw.getCurrentFrame()
    self.successCount = 0
    self.failureCount = 0
    self.skipCount = 0
    self.results = {}
end

-------------------------------------------------------------------------------
-- Runs a single testcase
-- @param name test nume
-- @param test function containing assertions
-- 
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})
    else
        self.failureCount = self.failureCount + 1
        local message = details.source or ""
        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, testname = details.message})
    end
end

-------------------------------------------------------------------------------
-- Runs all tests and displays the results.
-- 
function ScribuntoUnit:runSuite(suite, frame)
    self:init(frame)
	for i, testDetails in ipairs(suite._tests) do
		self:runTest(suite, testDetails.name, testDetails.test)
	end
    return {
        successCount = self.successCount,
        failureCount = self.failureCount,
        skipCount = self.skipCount,
        results = self.results,
    }
end

-------------------------------------------------------------------------------
-- #invoke entry point for running the tests.
-- Can be called without a frame, in which case it will use mw.log for output
-- @param displayMode see displayResults()
-- 
function ScribuntoUnit:run(suite, frame)
    local testData = self:runSuite(suite, frame)
    if frame and frame.args then
        return self:displayResults(testData, frame.args.displayMode or 'table')
    else
        return self:displayResults(testData, 'log')
    end
end

-------------------------------------------------------------------------------
-- Displays test results 
-- @param displayMode: 'table', 'log' or 'short'
-- 
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

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 then
            mw.log(string.format('%s: %s', result.name, result.message))
        end
    end
end

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

function ScribuntoUnit:displayResultsAsTable(testData)
    local successIcon, failIcon = self.frame:preprocess(cfg.successIndicator), self.frame:preprocess(cfg.failureIndicator)
    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'
	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| '
            if (result.expected and result.actual) then
            	local name = result.name
            	if result.testname then
            		name = name .. ' / ' .. result.testname
            	end
                text = text .. mw.text.nowiki(name) .. '\n| ' .. mw.text.nowiki(tostring(result.expected)) .. '\n| ' .. mw.text.nowiki(tostring(result.actual)) .. '\n'
            else
                text = text .. mw.text.nowiki(result.name) .. '\n| ' .. ' colspan="2" | ' .. mw.text.nowiki(result.message) .. '\n'
            end
        else
            text = text .. '| ' .. successIcon .. '\n| ' .. mw.text.nowiki(result.name) .. '\n|\n|\n'
        end
    end
    text = text .. '|}\n'
    return text
end

return ScribuntoUnit