Deze module is nog in ontwikkeling (versie 0.0) en wordt getest.
De Module:Layout is bedoeld om snel, consistent en uitgebreid een pagina op te maken.
Er is een op de module afgestemde handleiding over deze onderwijswiki beschikbaar.
-- This module creates a simple unit testing framework that allows you to define and run test functions, and then displays the results in a specified format.--------------------------------------------------------------------------------- Unit tests for Scribunto.-------------------------------------------------------------------------------require('strict')localDebugHelper={}localunittest={}-- The CFG table contains all localisable strings and configuration, to make it-- easier to port this module to another wiki.localCFG={};-- Categories --CFG.CATEGORY={};CFG.CATEGORY.TEST_FAILURE=nil;-- IndicatorsCFG.TEST_FAILURE_SUMMARY="'''$1 {{PLURAL:$1|test|tests}} mislukt'''.";CFG.TEST_SUCCESS_MULTIPLE="All %s tests are ok.";CFG.TEST_SUCCESS_SINGLE="The only test is ok.";-- String formatsCFG.TEST_FORMAT_SHORT_RESULTS="succesvol: %d, fout: %d, overgeslagen: %d";-- TestCFG.TEST_COLUMN_ACTUAL="Actual";CFG.TEST_COLUMN_EXPECTED="Expected";CFG.TEST_COLUMN_NAME="Name";CFG.FAILURE_INDICATOR='[[File:OOjs UI icon close-ltr-destructive.svg|20px|link=|alt=]]<span style="display:none">N</span>';CFG.SUCCESS_INDICATOR='[[File:OOjs UI icon check-constructive.svg|20px|alt=Yes|link=]]';--------------------------------------------------------------------------------- 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"-- functionDebugHelper.concatWithKeys(table,keySeparator,separator)keySeparator=keySeparatoror' = 'separator=separatoror', 'localconcatted=''locali=1localfirst=truelocalunnamedArguments=truefork,vinpairs(table)doiffirstthenfirst=falseelseconcatted=concatted..separatorendifk==iandunnamedArgumentstheni=i+1concatted=concatted..tostring(v)elseunnamedArguments=falseconcatted=concatted..tostring(k)..keySeparator..tostring(v)endendreturnconcattedend--------------------------------------------------------------------------------- Compares two tables recursively (non-table values are handled correctly as well).-- @param ignoreMetatable if false, t1.__eq is used for the comparison-- functionDebugHelper.deepCompare(t1,t2,ignoreMetatable)localtype1=type(t1)localtype2=type(t2)iftype1~=type2thenreturnfalseendiftype1~='table'thenreturnt1==t2endlocalmetatable=getmetatable(t1)ifnotignoreMetatableandmetatableandmetatable.__eqthenreturnt1==t2endfork1,v1inpairs(t1)dolocalv2=t2[k1]ifv2==nilornotDebugHelper.deepCompare(v1,v2)thenreturnfalseendendfork2,v2inpairs(t2)doift1[k2]==nilthenreturnfalseendendreturntrueend--------------------------------------------------------------------------------- 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-- functionDebugHelper.raise(details,level)level=(levelor1)+1details.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.-- functionunittest:markTestSkipped()DebugHelper.raise({unittest=true,skipped=true},3)end--------------------------------------------------------------------------------- Checks that the input is true-- @param message optional description of the test-- functionunittest:assertTrue(actual,message)ifnotactualthenDebugHelper.raise({unittest=true,text=string.format("Failed to assert that %s is true",tostring(actual)),message=message},2)endend--------------------------------------------------------------------------------- Checks that the input is false-- @param message optional description of the test-- functionunittest:assertFalse(actual,message)ifactualthenDebugHelper.raise({unittest=true,text=string.format("Failed to assert that %s is false",tostring(actual)),message=message},2)endend--------------------------------------------------------------------------------- Checks that the output is a function-- @param message optional description of the test-- functionunittest:assertFunction(func,message)iftype(func)~='function'thenDebugHelper.raise({unittest=true,text=string.format("Failed to assert that %s is a function",tostring(func)),actual=func,expected='function',message=message,},2)endend--------------------------------------------------------------------------------- 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-- functionunittest:assertStringContains(pattern,s,plain,message)iftype(pattern)~='string'thenDebugHelper.raise({unittest=true,text=mw.ustring.format("Pattern type error (expected string, got %s)",type(pattern)),message=message},2)endiftype(s)~='string'thenDebugHelper.raise({unittest=true,text=mw.ustring.format("String type error (expected string, got %s)",type(s)),message=message},2)endifnotmw.ustring.find(s,pattern,nil,plain)thenDebugHelper.raise({unittest=true,text=mw.ustring.format('Failed to find %s "%s" in string "%s"',plainand"plain string"or"pattern",pattern,s),message=message},2)endend--------------------------------------------------------------------------------- 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-- functionunittest:assertNotStringContains(pattern,s,plain,message)iftype(pattern)~='string'thenDebugHelper.raise({unittest=true,text=mw.ustring.format("Pattern type error (expected string, got %s)",type(pattern)),message=message},2)endiftype(s)~='string'thenDebugHelper.raise({unittest=true,text=mw.ustring.format("String type error (expected string, got %s)",type(s)),message=message},2)endlocali,j=mw.ustring.find(s,pattern,nil,plain)ifithenlocalmatch=mw.ustring.sub(s,i,j)DebugHelper.raise({unittest=true,text=mw.ustring.format('Found match "%s" for %s "%s"',match,plainand"plain string"or"pattern",pattern),message=message},2)endend--------------------------------------------------------------------------------- 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")-- functionunittest:assertEquals(expected,actual,message)iftype(expected)=='number'andtype(actual)=='number'thenself:assertWithinDelta(expected,actual,1e-8,message)elseifexpected~=actualthenDebugHelper.raise({unittest=true,text=string.format("Failed to assert that %s equals expected %s",tostring(actual),tostring(expected)),actual=actual,expected=expected,message=message,},2)endend--------------------------------------------------------------------------------- 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)functionunittest:assertWithinDelta(expected,actual,delta,message)iftype(expected)~="number"thenDebugHelper.raise({unittest=true,text=string.format("Expected value %s is not a number",tostring(expected)),actual=actual,expected=expected,message=message,},2)endiftype(actual)~="number"thenDebugHelper.raise({unittest=true,text=string.format("Actual value %s is not a number",tostring(actual)),actual=actual,expected=expected,message=message,},2)endlocaldiff=expected-actualifdiff<0thendiff=-diffend-- instead of importing math.absifdiff>deltathenDebugHelper.raise({unittest=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)endend--------------------------------------------------------------------------------- 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}))functionunittest:assertDeepEquals(expected,actual,message)ifnotDebugHelper.deepCompare(expected,actual)theniftype(expected)=='table'thenexpected=mw.dumpObject(expected)endiftype(actual)=='table'thenactual=mw.dumpObject(actual)endDebugHelper.raise({unittest=true,text=string.format("Failed to assert that %s equals expected %s",tostring(actual),tostring(expected)),actual=actual,expected=expected,message=message,},2)endend--------------------------------------------------------------------------------- Checks that a wikitext gives the expected result after processing.-- @param message optional description of the test-- @example assertResultEquals("Hello world", "{{concat|Hello|world}}")functionunittest:assertResultEquals(expected,text,message)localframe=self.framelocalactual=frame:preprocess(text)ifexpected~=actualthenDebugHelper.raise({unittest=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)endend--------------------------------------------------------------------------------- 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!}}")functionunittest:assertSameResult(text1,text2,message)localframe=self.framelocalprocessed1=frame:preprocess(text1)localprocessed2=frame:preprocess(text2)ifprocessed1~=processed2thenDebugHelper.raise({unittest=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)endend--------------------------------------------------------------------------------- Checks that a template gives the expected output.-- @param message optional description of the test-- @example assertTemplateEquals("Hello world", "concat", {"Hello", "world"})functionunittest:assertTemplateEquals(expected,template,args,message)localframe=self.framelocalactual=frame:expandTemplate{title=template,args=args}ifexpected~=actualthenDebugHelper.raise({unittest=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)endend--------------------------------------------------------------------------------- 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 testfunctionunittest:assertThrows(fn,expectedMessage,message)localsucceeded,actualMessage=pcall(fn)ifsucceededthenDebugHelper.raise({unittest=true,text='Expected exception but none was thrown',message=message,},2)end-- For strings, strip the line number added to the error messageactualMessage=type(actualMessage)=='string'andstring.match(actualMessage,'Module:[^:]*:[0-9]*: (.*)')oractualMessagelocalmessagesMatch=DebugHelper.deepCompare(expectedMessage,actualMessage)ifexpectedMessageandnotmessagesMatchthenDebugHelper.raise({unittest=true,expected=expectedMessage,actual=actualMessage,text=string.format('Expected exception with message %s, but got message %s',tostring(expectedMessage),tostring(actualMessage)),message=message},2)endend--------------------------------------------------------------------------------- Checks whether a function doesn't throw an error-- @param fn the function to test-- @param message optional description of the testfunctionunittest:assertDoesNotThrow(fn,message)localsucceeded,actualMessage=pcall(fn)ifsucceededthenreturnend-- For strings, strip the line number added to the error messageactualMessage=type(actualMessage)=='string'andstring.match(actualMessage,'Module:[^:]*:[0-9]*: (.*)')oractualMessageDebugHelper.raise({unittest=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.-- The new function takes an optional table as an argument,-- which can contain test functions, and sets the metatable of the new object to inherit from the unittest table. -- Metatables are special tables that can define and control the behavior of other tables through metamethods.-- The __index metamethod is used to define the default behavior when attempting to access a key that does not exist in the table.-- In this case, the __index metamethod is set to self, which refers to the unittest table, -- effectively making the unittest table a fallback for the test_functions.-- The run function is added as a method to the new test suite object.-- When we create a new test suite object with unittest:new(), -- the metatable is set up so that the new object inherits methods from the unittest table.-- If a method is not found in the new object, Lua will search for it in the unittest table, -- allowing to call methods like test.run() even though the run() method is actually defined in the unittest table.functionunittest:new(test_functions)test_functions=test_functionsor{};setmetatable(test_functions,{__index=self});test_functions.run=function(frame)returnself:run(test_functions,frame);endreturntest_functions;end--------------------------------------------------------------------------------- Resets global counters-- functionunittest:init(frame)self.frame=frameormw.getCurrentFrame()self.successCount=0self.failureCount=0self.skipCount=0self.results={}end--------------------------------------------------------------------------------- Runs a single testcase-- @param name test nume-- @param test function containing assertions-- functionunittest:runTest(suite,name,test)localsuccess,details=pcall(test,suite)ifsuccessthenself.successCount=self.successCount+1table.insert(self.results,{name=name,success=true})elseiftype(details)~='table'ornotdetails.unittestthen-- a real error, not a failed assertionself.failureCount=self.failureCount+1table.insert(self.results,{name=name,error=true,message='Lua error -- '..tostring(details)})elseifdetails.skippedthenself.skipCount=self.skipCount+1table.insert(self.results,{name=name,skipped=true})elseself.failureCount=self.failureCount+1localmessage=details.sourceifdetails.messagethenmessage=message..details.message.."\n"endmessage=message..details.texttable.insert(self.results,{name=name,error=true,message=message,expected=details.expected,actual=details.actual,testname=details.message})endend--------------------------------------------------------------------------------- Runs all tests and displays the results.-- functionunittest:runSuite(suite,frame)self:init(frame)localnames={}fornameinpairs(suite)doifname:find('^test')thentable.insert(names,name)endendtable.sort(names)-- Put tests in alphabetical order.fori,nameinipairs(names)dolocalfunc=suite[name]self:runTest(suite,name,func)endreturn{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()-- functionunittest:run(suite,frame)localtestData=self:runSuite(suite,frame)ifframeandframe.argsthenreturnself:displayResults(testData,frame.args.displayModeor'table'),testDataelsereturnself:displayResults(testData,'log'),testDataendend--------------------------------------------------------------------------------- Displays test results -- @param displayMode: 'table', 'log' or 'short'-- functionunittest:displayResults(testData,displayMode)ifdisplayMode=='table'thenreturnself:displayResultsAsTable(testData)elseifdisplayMode=='log'thenreturnself:displayResultsAsLog(testData)elseifdisplayMode=='short'thenreturnself:displayResultsAsShort(testData)elseerror('unknown display mode')endendfunctionunittest:displayResultsAsLog(testData)iftestData.failureCount>0thenmw.log('FAILURES!!!')elseiftestData.skipCount>0thenmw.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.')endmw.log(string.format('Assertions: success: %d, error: %d, skipped: %d',testData.successCount,testData.failureCount,testData.skipCount))mw.log('-------------------------------------------------------------------------------')for_,resultinipairs(testData.results)doifresult.errorthenmw.log(string.format('%s: %s',result.name,result.message))endendendfunctionunittest:displayResultsAsShort(testData)localtext=string.format(CFG.TEST_FORMAT_SHORT_RESULTS,testData.successCount,testData.failureCount,testData.skipCount)iftestData.failureCount>0thentext='<span class="error">'..text..'</span>'endreturntextendfunctionunittest:displayResultsAsTable(testData)localsuccessIcon,failIcon=self.frame:preprocess(CFG.SUCCESS_INDICATOR),self.frame:preprocess(CFG.FAILURE_INDICATOR);localtesttable=mw.html.create('table'):addClass('wikitable'):css('width','100%'):css('max-width','100%'):css('background-color','#E8E4EF'):css('color','black');-- Header rowtesttable:tag('tr'):tag('th'):css('background-color','#BBAFD1'):css('color','black'):done():tag('th'):css('background-color','#BBAFD1'):css('color','black'):wikitext(CFG.TEST_COLUMN_NAME):done():tag('th'):css('background-color','#BBAFD1'):css('color','black'):wikitext(CFG.TEST_COLUMN_EXPECTED):done():tag('th'):css('background-color','#BBAFD1'):css('color','black'):wikitext(CFG.TEST_COLUMN_ACTUAL):done();for_,resultinipairs(testData.results)dolocalrow=testtable:tag('tr');row:addClass(result.errorand'test-result-error'or'test-result-success');row:tag('td'):addClass('test-status-icon'):wikitext(result.errorandfailIconorsuccessIcon):done()ifresult.errorthenrow:tag('td'):addClass('test-name'):wikitext(result.name.."/"..tostring(result.testname)):done();ifresult.expectedandresult.actualthenrow:tag('td'):css('overflow-wrap','break-word'):css('word-break','break-all'):css('font-family','monospace, monospace'):css('white-space','pre-wrap'):wikitext(mw.text.nowiki(tostring(result.expected))):done();row:tag('td'):css('overflow-wrap','break-word'):css('word-break','break-all'):css('font-family','monospace, monospace'):css('white-space','pre-wrap'):wikitext(mw.text.nowiki(tostring(result.actual))):done();elserow:tag('td'):css('overflow-wrap','break-word'):css('word-break','break-all'):attr('colspan','2'):wikitext(mw.text.nowiki(result.message)):done();endelserow:tag('td'):addClass('test-name'):wikitext(result.name):done();row:tag('td'):done();row:tag('td'):done();endendlocalheader=''iftestData.failureCount>0thenlocalmsg=mw.message.newRawMessage(CFG.TEST_FAILURE_SUMMARY,testData.failureCount):plain();msg=self.frame:preprocess(msg);ifCFG.CATEGORY.TEST_FAILUREthenmsg=CFG.CATEGORY.TEST_FAILURE..msg;endheader=header..failIcon..' '..msg..'\n';elseiftestData.successCount==1thenheader=header..successIcon..' '..CFG.TEST_SUCCESS_SINGLE..'\n';elseheader=header..successIcon..' '..string.format(CFG.TEST_SUCCESS_MULTIPLE,testData.successCount)..'\n';endendreturnheader..tostring(testtable);endreturnunittest