Module:ScribuntoUnit
Appearance
From Meta, a Wikimedia project coordination wiki
Module documentation
You might want to create a documentation page for this Scribunto module.
Editors can experiment in this module’s sandbox (create | mirror) and testcases (create) pages.
Please add categories to the /doc subpage. Subpages of this module.
Editors can experiment in this module’s sandbox (create | mirror) and testcases (create) pages.
Please add categories to the /doc subpage. Subpages of this module.
------------------------------------------------------------------------------- -- Unit tests for Scribunto. ------------------------------------------------------------------------------- require('strict') localDebugHelper={} localScribuntoUnit={} -- The cfg table contains all localisable strings and configuration, to make it -- easier to port this module to another wiki. localcfg=mw.loadData('Module:ScribuntoUnit/config') ------------------------------------------------------------------------------- -- Concatenates keys and values, ideal for displaying a template or parser function 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=1 localfirst=true localunnamedArguments=true fork,vinpairs(table)do iffirstthen first=false else concatted=concatted..separator end ifk==iandunnamedArgumentsthen i=i+1 concatted=concatted..tostring(v) else unnamedArguments=false concatted=concatted..tostring(k)..keySeparator..tostring(v) end end returnconcatted end ------------------------------------------------------------------------------- -- 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~=type2then returnfalse end iftype1~='table'then returnt1==t2 end localmetatable=getmetatable(t1) ifnotignoreMetatableandmetatableandmetatable.__eqthen returnt1==t2 end fork1,v1inpairs(t1)do localv2=t2[k1] ifv2==nilornotDebugHelper.deepCompare(v1,v2)then returnfalse end end fork2,v2inpairs(t2)do ift1[k2]==nilthen returnfalse end end returntrue 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 -- functionDebugHelper.raise(details,level) level=(levelor1)+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. -- functionScribuntoUnit:markTestSkipped() DebugHelper.raise({ScribuntoUnit=true,skipped=true},3) end ------------------------------------------------------------------------------- -- Unconditionally fail a test -- @param message optional description of the test -- functionScribuntoUnit:fail(message) DebugHelper.raise({ScribuntoUnit=true,text="Test failed",message=message},2) end ------------------------------------------------------------------------------- -- Checks that the input is true -- @param message optional description of the test -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertTrue(actual,message,level) ifnotactualthen DebugHelper.raise({ScribuntoUnit=true,text=string.format("Failed to assert that %s is true",tostring(actual)),message=message},2+(levelor0)) end end ------------------------------------------------------------------------------- -- Checks that the input is false -- @param message optional description of the test -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertFalse(actual,message,level) ifactualthen DebugHelper.raise({ScribuntoUnit=true,text=string.format("Failed to assert that %s is false",tostring(actual)),message=message},2+(levelor0)) 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 -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertStringContains(pattern,s,plain,message,level) iftype(pattern)~='string'then DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format("Pattern type error (expected string, got %s)",type(pattern)), message=message },2+(levelor0)) end iftype(s)~='string'then DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format("String type error (expected string, got %s)",type(s)), message=message },2+(levelor0)) end ifnotmw.ustring.find(s,pattern,nil,plain)then DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format('Failed to find %s "%s" in string "%s"',plainand"plain string"or"pattern",pattern,s), message=message },2+(levelor0)) 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 -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertNotStringContains(pattern,s,plain,message,level) iftype(pattern)~='string'then DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format("Pattern type error (expected string, got %s)",type(pattern)), message=message },2+(levelor0)) end iftype(s)~='string'then DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format("String type error (expected string, got %s)",type(s)), message=message },2+(levelor0)) end locali,j=mw.ustring.find(s,pattern,nil,plain) ifithen localmatch=mw.ustring.sub(s,i,j) DebugHelper.raise({ ScribuntoUnit=true, text=mw.ustring.format('Found match "%s" for %s "%s"',match,plainand"plain string"or"pattern",pattern), message=message },2+(levelor0)) 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") -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertEquals(expected,actual,message,level) iftype(expected)=='number'andtype(actual)=='number'then self:assertWithinDelta(expected,actual,1e-8,message,(levelor0)+1) elseifexpected~=actualthen 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+(levelor0)) end end ------------------------------------------------------------------------------- -- Checks that an input does not have the expected value. -- @param message optional description of the test -- @example assertNotEquals(5, add(2,2), "2+2 should not be 5") -- @param level optional number to raise error stack level by -- functionScribuntoUnit:assertNotEquals(expected,actual,message,level) iftype(expected)=='number'andtype(actual)=='number'then self:assertNotWithinDelta(expected,actual,1e-8,message,(levelor0)+1) elseifexpected==actualthen DebugHelper.raise({ ScribuntoUnit=true, text=string.format("Failed to assert that %s does not equal expected %s",tostring(actual),tostring(expected)), actual=actual, expected=expected, message=message, },2+(levelor0)) end end ------------------------------------------------------------------------------- -- Validates that both the expected and actual values are numbers -- @param message optional description of the test -- @param level number to raise error stack level by -- localfunctionvalidateNumbers(expected,actual,message,level) iftype(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, },3+level) end iftype(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, },3+level) end end ------------------------------------------------------------------------------- -- Checks that 'actual' is within 'delta' of 'expected'. -- @param message optional description of the test -- @param level optional number to raise error stack level by -- @example assertWithinDelta(1/3, 3/9, 0.000001, "3/9 should be 1/3") functionScribuntoUnit:assertWithinDelta(expected,actual,delta,message,level) validateNumbers(expected,actual,message,levelor0) localdiff=expected-actual ifdiff<0thendiff=-diffend-- instead of importing math.abs ifdiff>deltathen 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+(levelor0)) end end ------------------------------------------------------------------------------- -- Checks that 'actual' is not within 'delta' of 'expected'. -- @param message optional description of the test -- @param level optional number to raise error stack level by -- @example assertNotWithinDelta(1/3, 2/3, 0.000001, "1/3 should not be 2/3") functionScribuntoUnit:assertNotWithinDelta(expected,actual,delta,message,level) validateNumbers(expected,actual,message,levelor0) localdiff=expected-actual ifdiff<0thendiff=-diffend-- instead of importing math.abs ifdiff<=deltathen DebugHelper.raise({ ScribuntoUnit=true, text=string.format("Failed to assert that %f is not within %f of expected %f",actual,delta,expected), actual=actual, expected=expected, message=message, },2+(levelor0)) end end ------------------------------------------------------------------------------- -- Checks that a table has the expected value (including sub-tables). -- @param message optional description of the test -- @param level optional number to raise error stack level by -- @example assertDeepEquals({{1,3}, {2,4}}, partition(odd, {1,2,3,4})) functionScribuntoUnit:assertDeepEquals(expected,actual,message,level) ifnotDebugHelper.deepCompare(expected,actual)then iftype(expected)=='table'then expected=mw.dumpObject(expected) end iftype(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+(levelor0)) 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}}") functionScribuntoUnit:assertResultEquals(expected,text,message) localframe=self.frame localactual=frame:preprocess(text) ifexpected~=actualthen 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!}}") functionScribuntoUnit:assertSameResult(text1,text2,message) localframe=self.frame localprocessed1=frame:preprocess(text1) localprocessed2=frame:preprocess(text2) ifprocessed1~=processed2then 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 parser function gives the expected output. -- @param message optional description of the test -- @example assertParserFunctionEquals("Hello world", "msg:concat", {"Hello", " world"}) functionScribuntoUnit:assertParserFunctionEquals(expected,pfname,args,message) localframe=self.frame localactual=frame:callParserFunction{name=pfname,args=args} ifexpected~=actualthen DebugHelper.raise({ ScribuntoUnit=true, text=string.format("Failed to assert that %s with args %s equals expected %s after preprocessing", DebugHelper.concatWithKeys(args),pfname,expected), actual=actual, actualRaw=pfname, expected=expected, 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"}) functionScribuntoUnit:assertTemplateEquals(expected,template,args,message) localframe=self.frame localactual=frame:expandTemplate{title=template,args=args} ifexpected~=actualthen 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 ------------------------------------------------------------------------------- -- 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 test -- @param varargs optional arguments to be passed to fn functionScribuntoUnit:assertThrows(fn,expectedMessage,message,...) localsucceeded,actualMessage=pcall(fn,...) ifsucceededthen DebugHelper.raise({ ScribuntoUnit=true, text='Expected exception but none was thrown', message=message, },2) end -- For strings, strip the line number added to the error message actualMessage=type(actualMessage)=='string' andstring.match(actualMessage,'Module:[^:]*:[0-9]*: (.*)') oractualMessage localmessagesMatch=DebugHelper.deepCompare(expectedMessage,actualMessage) ifexpectedMessageandnotmessagesMatchthen DebugHelper.raise({ ScribuntoUnit=true, expected=expectedMessage, actual=actualMessage, text=string.format('Expected exception with message %s, but got message %s', tostring(expectedMessage),tostring(actualMessage) ), message=message },2) end end ------------------------------------------------------------------------------- -- Checks whether a function doesn't throw an error -- @param fn the function to test -- @param message optional description of the test -- @param varargs optional arguments to be passed to fn functionScribuntoUnit:assertDoesNotThrow(fn,message,...) localsucceeded,actualMessage=pcall(fn,...) ifsucceededthen return end -- For strings, strip the line number added to the error message actualMessage=type(actualMessage)=='string' andstring.match(actualMessage,'Module:[^:]*:[0-9]*: (.*)') oractualMessage DebugHelper.raise({ ScribuntoUnit=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. -- functionScribuntoUnit.new() localself={} self._tests={} setmetatable(self,{ __index=ScribuntoUnit, __newindex=function(t,k,v) iftype(k)=="string"andk:find('^test')andtype(v)=="function"then -- Store test functions in the order they were defined table.insert(self._tests,{name=k,test=v}) else rawset(t,k,v) end end }) self.run=function(frame)returnScribuntoUnit.run(self,frame)end returnself end ------------------------------------------------------------------------------- -- Resets global counters -- functionScribuntoUnit:init(frame) self.frame=frameormw.getCurrentFrame() 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 -- functionScribuntoUnit:runTest(name,test) localsuccess,details=pcall(test,self) ifsuccessthen self.successCount=self.successCount+1 table.insert(self.results,{name=name,success=true}) elseiftype(details)~='table'ornotdetails.ScribuntoUnitthen-- 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)}) elseifdetails.skippedthen self.skipCount=self.skipCount+1 table.insert(self.results,{name=name,skipped=true}) else self.failureCount=self.failureCount+1 localmessage=details.sourceor"" ifdetails.messagethen 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,testname=details.message}) end end ------------------------------------------------------------------------------- -- Runs all tests and displays the results. -- functionScribuntoUnit:runSuite(frame) self:init(frame) fori,testDetailsinipairs(self._tests)do self:runTest(testDetails.name,testDetails.test) 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() -- functionScribuntoUnit:run(frame) localtestData=self:runSuite(frame) ifframeandframe.argsthen returnself:displayResults(testData,frame.args.displayModeor'table') else returnself:displayResults(testData,'log') end end ------------------------------------------------------------------------------- -- Displays test results -- @param displayMode: 'table', 'log' or 'short' -- functionScribuntoUnit:displayResults(testData,displayMode) ifdisplayMode=='table'then returnself:displayResultsAsTable(testData) elseifdisplayMode=='log'then returnself:displayResultsAsLog(testData) elseifdisplayMode=='short'then returnself:displayResultsAsShort(testData) else error('unknown display mode') end end functionScribuntoUnit:displayResultsAsLog(testData) iftestData.failureCount>0then mw.log('FAILURES!!!') elseiftestData.skipCount>0then 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_,resultinipairs(testData.results)do ifresult.errorthen mw.log(string.format('%s: %s',result.name,result.message)) end end end functionScribuntoUnit:displayResultsAsShort(testData) localtext=string.format(cfg.shortResultsFormat,testData.successCount,testData.failureCount,testData.skipCount) iftestData.failureCount>0then text='<span class="error">'..text..'</span>' end returntext end functionScribuntoUnit:displayResultsAsTable(testData) localsuccessIcon,failIcon=self.frame:preprocess(cfg.successIndicator),self.frame:preprocess(cfg.failureIndicator) localtext='' iftestData.failureCount>0then localmsg=mw.message.newRawMessage(cfg.failureSummary,testData.failureCount):plain() msg=self.frame:preprocess(msg) ifcfg.failureCategorythen msg=cfg.failureCategory..msg end text=text..failIcon..' '..msg..'\n' else text=text..successIcon..' '..cfg.successSummary..'\n' end text=text..'{| class="wikitable scribunto-test-table"\n' text=text..'!\n! '..cfg.nameString..'\n! '..cfg.expectedString..'\n! '..cfg.actualString..'\n' for_,resultinipairs(testData.results)do text=text..'|-\n' ifresult.errorthen text=text..'| '..failIcon..'\n| ' if(result.expectedandresult.actual)then localname=result.name ifresult.testnamethen name=name..' / '..result.testname end text=text..mw.text.nowiki(name)..'\n| '..mw.text.nowiki(tostring(result.expected))..'\n| '..mw.text.nowiki(tostring(result.actual))..'\n' else text=text..mw.text.nowiki(result.name)..'\n| '..' colspan="2" | '..mw.text.nowiki(result.message)..'\n' end else text=text..'| '..successIcon..'\n| '..mw.text.nowiki(result.name)..'\n|\n|\n' end end text=text..'|}\n' returntext end returnScribuntoUnit