Day 8: minitest
I'm a big fan of Practical Common Lisp and It has a chapter on building a unittest framework using macros and I didn't get the chance to tinker with nim macros just yet, So today we will be building almost the same thing in nim.
So what's up?
Imagine you want to check for some expression and print a specific message donating the expression
doAssert(1==2, "1 == 2 failed")
Here we want to assure that 1==2 or show a message with 1==2 failed
and it goes on for whatever we want to check for
doAssert(1+2==3, "1+2 == 3 failed")
doAssert(5*2==10, "5*2 == 10 failed")
We can already see the boilerplate here, repeating the expression twice one for the check and one for the message itself.
What to expect?
We expect having a DSL to remove the boilerplate we're suffering from in the prev. section.
check(3==1+2)
check(6+5*2 == 16)
And this will print
3 == 1 + 2 .. passed
6 + 5 * 2 == 16 .. passed
And it should evolve to allow grouping of test checks
check(3==1+2)
check(6+5*2 == 16)
suite "Arith":
check(1+2==3)
check(3+2==5)
suite "Strs":
check("HELLO".toLowerAscii() == "hello")
check("".isNilOrEmpty() == true)
Resulting something like this
3 == 1 + 2 .. passed
6 + 5 * 2 == 16 .. passed
==================================================
Arith
==================================================
1 + 2 == 3 .. passed
3 + 2 == 5 .. passed
==================================================
Strs
==================================================
"HELLO".toLowerAscii() == "hello" .. passed
"".isNilOrEmpty() == true .. passed
Implementation
So nim has two way to do macros
templates
Which are like functions that called in compilation time like preprocessor
From the nim manual
template `!=` (a, b: untyped): untyped =
# this definition exists in the System module
not (a == b)
assert(5 != 6) # the compiler rewrites that to: assert(not (5 == 6))
so in compile time 5 != 6
will be converted into not ( 5 == 6)
and the whole expression will be assert(not ( 5== 6))
So what're we gonna do is check for the passed expression to convert it to a string to be printed in the terminal output and if the expression fails we append failed
message or any other custom failure message
template check*(exp:untyped, failureMsg:string="failed", indent:uint=0): void =
let indentationStr = repeat(' ', indent)
let expStr: string = astToStr(exp)
var msg: string
if not exp:
if msg.isNilOrEmpty():
msg = indentationStr & expStr & " .. " & failureMsg
else:
msg = indentationStr & expStr & " .. passed"
echo(msg)
-
untyped
means the expression doesn't have to have a type yet, imagine passing variable name that doesn't exist yetdefineVar(myVar, 5)
so heremyVar
needs to be untyped or the compiler will complain. check the manual for more info https://nim-lang.org/docs/manual.html#templates -
astToStr
converts the ASTexp
to a string -
indent
amount of spaces prefixing the message.
Macros
Nim provides us with a way to access the AST in a very low level when we templates don't cut it.
What we expected is having a suite
macro
suite "Strs":
check("HELLO".toLowerAscii() == "hello")
check("".isNilOrEmpty() == true)
that takes a name
for the suite and bunch of statements
- Please note there're two kind of macros and we're interested in the
statements macro
here - Statments macro is a macro that has
colon
:
operator followed by bunch of statements
dumpTree
dumpTree is amazing to debug the ast and print them in a good visual way
dumpTree:
suite "Strs":
check("HELLO".toLowerAscii() == "hello")
Ident ident"suite"
StrLit Strs
StmtList
Call
Ident ident"check"
Infix
Ident ident"=="
Call
DotExpr
StrLit HELLO
Ident ident"toLowerAscii"
StrLit hello
dumpTree
says it gotIdentifier Ident
namedsuite
suite
containsStringLiteral
node with valueStrs
suite
containsStmtList
node- first statement in
StmtList
is acall
statement call
statement consist ofprocedure
namecheck
in this case and args list and so on..
macro suite*(name:string, exprs: untyped) : typed =
Here, we define a macro suite
takes name
and bunch of statements exprs
- Macro must return an AST in our case will be list of statements of
check
call statemenets - Need the messages to be indented
To achieve the indentation we can either print tab before calling check
or overwrite check to pass indent
option, we will go with overwrite the check
call ASTs
var result = newStmtList()
We will be returning a list of statments right?
let equline = newCall("repeat", newStrLitNode("="), newIntLitNode(50))
statement node that equals repeat("=", 50)
let writeEquline = newCall("echo", equline)
statement node the equals echo repeat("=", 50)
add(result, writeEquline, newCall("echo", name))
add(result, writeEquline)
this will generate
================
$name
================
Now we iterate over the passed statements to suite
macro and check for its kind
for i in 0..<exprs.len:
var exp = exprs[i]
let expKind = exp.kind
case expKind
of nnkCall:
case exp[0].kind
of nnkIdent:
let identName = $exp[0].ident
if identName == "check":
- If we're in a
check
call we will convert it fromcheck(expr)
=>check(expr, "", 1)
var checkWithIndent = exp
checkWithIndent.add(newStrLitNode(""))
checkWithIndent.add(newIntLitNode(1))
add(result, checkWithIndent)
otherwise we add any other statement as is unprocessesed.
else:
add(result, exp)
else:
discard
return result
Code is available on https://github.com/xmonader/nim-minitest