Jump to content
Wikimedia Meta-Wiki

Module:ScribuntoUnit

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

AltStyle によって変換されたページ (->オリジナル) /