Modul:ScribuntoUnit

ScribuntoUnit[mi ez?] • [dokumentáció: mutat, szerkeszt] • [tesztek: létrehozás]

Ez a modul más modulok egységtesztelésére szolgál. A teszteléshez létre kell hozni egy tesztmodult (ez általában az eredeti modul /tests allapja), ami a tesztelt modul és a ScribuntoUnit felhasználásával ellenőrzi, hogy a tesztelt modul műveletei a várt eredményt adják-e.

A modul /tests allapján elhelyezett teszt eredménye automatikusan megjelenik a modul wikioldalán; a teszten elbukó modulok emellett bekerülnek a Sikertelen tesztet eredményező modulok kategóriába is.

Példa egy teszteket is tartalmazó modulra: Modul:Coordinate

A tesztmodul felépítése

szerkesztés

A tesztmodul egy tesztkészlet (test suite) létrehozásával kezdődik:

local SUT = require('Modul:SUT') -- a tesztelt modul
local ScribuntoUnit = require('Modul:ScribuntoUnit')
local suite = ScribuntoUnit:new()

Ezután a suite tesztkészlethez hozzá lehet adni az egyes teszteket. Minden olyan függvény, aminek a neve test-tel kezdődik, tesztként lesz kezelve (más függvényeket is tartalmazhat a tesztkészlet, ezeket a ScribuntoUnit figyelmen kívül hagyja, de a tesztek használhatják őket).

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

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

A tesztek ellenőrző állításokat (assertion) tartalmaznak; ezek ellenőrzik valamilyen feltétel teljesülését (például az assertEquals azt, hogy a két argumentuma megegyezik). Ha a feltétel nem teljesül, a teszt sikertelen, és a futtatásakor egy hibaüzenetet látunk, hogy melyik ellenőrző állítás hiúsult meg (az adott tesztben szereplő többi ellenőrzésre ilyenkor nem kerül sor). Ha minden ellenőrzés sikeres, maga a teszt is sikeres.

A teszt futtatása

szerkesztés

A tesztet kétféleképpen lehet futtatni: a modul szerkesztésekor megjelenő debug konzolból a require('Module:SUT/tests').run() paranccsal (ilyenkor a hibaüzenetek is a konzolban jelennek meg), vagy sablonként az {{#invoke:Module:SUT/tests|run}} paranccsal (ilyenkor egy táblázatként jelenik meg, ez a tesztmodul tetején látható; lehetőség van tömörebb megjelenítésre is a {{#invoke:Module:SUT/tests|run|displayMode=short}} paraméterezéssel, emez pedig a moduldokumentáció fejlécében).

Ellenőrzések

szerkesztés

hibaüzenetek

szerkesztés

Minden ellenőrzés opcionális utolsó paraméterként elfogad egy üzenetet is, ami meg fog jelenni, ha az ellenőrzés sikertelen.

assertTrue, assertFalse

szerkesztés

Azt ellenőrzik, hogy a vizsgált állítás igaz ill. hamis-e.

self:assertTrue(2 + 2 == 4)

assertEquals, assertDeepEquals

szerkesztés

A paraméterek egyenlőségét vizsgálja. Hagyományosan az első paraméter az ismert eredmény, a második a vizsgált függvényhívás.

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

As assertDeepEquals táblák egyenlőségének a vizsgálatára is alkalmas.

assertTemplateEquals

szerkesztés

Egy sablonhívás eredményét vizsgálja.

self:assertTemplateEquals(4, 'add', {2, 2}) -- akkor igaz, ha {{add|2|2}} értéke 4

assertResultEquals

szerkesztés

Tetszőleges wikikód (tipikusan egy modulhívás) kimenetét vizsgálja.

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

assertSameResult

szerkesztés

Két wikikód kimenetét hasonlítja össze; hasznos lehet egy sablon lecserélésénél annak ellenőrzésére, hogy az új sablon ugyanúgy viselkedik-e.

self:assertSameResult('{{add|2|2}}', '{{#invoke:Calculator|add|2|2}}')
-------------------------------------------------------------------------------
-- Unit tests for Scribunto.
-------------------------------------------------------------------------------
require('strict')

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

-------------------------------------------------------------------------------
-- 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 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 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 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
        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", processed2, processed1), 
            actual = processed2,
            actualRaw = text2,
            expected = processed1,
            expectedRaw = text1,
            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

-------------------------------------------------------------------------------
-- 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 {}
    local meta = {__index = self}
    setmetatable(o, meta)
    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)
    for name, func in pairs(suite) do
        if name:find('^test') then
            self:runTest(suite, name, func)
        end
    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)
    frame = frame or mw.getCurrentFrame()
    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

function ScribuntoUnit:displayResultsAsShort(testData)
    local text = string.format('sikeres: %d, sikertelen: %d, kihagyva: %d', testData.successCount, testData.failureCount, testData.skipCount)
    if testData.failureCount > 0 then
        text = '<span class="error">' .. text .. '</span> [[Kategória:Sikertelen tesztet eredményező modulok]]'
    end
    return text
end

function ScribuntoUnit:displayResultsAsTable(testData)
    local successIcon, failIcon = self.frame:preprocess('{{zöldpipa}}'), self.frame:preprocess('{{pirosiksz}}')
    local text = '{| class="wikitable scribunto-test-table"\n'
    text = text .. '! !! Név !! Várt !! Tényleges\n'
    for _, result in ipairs(testData.results) do
        text = text .. '|-\n'
        if result.error then
            text = text .. '| ' .. failIcon .. ' || ' .. result.name .. ' || '
            if (result.expected and result.actual) then
                text = text .. tostring(result.expected) .. '\n| ' .. tostring(result.actual) .. '\n'
            else
                text = text .. ' colspan="2" | ' .. result.message .. '\n'
            end
        else
            text = text .. '| ' .. successIcon .. ' || ' .. result.name .. ' || ||\n'
        end
    end
    text = text .. '|}\n'
    return text
end

return ScribuntoUnit