memory alpha
Module documentation ()

UnitTests provides a unit test facility that can be used by other scripts using require. See Wikipedia:Lua#Unit testing for details. The following is a sample from Module:UnitTests/testcases/Module:Example:

local p = require('Module:UnitTests')

function p:test_hello()
	self:preprocess_equals('{{#invoke:Example|hello}}', 'Hello World!')
end

return p

The documentation subpage Module:UnitTests/testcases/Module:Example/doc executes it with {{#invoke:UnitTests/testcases/Module:Example|run_tests}}. Test methods like test_hello above must begin with "test".

Methods

run_tests

preprocess_equals

preprocess_equals_many

preprocess_equals_preprocess

preprocess_equals_preprocess_many

preprocess_equals_sandbox_many

equals

equals_deep

Test options

These are the valid options that can be passed into the options parameters of the test functions listed above.

nowiki

Enabling this wraps the output text in <nowiki>...</nowiki> tags to avoid the text being rendered (e.g. <span>[[Example|Page]]</span> instead of Page)

combined

Enabling this will display the output text in both the rendered mode and the nowiki mode to allow for both a raw text and visual comparison.

noexpectation

Enabling this and providing an empty string as an expected value makes a test always succeed, regardless of the actual value.

templatestyles

Enabling this fixes the IDs in the strip markers <templatestyles>...</templatestyles> produces when processed to avoid incorrectly failing the tests.

stripmarker

Enabling this fixes the IDs in all strip markers produces when processed to avoid incorrectly failing the tests.

display

An optional function that changes how the output from the tests are displayed. This doesn't affect the comparison process.

Module source

local UnitTester = {}

local frame, tick, cross, should_highlight
local result_table_header = '{| class="wikitable unit-tests-result"\n|+ %s\n! !! Text !! Expected !! Actual'
local result_table_live_sandbox_header = '{| class="wikitable unit-tests-result"\n|+ %s\n! !! Test !! Live !! Sandbox !! Expected'

local result_table = {n = 0}
local result_table_mt = {
	insert = function (self, ...)
		local n = self.n
		for i = 1, select('#', ...) do
			local val = select(i, ...)
			if val ~= nil then
				n = n + 1
				self[n] = val
			end
		end
		self.n = n
	end,
	insert_format = function (self, ...)
		self:insert(string.format(...))
	end,
	concat = table.concat
}
result_table_mt.__index = result_table_mt
setmetatable(result_table, result_table_mt)

local num_failures_sandbox = 0
local num_failures = 0
local num_runs = 0

local function first_difference(s1, s2)
	s1, s2 = tostring(s1), tostring(s2)
	if s1 == s2 then return '' end
	local max = math.min(#s1, #s2)
	for i = 1, max do
		if s1:sub(i, i) ~= s2:sub(i, i) then return i end
	end
	return max + 1
end

local function return_varargs(...)
	return ...
end

function UnitTester:calculate_output(text, expected, actual, options)
	-- Set up some variables for throughout for ease
	num_runs = num_runs + 1
	options = options or {}

	-- Fix any stripmarkers if asked to do so to prevent incorrect fails
	local compared_expected = expected
	local compared_actual = actual
	if options.templatestyles then
		local pattern = '(\127[^\127]*UNIQ%-%-templatestyles%-)(%x+)(%-QINU[^\127]*\127)'
		local _, expected_stripmarker_id = compared_expected:match(pattern)				-- when module rendering has templatestyles strip markers, use ID from expected to prevent false test fail
		if expected_stripmarker_id then
			compared_actual = compared_actual:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')	-- replace actual id with expected id; ignore second capture in pattern
			compared_expected = compared_expected:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')		-- account for other strip markers
		end
	end
	if options.stripmarker then
		local pattern = '(\127[^\127]*UNIQ%-%-%l+%-)(%x+)(%-%-?QINU[^\127]*\127)'
		local _, expected_stripmarker_id = compared_expected:match(pattern)
		if expected_stripmarker_id then
			compared_actual = compared_actual:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')
			compared_expected = compared_expected:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')
		end
	end

	-- Perform the comparison
	local success = compared_actual == compared_expected
	if options.noexpectation and compared_expected == '' then
		success = true
	end
	if not success then
		num_failures = num_failures + 1
	end

	-- Sort the wikitext for displaying the results
	if options.combined then
		-- We need 2 rows available for the expected and actual columns
		-- Top one is parsed, bottom is unparsed
		local differs_at = self.differs_at and (' \n| rowspan=2|' .. first_difference(compared_expected, compared_actual)) or ''
		-- Local copies of tick/cross to allow for highlighting
		local highlight = (should_highlight and not success and 'style="background: #fc0;" ') or ''
		result_table:insert(													-- Start output
			'| ', highlight, 'rowspan=2|', success and tick or cross,			-- Tick/Cross (2 rows)
			' \n| rowspan=2|', mw.text.nowiki(text), ' \n| ',					-- Text used for the test (2 rows)
			expected, ' \n| ', actual,											-- The parsed outputs (in the 1st row)
			differs_at, ' \n|-\n| ',											-- Where any relevant difference was (2 rows)
			mw.text.nowiki(expected), ' \n| ', mw.text.nowiki(actual),			-- The unparsed outputs (in the 2nd row)
			'\n|-\n'															-- End output
		)
	else
		-- Display normally with whichever option was preferred (nowiki/parsed)
		local differs_at = self.differs_at and (' \n| ' .. first_difference(compared_expected, compared_actual)) or ''
		local formatting = options.nowiki and mw.text.nowiki or return_varargs
		local highlight = (should_highlight and not success and 'style="background: #fc0;" |') or ''
		result_table:insert(													-- Start output
			'| ', highlight, success and tick or cross,							-- Tick/Cross
			' \n| ', mw.text.nowiki(text), ' \n| ',								-- Text used for the test
			formatting(expected), ' \n| ', formatting(actual),					-- The formatted outputs
			differs_at,															-- Where any relevant difference was
			'\n|-\n'															-- End output
		)
	end
end

function UnitTester:preprocess_equals(text, expected, options)
	local actual = frame:preprocess(text)
	self:calculate_output(text, expected, actual, options)
end

function UnitTester:preprocess_equals_many(prefix, suffix, cases, options)
	for _, case in ipairs(cases) do
		self:preprocess_equals(prefix .. case[1] .. suffix, case[2], options)
	end
end

function UnitTester:preprocess_equals_preprocess(text1, text2, options)
	local actual = frame:preprocess(text1)
	local expected = frame:preprocess(text2)
	self:calculate_output(text1, expected, actual, options)
end

function UnitTester:preprocess_equals_compare(live, sandbox, expected, options)
	options = options or {}
	local live_text = frame:preprocess(live)
	local sandbox_text = frame:preprocess(sandbox)
	local highlight_live = false
	local highlight_sandbox = false
	num_runs = num_runs + 1
	local compared_live = live_text
	local compared_sandbox = sandbox_text
	local compared_expected = expected
	if options.templatestyles then
		local pattern = '(\127[^\127]*UNIQ%-%-templatestyles%-)(%x+)(%-QINU[^\127]*\127)'
		local _, expected_stripmarker_id = compared_expected:match(pattern)				-- when module rendering has templatestyles strip markers, use ID from expected to prevent false test fail
		if expected_stripmarker_id then
			compared_live = compared_live:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')	-- replace actual id with expected id; ignore second capture in pattern
			compared_sandbox = compared_sandbox:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')	-- replace actual id with expected id; ignore second capture in pattern
			compared_expected = compared_expected:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')		-- account for other strip markers
		end
	end
	if options.stripmarker then
		local pattern = '(\127[^\127]*UNIQ%-%-%l+%-)(%x+)(%-%-?QINU[^\127]*\127)'
		local _, expected_stripmarker_id = compared_expected:match(pattern)
		if expected_stripmarker_id then
			compared_live = compared_live:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')
			compared_sandbox = compared_sandbox:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')
			compared_expected = compared_expected:gsub(pattern, '%1' .. expected_stripmarker_id .. '%3')
		end
	end
	local success = compared_live == compared_expected and compared_sandbox == compared_expected
	if not success then
		if compared_live ~= compared_expected then
			num_failures = num_failures + 1
			highlight_live = true
		end

		if compared_sandbox ~= compared_expected then
			num_failures_sandbox = num_failures_sandbox + 1
			highlight_sandbox = true
		end
	end

	-- Sort the wikitext for displaying the results
	if options.combined then
		-- We need 2 rows available for the expected, live, and sandbox columns
		-- Top one is parsed, bottom is unparsed
		local differs_at = self.differs_at and (' \n| ' .. first_difference(expected, live_text) or first_difference(expected, sandbox_text)) or ''
		result_table:insert(
			'| ', 'rowspan=2|', success and tick or cross,
			' \n| rowspan=2|', mw.text.nowiki(live),
			should_highlight and highlight_live and ' \n| style="background: #fc0;" | ' or ' \n| ',
			live_text,
			should_highlight and highlight_sandbox and ' \n| style="background: #fc0;" | ' or ' \n| ',
			sandbox_text,
			' \n| ',
			expected,
			differs_at,
			should_highlight and highlight_sandbox and ' \n|-\n| style="background: #fc0;" | ' or ' \n|-\n| ',
			mw.text.nowiki(live_text),
			should_highlight and highlight_sandbox and ' \n| style="background: #fc0;" | ' or ' \n| ',
			mw.text.nowiki(sandbox_text),
			' \n| ',
			mw.text.nowiki(expected),
			'\n|-\n'
		)
	else
		-- Display normally with whichever option was preferred (nowiki/parsed)
		local differs_at = self.differs_at and (' \n| ' .. first_difference(expected, live_text) or first_difference(expected, sandbox_text)) or ''
		local formatting = options.nowiki and mw.text.nowiki or return_varargs
		result_table:insert(
			'| ', success and tick or cross,
			' \n| ',
			mw.text.nowiki(live),
			should_highlight and highlight_live and ' \n| style="background: #fc0;" | ' or ' \n| ',
			formatting(live_text),
			should_highlight and highlight_sandbox and ' \n| style="background: #fc0;" | ' or ' \n| ',
			formatting(sandbox_text),
			' \n| ',
			formatting(expected),
			differs_at,
			'\n|-\n'
		)
	end
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[1] .. suffix1, prefix2 .. (case[2] and case[2] or case[1]) .. suffix2, options)
	end
end

function UnitTester:preprocess_equals_sandbox_many(module, function_name, cases, options)
	for _, case in ipairs(cases) do
		local live = module .. '|' .. function_name .. '|' .. case[1] .. '}}'
		local sandbox = module .. '/sandbox|' .. function_name .. '|' .. case[1] .. '}}'
		self:preprocess_equals_compare(live, sandbox, case[2], options)
	end
end

function UnitTester:equals(name, actual, expected, options)
	num_runs = num_runs + 1
	if actual == expected then
		result_table:insert('| ', tick)
	else
		result_table:insert('| ', cross)
		num_failures = num_failures + 1
	end
	local formatting = (options and options.nowiki and mw.text.nowiki) or return_varargs
	local differs_at = self.differs_at and (' \n| ' .. first_difference(expected, actual)) or ''
	local display = options and options.display or return_varargs
	result_table:insert(' \n| ', name, ' \n| ',
		formatting(tostring(display(expected))), ' \n| ',
		formatting(tostring(display(actual))), differs_at, '\n|-\n')
end

local function deep_compare(t1, t2, ignore_mt)
	local ty1 = type(t1)
	local ty2 = type(t2)
	if ty1 ~= ty2 then return false end
	if ty1 ~= 'table' and ty2 ~= 'table' then return t1 == t2 end

	local mt = getmetatable(t1)
	if not ignore_mt and mt and mt.__eq then return t1 == t2 end

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

	return true
end

local function val_to_str(obj)
	local function table_key_to_str(k)
		if type(k) == 'string' and mw.ustring.match(k, '^[_%a][_%a%d]*$') then
			return k
		else
			return '[' .. val_to_str(k) .. ']'
		end
	end

	if type(obj) == 'string' then
		obj = mw.ustring.gsub(obj, '\n', '\\n')
		if mw.ustring.match(mw.ustring.gsub(obj, '[^\'"]', ''), '^"+$') then
			return "'" .. obj .. "'"
		end
		return '"' .. mw.ustring.gsub(obj, '"', '\\"' ) .. '"'
	elseif type(obj) == 'table' then
		local result, checked = {}, {}
		for k, v in ipairs(obj) do
			table.insert(result, val_to_str(v))
			checked[k] = true
		end
		for k, v in pairs(obj) do
			if not checked[k] then
				table.insert(result, table_key_to_str(k) .. '=' .. val_to_str(v))
			end
		end
		return '{' .. table.concat(result, ',') .. '}'
	else
		return tostring(obj)
	end
end

function UnitTester:equals_deep(name, actual, expected, options)
	num_runs = num_runs + 1
	if deep_compare(actual, expected) then
		result_table:insert('| ', tick)
	else
		result_table:insert('| ', cross)
		num_failures = num_failures + 1
	end
	local formatting = (options and options.nowiki and mw.text.nowiki) or return_varargs
	local actual_str = val_to_str(actual)
	local expected_str = val_to_str(expected)
	local differs_at = self.differs_at and (' \n| ' .. first_difference(expected_str, actual_str)) or ''
	result_table:insert(' \n| ', name, ' \n| ', formatting(expected_str),
		' \n| ', formatting(actual_str), differs_at, '\n|-\n')
end

function UnitTester:iterate(examples, func)
	require 'libraryUtil'.checkType('iterate', 1, examples, 'table')
	if type(func) == 'string' then
		func = self[func]
	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:heading(example)
		else
			error(('bad example #%d (expected table, got %s)')
				:format(i, type(example)), 2)
		end
	end
end

function UnitTester:heading(text)
	result_table:insert_format(' ! colspan="%u" style="text-align: left;" | %s \n |- \n ',
		self.columns, text)
end

function UnitTester:runTest(name, test)
	local success, details = xpcall(function() test(self) end, function(err) return {error = err, trace = debug.traceback()} end)
	if not success then
		num_failures = num_failures + 1
		num_runs = num_runs + 1
		result_table:insert('| ', 'rowspan=2|', cross, ' \n| ', name, ' \n| ')
		result_table:insert_format('<strong class="error">Lua error -- %s;traceback:<br>%s</strong>\n',
			details.error, details.trace:gsub('\n', '<br>'))
		if self.differs_at then
			result_table:insert(' \n| ')
		end
		if self.live_sandbox then
			result_table:insert(' \n| ', ' \n| ', '\n|-\n')
		else
			result_table:insert(' \n| ', '\n|-\n')
		end
	end
end

function UnitTester:run(frame_arg)
	frame = frame_arg or mw.getCurrentFrame()
	self.frame = frame
	self.differs_at = frame.args['differs_at']
	self.live_sandbox = frame.args['live_sandbox']
	tick = frame:preprocess('{{Tick}}')
	cross = frame:preprocess('{{Cross}}')

	local table_header = result_table_header
	if self.live_sandbox then
		table_header = result_table_live_sandbox_header
	end
	if frame.args.highlight then
		should_highlight = true
	end

	self.columns = 4
	if self.differs_at then
		table_header = table_header .. ' !! Differs at'
		self.columns = self.columns + 1
	end
	if self._tests._last then
		table.insert(self._tests, self._tests._last)
		self._tests._last = nil
	end
	-- Add results to the results table.
	for _, testDetails in ipairs(self._tests) do
		result_table:insert_format('<h2>%s</h2>\n', testDetails.name)
		result_table:insert_format(table_header .. '\n|-\n', testDetails.name)
		self:runTest(testDetails.name, testDetails.test)
		result_table:insert('|}\n')
	end
	local failures_cat = 'Memory Alpha failed Lua testcases'
	local doc_header = frame:expandTemplate{title = 'Documentation subpage'}
	if self.live_sandbox then
		local live = (num_runs == 0 and '<b>No tests were run.</b>'
			or num_failures == 0 and '<b style="color: #008000;">All ' .. num_runs .. ' live tests passed.</b>'
			or '<b style="color: #800000;">' .. num_failures .. ' of ' .. num_runs .. ' live tests failed.</b>'
		)
		local sandbox = (num_runs == 0 and ''
			or num_failures_sandbox == 0 and '<b style="color: #008000;">All ' .. num_runs .. ' sandbox tests passed.</b>'
			or '<b style="color: #800000;">' .. num_failures_sandbox .. ' of ' .. num_runs .. ' sandbox tests failed.</b>'
		)
		local cat = (num_failures ~= 0 or num_failures_sandbox ~= 0) and '[[Category:' .. failures_cat .. ']]' or ''
		return doc_header .. live .. ' ' .. sandbox .. cat .. '\n\n' .. frame:preprocess(result_table:concat())
	else
		return doc_header .. (num_runs == 0 and '<b>No tests were run.</b>'
			or num_failures == 0 and '<b style="color: #008000;">All ' .. num_runs .. ' tests passed.</b>'
			or '<b style="color: #800000;">' .. num_failures .. ' of ' .. num_runs .. ' tests failed.</b>[[Category:' .. failures_cat .. ']]'
		) .. '\n\n' .. frame:preprocess(result_table:concat())
	end
end

-- Set up metatable
UnitTester.__meta = {}
UnitTester.__meta.__index = UnitTester
function UnitTester.__meta:__newindex(key, value)
	if type(key) == 'string' and key:find('^test') and type(value) == 'function' then
		-- Store test functions in the order they were defined
		if key == self.runLast then
			self._tests._last = {name = key, test = value}
		else
			table.insert(self._tests, {name = key, test = value})
		end
	else
		rawset(self, key, value)
	end
end

function UnitTester.new()
	local o = {}
	o.runLast = '' -- puts a test at the end of the page
	o._tests = {}
	setmetatable(o, UnitTester.__meta)
	function o.run_tests(frame_arg) return o:run(frame_arg) end
	return o
end

local p = UnitTester.new()

return p