Customizing Source Locations in Rackunit Macros

:: Racket, meta-programming

Generating rackunit tests with Racket macros is an easy way to improve the quality of your unit tests and your unit testing experience. Macros not only save you the time and energy of writing boilerplate, but also allow you to customize how tests are specified.

So what’s the catch?

rackunit’s basic checks are, by and large, functions. They raise run-time errors when they fail, and run-time errors report the source locations of their call sites. In tests generated by purely pattern-based macros, these locations point inside the macro definitions. Giving useful source locations to macro-generated tests takes a little more work.

Let’s test the equal? function.

As a basic sanity check, I’d like to make sure equal? works on positive numbers, negative numbers, and zeros. Instead of writing each check manually, I will write a macro that plugs expression fragments into a code template. Generating this kind of boilerplate with a pattern-based macro is usually straight-forward.

> (define-syntax-rule (test-equal?* [a b] ...)
    (test-case "equal?" (check equal? a b) ...))
> (test-equal?*
   [ 1  1]
   [-1 -1]
   [ 0  0])

When all the checks pass, as they did above, everything seems fine. But what happens when a test fails?

> (test-equal?* [1 2])

--------------------

equal?

FAILURE

name:       check

location:   eval:71:24

params:     '(#<procedure:equal?> 1 2)

--------------------

The source location points to the check invocation inside the macro definition (inside the source for this article), but it would be more helpful if it pointed to the offending clause of test-equal?* instead. If test-equal?* was a procedural macro, it could copy the source location from the syntax object of each clause directly onto the syntax object of the corresponding check invocation.

Converting a define-syntax-rule macro into a procedural macro is a simple substitution. Given a pattern-based macro definition of the form

(define-syntax-rule (id arg ...) expr)

an equivalent procedural macro definition is

(define-syntax (id stx)
  (syntax-case stx () [(_ arg ...) #'expr]))

Accordingly, test-equal?* becomes:

(define-syntax (test-equal? stx)
  (syntax-case stx ()
    [(_ [a b] ...)
     #'(test-case "equal?" (check equal? a b) ...)]))

To get at the check invocations, the body of the test-case needs to be broken down further. I will do that by generating a list of check invocation expressions and then splicing them in. That list can be generated by mapping over the individual expressions bound to repeating pattern variables (here, a and b) and combining them with quasisyntax, unsyntax, and unsyntax-splicing.

(define-syntax (test-equal? stx)
  (syntax-case stx ()
    [(_ [a b] ...)
     #`(test-case "equal?"
         #,@(for/list ([a-stx (syntax->list #'(a ...))]
                       [b-stx (syntax->list #'(b ...))])
              #`(check equal? #,a-stx #,b-stx)))]))

Next, I need to introduce the original clauses and copy their source locations onto the corresponding check invocations. According to the shape of the syntax pattern (_ [a b] ...), turning the original macro invocation (bound to stx) into a list and discarding its head will produce the list of clauses. And finally, I can replace the quasisyntax wrapping the check invocation with quasisyntax/loc.

> (define-syntax (test-equal? stx)
    (syntax-case stx ()
      [(_ [a b] ...)
       #`(test-case "equal?"
           #,@(for/list ([clause-stx (cdr (syntax->list stx))]
                         [a-stx (syntax->list #'(a ...))]
                         [b-stx (syntax->list #'(b ...))])
                (quasisyntax/loc clause-stx
                  (check equal? #,a-stx #,b-stx))))]))

Now failed checks will report the location of the clause they came from. (Again, check line and column number against the source to be sure.)

> (test-equal?
   [ 1  1]
   [-1 -1]
   [ 1  2]
   [ 0  0])

--------------------

equal?

FAILURE

name:       check

location:   eval:163:3

params:     '(#<procedure:equal?> 1 2)

--------------------