This module facilitates creating unit tests for Lua modules.
Put the following at Module:name/testcases
:
local tests = require('Module:UnitTests')
function tests:test_example()
--]
end
return tests
Then put the following on Module:name/testcases/documentation
:
{{#invoke:name/testcases|run_tests}}
Tests should be written as Lua methods whose names start with test
. The self
object contains the following methods, which may be called from the method:
preprocess_equals(text, expected, options)
text
results in expected
.preprocess_equals_many(prefix, suffix, cases, options)
preprocess_equals_preprocess(text1, text2, options)
text1
and text2
results in the same string.preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
equals(name, actual, expected, options)
name
will be used as the row header. When the value is a table, equals_deep
should be used.equals_deep(name, actual, expected, options)
name
will be used as the row header.header(string)
iterate
self:header()
.iterate(array, function_name)
function_name
is a string, the name of a method in the self
object. For instance, self:iterate({ { "a", "b" }, { "c", "d" } }, "check")
calls self:check("a", "b")
and self:check("c", "d")
. This self:check()
method must be defined separately.iterate(array, func)
self:iterate( { { "a", "b" }, { "c", "d" } }, check)
will call check(self, "a", "b")
and check(self, "c", "d")
.options
should be given in a table or omitted. Currently, these are the options supported:
nowiki
: Causes both the expected and the actual values to be wrapped in <nowiki> tags when rendering the results table.comment
: A comment to be added to the rightmost column of table.display
: A function to yield the form actually displayed in the table. This is used in testcases for pronunciation modules to make the IPA transcriptions display with the correct fonts.show_difference
: If this is set to true
(or any truthy value besides a function), failing tests will have the first offending character highlighted in red (that is, the first character in the "actual" string that is different from the corresponding character in the "expected" string). If this is a function, the character will be highlighted using the function. (Currently only available in the equals
checking function. The highlighter will highlight combining characters together with the characters they are placed on.)Use Module:transliteration module testcases to quickly create testcases for a transliteration module. It uses this module. At the moment, it only supports romanization and a single set of examples.
local m_table = require("Module:table")
local UnitTester = {}
local concat = table.concat
local deep_equals = m_table.deepEquals
local explode_utf8 = require("Module:string utilities").explode_utf8
local html = mw.html
local insert = table.insert
local is_combining = require("Module:Unicode data").is_combining
local nowiki = require("Module:string/nowiki")
local shallowcopy = m_table.shallowcopy
local sort = table.sort
local sorted_pairs = m_table.sortedPairs
local ustring = mw.ustring
local tick, cross =
']',
']'
local function first_difference(s1, s2)
if not (type(s1) == "string" and type(s2) == "string") then
return "N/A"
elseif s1 == s2 then
return ""
end
s1 = explode_utf8(s1)
s2 = explode_utf8(s2)
local i = 0
repeat
i = i + 1
until s1 ~= s2
return i
end
local function highlight(str)
if ustring.find(str, "%s") then
return '<span style="background-color: pink;">' ..
string.gsub(str, " ", " ") .. '</span>'
else
return '<span style="color: red;">' ..
str .. '</span>'
end
end
local function find_noncombining(str, i, incr)
local char = ustring.sub(str, i, i)
while char ~= '' and is_combining(char) do
i = i + incr
char = ustring.sub(str, i, i)
end
return i
end
-- Highlight character where a difference was found. Start highlight at first
-- non-combining character before the position. End it after the first non-
-- combining characters after the position. Can specify a custom highlighing
-- function.
local function highlight_difference(actual, expected, differs_at, func)
if type(differs_at) ~= "number" or not (actual and expected) then
return actual
end
differs_at = find_noncombining(expected, differs_at, -1)
local i = find_noncombining(actual, differs_at, -1)
local j = find_noncombining(actual, differs_at + 1, 1)
j = j - 1
return ustring.sub(actual, 1, i - 1) ..
(type(func) == "function" and func or highlight)(ustring.sub(actual, i, j)) ..
ustring.sub(actual, j + 1, -1)
end
local function val_to_str(v)
if type(v) == 'string' then
v = string.gsub(v, '\n', '\\n')
if string.find(string.gsub(v, '', ''), '^"+$') then
return "'" .. v .. "'"
end
return '"' .. string.gsub(v, '"', '\\"' ) .. '"'
elseif type(v) == 'table' then
local result, done = {}, {}
for k, val in ipairs(v) do
insert(result, val_to_str(val))
done = true
end
for k, val in sorted_pairs(v) do
if not done then
if (type(k) ~= "string") or not string.find(k, '^*$') then
k = ''
end
insert(result, k .. '=' .. val_to_str(val))
end
end
return "{" .. concat(result, ", ") .. "}"
else
return tostring(v)
end
end
local function deep_compare(t1, t2, ignore_mt)
return deep_equals(t1, t2, not ignore_mt)
end
local function get_differing_keys(t1, t2)
local ty1, ty2 = type(t1), type(t2)
if ty1 ~= ty2 then return nil
elseif ty1 ~= 'table' then return nil end
local mt = getmetatable(t1)
if not ignore_mt and mt and mt.__eq then return nil end
local keys = {}
for k1, v1 in pairs(t1) do
local v2 = t2
if v2 == nil or not deep_compare(v1, v2) then insert(keys, k1) end
end
for k2, v2 in pairs(t2) do
local v1 = t1
if v1 == nil or not deep_compare(v1, v2) then insert(keys, k2) end
end
return keys
end
local function extract_keys(table, keys)
if not keys then return table end
local new_table = {}
for _, key in ipairs(keys) do
new_table = table
end
return new_table
end
-- Return the header for the result table along with the number of columns in the table.
function UnitTester:new_result_table()
local header_row = html.create("tr")
:tag("th")
:attr("class", "unit-tests-img-corner")
:css("cursor", "pointer")
:attr("title", "Only failed tests")
:done()
local columns = shallowcopy(self.name_columns)
insert(columns, "Expected")
insert(columns, "Actual")
insert(columns, differs_at)
if self.differs_at then
insert(columns, "Differs at")
end
if self.comments then
insert(columns, "Comments")
end
for _, cell in ipairs(columns) do
header_row = header_row:tag("th")
:wikitext(cell)
:done()
end
self.columns = #columns + 1
return html.create("table")
:attr("class", "unit-tests wikitable")
:node(header_row)
end
function UnitTester:display_difference(success, name, actual, expected, options)
local differs_at = self.differs_at and first_difference(expected, actual)
local comment = self.comments and (options and options.comment or "")
expected = expected == nil and "(nil)" or tostring(expected)
actual = actual == nil and "(nil)" or tostring(actual)
if self.nowiki or options and options.nowiki then
expected = nowiki(expected)
actual = nowiki(actual)
end
if options and type(options.display) == "function" then
expected = options.display(expected)
actual = options.display(actual)
end
local cells
if type(name) == "table" then
cells = shallowcopy(name)
insert(cells, expected)
insert(cells, actual)
insert(cells, differs_at)
else
cells = {
name,
expected,
actual,
differs_at
}
end
insert(cells, comment) -- In case differs_at is nil.
local row = html.create("tr")
if success then
row = row:attr("class", "unit-test-pass")
insert(cells, 1, tick)
else
row = row:attr("class", "unit-test-fail")
insert(cells, 1, cross)
self.num_failures = self.num_failures + 1
end
for _, cell in ipairs(cells) do
row = row:tag("td")
:wikitext(cell)
:done()
end
self.result_table = self.result_table:node(row)
self.total_tests = self.total_tests + 1
end
function UnitTester:equals(name, actual, expected, options)
success = actual == expected
if options and options.show_difference then
local difference = first_difference(expected, actual)
if type(difference) == "number" then
actual = highlight_difference(actual, expected, difference,
type(options.show_difference) == "function" and options.show_difference)
end
end
self:display_difference(success, name, actual, expected, options)
end
function UnitTester:preprocess_equals(text, expected, options)
local actual = self.frame:preprocess(text)
self:equals(nowiki(text), actual, expected, options)
end
function UnitTester:preprocess_equals_many(prefix, suffix, cases, options)
for _, case in ipairs(cases) do
self:preprocess_equals(prefix .. case .. suffix, case, options)
end
end
function UnitTester:preprocess_equals_preprocess(text1, text2, options)
local expected = self.frame:preprocess(text2)
self:preprocess_equals(text1, expected, options)
end
function UnitTester:preprocess_equals_preprocess_many(prefix1, suffix1, prefix2, suffix2, cases, options)
for _, case in ipairs(cases) do
self:preprocess_equals_preprocess(prefix1 .. case .. suffix1, prefix2 .. (case and case or case) .. suffix2, options)
end
end
function UnitTester:equals_deep(name, actual, expected, options)
local actual_str, expected_str
local success = deep_compare(actual, expected)
if success then
if options and options.show_table_difference then
actual_str = ''
expected_str = ''
end
else
if options and options.show_table_difference then
local keys = get_differing_keys(actual, expected)
actual_str = val_to_str(extract_keys(actual, keys))
expected_str = val_to_str(extract_keys(expected, keys))
end
end
if (not options) or not options.show_table_difference then
actual_str = val_to_str(actual)
expected_str = val_to_str(expected)
end
self:display_difference(success, name, actual_str, expected_str, options)
end
function UnitTester:iterate(examples, func)
require 'libraryUtil'.checkType('iterate', 1, examples, 'table')
if type(func) == 'string' then
func = self
elseif type(func) ~= 'function' then
error(("bad argument #2 to 'iterate' (expected function or string, got %s)")
:format(type(func)), 2)
end
for i, example in ipairs(examples) do
if type(example) == 'table' then
func(self, unpack(example))
elseif type(example) == 'string' then
self:header(example)
else
error(('bad example #%d (expected table or string, got %s)')
:format(i, type(example)), 2)
end
end
end
function UnitTester:header(text)
local prefix, maintext = text:match('^#(h+):(.*)$')
if not prefix then
maintext = text
end
local header = html.create("th")
:attr("colspan", self.columns)
if prefix == "h1" then
header = header:css("text-align", "center")
:css("font-size", "150%")
else
header = header:css("text-align", "left")
end
header = header:wikitext(maintext)
self.result_table = self.result_table:tag("tr")
:node(header)
:done()
end
function UnitTester:run(frame)
self.num_failures = 0
local output = {}
local iparams = {
= {type = "boolean"},
= {type = "boolean"},
= {type = "boolean"},
= {type = "boolean"},
= {list = true, default = "Text"},
}
local iargs = require("Module:parameters").process(frame.args, iparams)
self.frame = frame
self.nowiki = iargs.nowiki
self.differs_at = iargs.differs_at
self.comments = iargs.comments
self.summarize = iargs.summarize
self.name_columns = iargs.name_column
self.total_tests = 0
-- Sort results into alphabetical order.
local self_sorted = {}
for key in pairs(self) do
if key:find('^test') then
insert(self_sorted, key)
end
end
sort(self_sorted)
-- Add results to the results table.
for _, key in ipairs(self_sorted) do
self.result_table = self:new_result_table()
:tag("caption")
:css("text-align", "left")
:css("font-weight", "bold")
:wikitext(key .. ":")
:done()
local traceback = "(no traceback)"
local success, mesg = xpcall(function()
return self(self)
end, function(mesg)
traceback = debug.traceback("", 2)
return mesg
end)
if not success then
self.result_table = self.result_table:tag("tr")
:tag("td")
:attr("colspan", self.columns)
:css("text-align", "left")
:tag("strong")
:attr("class", "error")
:wikitext("Script error during testing: " .. nowiki(mesg))
:done()
:wikitext(frame:extensionTag("pre", traceback))
:allDone()
self.num_failures = self.num_failures + 1
end
insert(output, tostring(self.result_table))
end
local refresh_link = tostring(mw.uri.fullUrl(mw.title.getCurrentTitle().fullText, 'action=purge&forcelinkupdate=1'))
local failure_cat = ']'
if mw.title.getCurrentTitle().text:find("/documentation$") then
failure_cat = ''
end
local num_successes = self.total_tests - self.num_failures
if self.summarize then
if self.num_failures == 0 then
return '<strong class="success">' .. self.total_tests .. '/' .. self.total_tests .. ' tests passed</strong>'
else
return '<strong class="error">' .. num_successes .. '/' .. self.total_tests .. ' tests passed</strong>'
end
else
return (self.num_failures == 0 and '<strong class="success">All tests passed.</strong>' or
'<strong class="error">' .. self.num_failures .. ' of ' .. self.total_tests .. ' test' .. (self.total_tests == 1 and '' or 's' ) .. ' failed.</strong>' .. failure_cat) ..
" <span class='plainlinks unit-tests-refresh'></span>\n\n" ..
concat(output, "\n\n")
end
end
function UnitTester:new()
local o = {}
setmetatable(o, self)
self.__index = self
return o
end
local p = UnitTester:new()
function p.run_tests(frame) return p:run(frame) end
return p