In contracts, we saw how a contract can be a helpful development tool, because it lets us specify the acceptable data types for a function’s input arguments and return value. Once the contract is in place, we’ll get a detailed error whenever the function misbehaves.
But like any code, a contract is not self-executing. To verify that a function and its contract work correctly, we need to call the function with some sample cases and see what happens. These are known as .
Unit tests are valuable because they remove guesswork:
Unit tests convince us that our function does what it’s supposed to. We make unit tests that cover all the characteristic input and output—and some uncharacteristic cases too. Once the tests pass, we know we’re done.
Unit tests make rewriting a function easier, because they create an objective baseline for the function’s behavior. Without unit tests, it’s much easier to refactor a function so that it appears to work, while accidentally introducing subtle defects.
We recall that encourages us to design functions so that their behavior depends only their input arguments (instead of external ) and return a value (instead of relying on or ). A side benefit of this approach is that it becomes easy to write unit tests, because our functions have no remote dependencies or effects.
For this reason, Racketeers often like to write unit tests for a function before they start writing that function, not after. It’s also common to put the unit tests near the function definition, so they can be worked in tandem.
Racket makes this style of coding easy with two features: the rackunit unit-testing library, and the test submodule.
The rackunit library helps us create unit tests called . To use it, we first add (require rackunit) to our program.
Then we write our checks. Each check is an assertion about the behavior of the function. A check usually consists of a function call and the expected result. If the check succeeds, it passes silently. If the check fails, an error is raised that tells us both the expected and actual result.
1 2 3 4 5
When we run this sample, the second check raises an error:
1 2 3 4 5 6 7 8
-------------------- FAILURE name: check-equal? location: unsaved-editor:6:0 actual: 21 expected: 22 expression: (check-equal? (our-div 42 2) 22) --------------------
We can write as many tests as we want. A thorough set of tests will cover obvious cases, edge cases, and error cases.
Because error cases don’t produce a return value, we instead check the error type. For instance, our-div will raise an error if we pass 0 as the denom argument. Instead of check-equal?, we check an error with check-exn (exn is short for exception). The first argument is the type of error we expect—in this case we’ll use the generic exn:fail? predicate. + It’s better policy, as it was with contracts, to use the narrowest error predicate that applies. For more about Racket’s built-in error types, see errors and exceptions. The second argument is our test case, wrapped in a lambda, which prevents the error from being triggered immediately:
1 2 3 4 5 6 7 8
This time, all three checks will pass. For other types of checks, see unit testing.
Putting unit tests directly next to our function isn’t wrong. But it can be inefficient. Unit tests take time—especially if we’re being thorough—and they don’t need to run every time a module is loaded.
To avoid this overhead, we could put our unit tests in a separate source file. But this can be a drag, because the functions and their corresponding unit tests then live in two places. It’s often more ergonomic to keep them together.
Thus, as a convenience, DrRacket specially cooperates with an optional submodule called test. When we have the source file for a module open in DrRacket and click Run, DrRacket will run the body of the module as usual, but will also run the test submodule (if it exists). Otherwise—for instance, if the module is imported into another module with require—its test submodule will be ignored. This way, we can easily (and continually) run our tests when we’re actively developing the module. + The test submodule also cooperates with other Racket tools, like raco test.
It automatically imports the bindings available in its surrounding module, including variables and functions. This is helpful because our unit tests will be calling those functions.
When multiple module+ declarations for the same submodule appear in a source file, they’re concatenated into a single submodule. Thus, module+ lets us build up a submodule piece by piece. This is helpful because we can still keep unit tests for a certain function next to the function.
Because of these special behaviors, a submodule declared with module+ necessarily runs after its enclosing module is evaluated.
Starting with our example above, let’s move our unit tests into a test submodule:
1 2 3 4 5 6 7 8 9 10
When we run this code, DrRacket will first run the body of the module that contains the definition of our-div. Then it will assemble the pieces of the test submodule—putting together the (require rackunit) piece at the top with the checks at the bottom—and run it. If we were to change one of the tests so it fails, we’d get the same kind of error message as before.
Suppose we add a new function called our-mult. We could organize the function and its related tests like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
(module+ test (require rackunit)) (define (our-div num denom) (/ num denom)) (module+ test (check-equal? (our-div 42 6) 7) (check-equal? (our-div 42 2) 21) (check-exn exn:fail? (lambda () (our-div 42 0)))) (define (our-mult factor1 factor2) (* factor1 factor2)) (module+ test (check-equal? (our-mult 6 7) 42) (check-exn exn:fail? (lambda () (our-mult "a" "b"))))
Now that we know how to use rackunit and the test submodule, we’ll add some unit tests for jsonic-token? and make-tokenizer.
We introduce the test submodule and import rackunit. Then we add the unit tests. For predicates like jsonic-token?, it’s convenient to use check-true and check-false. These are like check-equal?, but they automatically compare the result to #t or #f. We write tests that cover each branch of the predicate, and one that fails:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
#lang br/quicklang (require brag/support racket/contract) (module+ test (require rackunit)) (define (jsonic-token? x) (or (eof-object? x) (string? x) (token-struct? x))) (module+ test (check-true (jsonic-token? eof)) (check-true (jsonic-token? "a string")) (check-true (jsonic-token? (token 'A-TOKEN-STRUCT "hi"))) (check-false (jsonic-token? 42))) (define (make-tokenizer port) (define (next-token) (define jsonic-lexer (lexer [(from/to "//" "\n") (next-token)] [(from/to "@$" "$@") (token 'SEXP-TOK (trim-ends "@$" lexeme "$@"))] [any-char (token 'CHAR-TOK lexeme)])) (jsonic-lexer port)) next-token) (provide (contract-out [make-tokenizer (input-port? . -> . (-> jsonic-token?))]))
Then we add tests for make-tokenizer. This function is a little trickier, because we need to feed it an input-port?, and what we get back is a function that emits tokens. For testing purposes, it would be simpler to pass in a string representing a piece of source code, and get back a list of tokens.
Good news—the brag/support module provides a helper function, apply-tokenizer-maker, that handles this minor housekeeping. We can use this function with check-equal? to write our tests.
Now we just need some test expressions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
#lang br/quicklang (require brag/support racket/contract) (module+ test (require rackunit)) (define (jsonic-token? x) (or (eof-object? x) (string? x) (token-struct? x))) (module+ test (check-true (jsonic-token? eof)) (check-true (jsonic-token? "a string")) (check-true (jsonic-token? (token 'A-TOKEN-STRUCT "hi"))) (check-false (jsonic-token? 42))) (define (make-tokenizer port) (define (next-token) (define jsonic-lexer (lexer [(from/to "//" "\n") (next-token)] [(from/to "@$" "$@") (token 'SEXP-TOK (trim-ends "@$" lexeme "$@"))] [any-char (token 'CHAR-TOK lexeme)])) (jsonic-lexer port)) next-token) (provide (contract-out [make-tokenizer (input-port? . -> . (-> jsonic-token?))])) (module+ test (check-equal? (apply-tokenizer-maker make-tokenizer "// comment\n") empty) (check-equal? (apply-tokenizer-maker make-tokenizer "@$ (+ 6 7) $@") (list (token-struct 'SEXP-TOK " (+ 6 7) " #f #f #f #f #f))) (check-equal? (apply-tokenizer-maker make-tokenizer "hi") (list (token-struct 'CHAR-TOK "h" #f #f #f #f #f) (token-struct 'CHAR-TOK "i" #f #f #f #f #f))))
But more good news—we already wrote them when we first wrote make-tokenizer. Then, we tested make-tokenizer by inspecting the results we got by entering these expressions on the REPL. Now, we take the same expressions and results and move them inside unit tests.
At this point, the module seems to run the same way it always did. With unit tests, no news is good news. If we were to monkey with the code inside jsonic-token? or make-tokenizer—for those who haven’t tried it, go ahead—our unit tests would start raising errors.
With unit tests, we can continually & automatically verify the behavior of our functions. If we change these functions in a way that alters this behavior, the unit tests will flag the problem immediately. This makes it easier to improve the functions, because we have immediate testing feedback.
Of course, not every update to a function will be backward-compatible with our existing tests (we’ll see an example of that in source locations). When that’s the case, we can update the tests too. Like contracts, tests aren’t meant to inhibit changes. Rather, by making the expected behavior of a function explicit, they help us proceed in a more disciplined and careful way.
One limitation of the test submodule is that we can only use it in modules that use a language that supports module+. Our "parser.rkt" module, for instance, is written in #lang brag, which doesn’t support module+:
1 2 3 4
#lang brag jsonic-program : (jsonic-char | jsonic-sexp)* jsonic-char : CHAR-TOK jsonic-sexp : SEXP-TOK
In this case, we can still make unit tests for parse using rackunit. But we’ll have to move them into a separate file. Slightly less convenient, but a lot better than not testing at all.
As we did with make-tokenizer, we’ll take the informal tests we wrote in the last tutorial and convert them into real unit tests in a new file called "parser-test.rkt".
Recall that our parser takes a list of tokens and turns them into a parse tree. Therefore, we need to import our tokenizer so that we can process strings of source code into tokens. We’ll also import brag/support so we can use apply-tokenizer-maker function again.
Then we can add our previous test cases, converted into check-equal? tests. To make things easier, in our tests we’ll use our parser’s parse-to-datum function, which works the same as parse, but converts the result into a simple hierarchical list.
The whole "parser-test.rkt" looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
#lang br (require jsonic/parser jsonic/tokenizer brag/support rackunit) (check-equal? (parse-to-datum (apply-tokenizer-maker make-tokenizer "// line commment\n")) '(jsonic-program)) (check-equal? (parse-to-datum (apply-tokenizer-maker make-tokenizer "@$ 42 $@")) '(jsonic-program (jsonic-sexp " 42 "))) (check-equal? (parse-to-datum (apply-tokenizer-maker make-tokenizer "hi")) '(jsonic-program (jsonic-char "h") (jsonic-char "i"))) (check-equal? (parse-to-datum (apply-tokenizer-maker make-tokenizer "hi\n// comment\n@$ 42 $@")) '(jsonic-program (jsonic-char "h") (jsonic-char "i") (jsonic-char "\n") (jsonic-sexp " 42 ")))
When we run this file, we should get no errors, because we already verified that these test cases work.
Now, let’s deliberately break "parser.rkt"—for instance, by adding a second CHAR-TOK to the second production rule:
1 2 3 4
#lang brag jsonic-program : (jsonic-char | jsonic-sexp)* jsonic-char : CHAR-TOK CHAR-TOK jsonic-sexp : SEXP-TOK
This time, when we run "parser-test.rkt", one of our checks will fail:
1 2 3 4 5 6 7 8
-------------------- FAILURE name: check-equal? location: parser-test.rkt:12:0 actual: (jsonic-program (jsonic-char "h" "i")) expected: (jsonic-program (jsonic-char "h") (jsonic-char "i")) expression: (check-equal? (parse-to-datum (apply-tokenizer-maker make-tokenizer "hi")) (quote (jsonic-program (jsonic-char "h") (jsonic-char "i")))) --------------------
Let’s reset "parser.rkt" to its original state and move on.
Those new to this idea of alternating between coding and testing sometimes consider it a chore and a bore. “Why waste time writing tests that’ll have to be revised later? Why not just skip it?“
The choice is yours, of course. The problem is that any function is likely to be used multiple places in a program. Therefore, the complexity of interactions between functions grows faster than the number of functions.
Sure, writing tests takes a little more effort at the outset. But over the life of a program, that investment is paid back in time and annoyance saved. Working on a program with a thorough suite of unit tests is a pleasure. Working on a program without tests—now that’s a chore and a bore.