Modulo:ScribuntoUnit

Da Wikipedia, l'enciclopedia libera.
Jump to navigation Jump to search

Questo modulo fornisce le funzionalità di unit testing per altri moduli Lua. Per testae un modulo si deve creare un'unità di test separata di solito nella voce Modulo:Nome modulo/test. Il modulo viene testato usando il modulo ScribuntoUnit che verifica che le operazioni definite nel modulo di test producano i risultati attesi.

Struttura del modulo di test

Per preparare un modulo di test (test suite), iniziate con il codice seguente:

local myModule = require('Module:MyModule') -- the module to be tested
local ScribuntoUnit = require('Module:ScribuntoUnit')
local suite = ScribuntoUnit:new()

Potete quindi aggiungere funzioni di test individuali all'oggetto suite object. Ogni funzine che inizia con test è trattata come un test. (Altre funzioni saranno ingorate da ScribuntoUnit, ma possono essere nei test.)

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

I test che scrivete dovrebbero fare asserzioni che ScribuntoUnit controllerà verificare che siano vere. Per esempio, assertEquals controlla che entrambi gli argomenti che gli vengono passati sono uguali. Se ScribuntoUnit trova un'asserzione falsa il test fallirà generando un messaggio di errore. Il messaggio di errore indica qual è l'asserzione fallita (al momento non vengono effettuati altri controlli).

Il modulo di test deve terminare ritornando l'oggetto suite.

return suite

Eseguire i test

I test possono essere eseguiti in due modi: attraverso la console di debug Lua e mediante chiamata da una pagina wiki usando #invoke. Se eseguite i test mediante la console di debg usate il codice require('Module:MyModule/tests').run(). Se l'eseguite da una pagina wiki usate il codice {{#invoke:MyModule/tests|run}}. Questo genererà una tabella contenente i risultati. È anche possibile visualizzare una tabella più conpatta usando il codice {{#invoke:MyModule/tests|run|displayMode=short}}.

Test

Messaggi di errore

L'ultimo parametro di tutti i metodi di test è un messaggio che viene mostrato se la validazione fallisce.

self:assertEquals('expected value', myModule.someCall(123), 'This tests whether the function x does y.')

assertTrue, assertFalse

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

Questi controllano se il risultato dell'asserzione è vero o falso. Nel caso di un un controllo di falsità notate che in Lua i valori false e nil sono falsi, mentre tutto il resto viene valutato vero. Per esempio il numero 0 o una lista vuota sono "veri" in Lua.

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

assertStringContains

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

Questi test controllano se il pattern viene trovato nella stringa s. Se plain è vero allora il pattern viene interpretato come testo letterale, altrimenti viene interpretato come pattern funzioni di ustring.

Se il pattern non viene trovato il messaggio di errore indica il valore del pattern e della stringa s; Se la stringa s è più lunga di 70 caratteri viene visualizzata una versione troncata. Questo metodo è utile per testare specifici comportamenti di testi wiki complessi.

self:assertStringContains("foo", "foobar") -- passa
self:assertStringContains("foo", "fobar") -- fallisce
self:assertStringContains(".oo", "foobar") -- passa: corrisponde a "foo"
self:assertStringContains(".oo", "foobar", true) -- fallisce: . viene interpretato come un carattere letterale

assertNotStringContains

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

Questa è l'opposto di assertStringContains. Il test fallirà se pattern viene trovato nella stringa s. Se plain è vero allora pattern viene interpretato come stringa letterale, altrimenti viene interpretato come pattern funzioni di ustring.

self:assertNotStringContains("foo", "foobar") -- fallisce
self:assertNotStringContains("foo", "fobar") -- passa
self:assertNotStringContains(".oo", "foobar") -- fallisce: corrisponde a "foo"
self:assertNotStringContains(".oo", "foobar", true) -- passa: . viene interpretato come un carattere letterale

assertEquals

self:assertEquals(expected, actual, message)

Questo verifica se il primo parametro è uguale al secondo parametro. Se entrambi sono numeri, vengono confrontati con la funzione assertWithinDelta con delta 1e-8 (0.00000001) essendo i floating point a precisione limitata.

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

assertWithinDelta

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

