Racket’s emphasis on functional programming naturally leads to an emphasis on unit testing. Why? Because as functions become self-contained—that is, they no longer rely on external state or mutation—they become easier to test. And when something is easy to test, it’s more likely to actually be tested.
Unit testing is the category of program testing that checks the behavior of individual functions—the eponymous “units”. (One among many testing categories.) Unit testing has two purposes:
To prove that the program does what it’s supposed to. (As opposed to “well, the program seems to work.”)
To make it possible to reliably refactor the program later. (As opposed to “well, that change didn’t seem to break the program.”)
Though unit testing is a virtuous habit, it’s always optional. If you’re programming in an experimental or exploratory way—meaning, intentionally making & breaking things—unit tests can be a distraction.
As the design of the program firms up, unit tests become increasingly valuable. They allow you to add rigor to your functions by nailing down expectations. In that way, unit tests are similar to contracts. But whereas contracts make guarantees about the outside interface of a function, unit tests validate its inner workings.
For released programs, unit tests are indispensable. They provide a baseline to measure the correctness of future revisions of the program. This is especially important when you’re making programming languages: others will be writing programs with your language, and you want to minimize the chance that revisions to the language will break those programs.
Racket includes a library called rackunit that allows us to create unit tests called checks. Each check is an assertion about the behavior of the program, usually consisting of a function call and the expected result. If the check is valid, it passes silently. If the check is invalid, an error is raised that tells us both the expected and actual result:
1 2 3 4 | (require rackunit) (define (plus x y) (+ x y)) (check-equal? (plus 10 14) 24) ; valid (check-equal? (plus 10 10) 24) ; invalid, actual result is 20 |
1 2 3 4 5 6 7 8 | -------------------- FAILURE name: check-equal? location: unsaved editor:6:0 actual: 20 expected: 24 expression: (check-equal? (plus 10 10) 24) -------------------- |
rackunit has built-in checks for the standard equality functions, and corresponding negated checks:
1 2 3 4 5 | (require rackunit) (check-eq? 'a 'a) ; symbols are guaranteed to be `eq?` (check-not-eq? "a" (format "~a" 'a)) ; strings are not (check-equal? "a" (format "~a" 'a)) ; strings can be `equal?` (check-not-equal? "a" 25) ; but not with numbers |
check-true and check-false are shorthand checks for predicates:
1 2 3 4 | (require rackunit) (check-true (symbol? 'a)) (check-false (symbol? "a")) (check-true (number? 42)) |
The special check-= can test whether a numerical result falls within a certain tolerance of a precise result, which is useful for inexact or probabilistic checks:
We can also check errors and exceptions with check-exn. For this check, we need two input arguments. First, a predicate that describes the exception we expect to get. Second, a test expression that raises an exception. We wrap this expression in a lambda to prevent it from being immediately evaluated. The check passes if the expression raises an exception that matches the predicate. For instance, (/ 25 0) will raise an exception of type exn:fail:contract?, but (/ 25 5) will not:
1 2 3 | (require rackunit) (check-exn exn:fail:contract? (lambda () (/ 25 0))) ; valid (check-exn exn:fail:contract? (lambda () (/ 25 5))) ; invalid |
Checks are easy to work with because they’re simple, flexible, and self-contained. Beware, however: as program code, they’re vulnerable to the same defects of mind that can afflict other parts of the code. There’s no way for rackunit to know whether your checks are complete or themselves correct. That remains your job.
Checks are only valuable when they’re being run. How often? As often as possible. For that reason, other Racket tools cooperate with the test submodule.
When you run a source file in DrRacket, it will also evaluate any submodule in the file called test. + Within DrRacket, you can change this default behavior through the menu item Language → Choose Language … → Submodules to Run. Though you can use module to make this submodule, it’s often convenient to use module+, which makes a submodule that automatically pulls in the existing bindings in the file (which you need for running checks):
1 2 3 4 | (define (f x) (expt x 2)) (module+ test (require rackunit) (check-equal? 16 (f 4))) ; valid |
Furthermore, a submodule made with module+ can be defined in pieces, so a source file can have tests interspersed with function definitions: + We only need to import rackunit once within the submodule.
1 2 3 4 5 6 7 8 | (define (f x) (expt x 2)) (module+ test (require rackunit) (check-equal? 16 (f 4))) ; valid (define (g x) (expt x 3)) (module+ test (check-equal? 64 (g 4))) ; valid |
This way, checks are close to the functions they test, and it’s easier to work between the two.
When the source file is run under normal circumstances (e.g., from the command line, or imported by another module), the test submodule will not be evaluated.
The raco pkg command-line tool can also automatically run tests.
When a package is installed with raco pkg install name, every file will be compiled, and any test submodules within the package will be run. See raco pkg install.
After installation, a package can be tested with raco test -p name. In this case, raco test will either run the test submodule (if it exists) or the whole file (if it doesn’t). See raco test.
Though test submodules are handy, a package author always has the choice whether to put checks in test submodules, or create separate files strictly for testing. Racket’s package-configuration tools allow even more granular control of which files get tested and which are omitted.