Per due numeri, il test verifica se i valori hanno una precisa distanza (delta) tra loro. Questo è utile per numeri floating point, che sono quelli usati nell'installazione std di Lua (per essere precisi, usa numeri double). Per esempio, nella versione di Scribunto installata su Wikipedia inglese, l'espressione 0.3 – 0.2 == 0.1 viene valutata false. Questo per il fatto che 0.3 – 0.2 è uguale a 0.09999999999999997780… e il numero 0.1 è uguale a 0.10000000000000000555…. Il piccolo errore tra i due vuole dire che Lua non li considera uquali. Quindi, il test per l'uguaglianza tra due numeri floating point, è vero all'interno di una distanza (delta) piccola tra loro, non proprio valori uguali. Questo problema non influenza i numeri interi, che sono rappresentati usando double fino a 2^53.

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

assertDeepEquals

self:assertDeepEquals(expected, actual, message)

Testa quando il primo parametro è uguale al secondo. Se i parametri sono tabelle, vengono confrontati con ricorsione, e i loro __eq metametodi sono riconosciuti.

self:assertDeepEquals(table1, table2)

assertTemplateEquals

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

Verifica se il primo parametro è uguale a quello di una chiamata di template. Il secondo parametro è il nome del template, il terzo è una tabella contenente gli argomenti del template.

self:assertTemplateEquals(4, 'add', {2, 2}) -- vero se {{add|2|2}} è uguale a 4

Notate che alcuni tag in notazione XML non si possono testare correttamente; vedere la nota della funzione successiva assertResultEquals.

assertResultEquals

self:assertResultEquals(expected, text, message)

Questo test verifica se il primo parametro è uguale all'espansione di un testo wiki. Il secondo parametro può essere un qualunque testo wiki.

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

Notate che alcuni tag speciali scritti in notazione XML, come per esempio <pre>, <nowiki>, <gallery> e <ref> non possono essere confrontati direttamente. Questi tag sono convertiti in strip marker prima di essere processati dal Lua. Poichè gli strip marker sono univoci anche se generati da input identici questi test di ugualianza falliranno. Questo discorso si applica anche alle funzioni assertTemplateEquals e assertSameResult.

assertSameResult

self:assertSameResult(text1, text2, message)

Questo verifica se l'espansione di una data stringa di testo wiki, è uguale all'espansione di un'altra stringa di testo wiki. Questo può essere utile per verificare che un modulo si comporti nella stessa maniera di un template che è destinato a rimpiazzare.

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

Notate che alcuni tags scritti in notazione XML non possono essere testati correttamente, vedi la nota per la funzione assertResultEquals più sopra.

Vedi anche


-------------------------------------------------------------------------------
-- Unit tests for Scribunto.
-------------------------------------------------------------------------------
local DebugHelper = {}
local ScribuntoUnit = {}

-------------------------------------------------------------------------------
-- Concatenates keys and values, ideal for displaying a template 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 = mw.text.split(details.trace, '%s')[5]
    -- this would be more robust but does not work
    -- local match = string.match(details.trace, '^%s*stack traceback:%s*(%S*): ')
    -- details.source = match and match[1] or ''

--    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

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

-------------------------------------------------------------------------------
-- 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 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(template, 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

-------------------------------------------------------------------------------
-- 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 {}
    setmetatable(o, {__index = self})
    o.run = function(frame) return self:run(o, frame) end
    return o
end

-------------------------------------------------------------------------------
-- Resets global counters
-- 
function ScribuntoUnit:init(frame)
    self.frame = frame
    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
        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

-------------------------------------------------------------------------------
-- Runs all tests and displays the results.
-- 
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

-------------------------------------------------------------------------------
-- #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 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

-- TODO l10n

function ScribuntoUnit:displayResultsAsShort(testData)
    local text = string.format('success: %d, error: %d, skipped: %d', 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('{{SI}}'), self.frame:preprocess('{{NO}}')
    local text = ''
	if testData.failureCount > 0 then
		local msg = "'''$1 {{PLURAL:$1|test|tests}} failed'''."
		msg = mw.message.newRawMessage(msg, testData.failureCount):plain()
		msg = self.frame:preprocess(msg)
		text = text .. failIcon .. ' ' .. msg .. '\n'
	else
		local msg = "'''All tests passed'''."
		text = text .. successIcon .. ' ' .. msg .. '\n'
	end
    text = text .. '{| class="wikitable scribunto-test-table"\n'
    text = text .. '!\n! Name\n! Expected\n! Actual\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
        else
            text = text .. '| ' .. successIcon .. '\n| ' .. result.name .. '\n|\n|\n'
        end
    end
    text = text .. '|}\n'
    return text
end

return ScribuntoUnit