← Back to DevBytes

Testing Strategies for Racket Applications

Introduction to Testing in Racket

Testing is a critical part of the software development lifecycle, and Racket, as a language born from the Lisp family, offers powerful tools to make testing both expressive and ergonomic. Whether you are building a small script, a web application, or a domain-specific language, having a solid testing strategy ensures your code behaves as expected and continues to do so as it evolves.

Racket ships with rackunit, a unit testing framework that integrates seamlessly with the language. Beyond basic assertions, Racket's macro system and module structure enable advanced testing techniques such as property-based testing, mocking, and test-driven development workflows.

Why Testing Matters in Racket Applications

Racket's flexibility—particularly its macro system and dynamic typing—makes it easy to write code quickly, but that same flexibility can introduce subtle bugs. Testing provides a safety net that catches regressions and documents expected behavior. Here are the key reasons testing matters:

Getting Started with RackUnit

rackunit is Racket's standard testing library. It provides a rich set of assertions and check forms. To use it, simply require the module in your test file.

Basic Assertions

Here is a simple example demonstrating the most common RackUnit checks:

#lang racket

(require rackunit)

;; Basic equality check
(check-equal? (+ 1 1) 2)

;; Check that two values are not equal
(check-not-equal? 1 2)

;; Check that a value is of a specific type
(check-pred number? 42)

;; Check that an expression raises an exception
(check-exn exn:fail? (lambda () (error "something went wrong")))

;; Check that a value is true
(check-true (boolean? #t))

;; Check that a value is false
(check-false (boolean? "not a boolean"))

Each check-* form evaluates its arguments and reports failures. When run in DrRacket, failures are highlighted directly in the editor. When run from the command line, failures produce detailed output.

Organizing Tests with test-case

For better organization, group related assertions using test-case:

#lang racket

(require rackunit)

(define (fizzbuzz n)
  (cond
    [(and (zero? (modulo n 3)) (zero? (modulo n 5))) "FizzBuzz"]
    [(zero? (modulo n 3)) "Fizz"]
    [(zero? (modulo n 5)) "Buzz"]
    [else (number->string n)]))

(test-case
 "fizzbuzz returns correct values"
 (check-equal? (fizzbuzz 1) "1")
 (check-equal? (fizzbuzz 3) "Fizz")
 (check-equal? (fizzbuzz 5) "Buzz")
 (check-equal? (fizzbuzz 15) "FizzBuzz")
 (check-equal? (fizzbuzz 30) "FizzBuzz"))

Structuring Test Files in Your Project

A common convention in Racket projects is to place tests in a separate directory, often called tests/ or alongside source files with a _test.rkt suffix. Here is a typical project layout:

my-app/
  my-app/
    main.rkt
    utils.rkt
    handlers.rkt
  tests/
    utils_test.rkt
    handlers_test.rkt
    run-all.rkt
  info.rkt

The info.rkt file can declare test modules so that raco test discovers them automatically:

#lang info

(define test-omit-paths '())
(define test-paths '("tests"))

You can then run all tests with:

raco test tests/

Unit Testing Strategies

Unit testing focuses on verifying individual functions in isolation. In Racket, the module system makes this straightforward. Each module exposes a set of functions, and your test module imports and exercises them.

Testing Pure Functions

Pure functions—those without side effects—are the easiest to test. They take inputs and return outputs deterministically:

#lang racket

(provide calculate-discount)

(define (calculate-discount price percentage)
  (unless (and (number? price) (positive? price))
    (error 'calculate-discount "price must be a positive number"))
  (unless (and (number? percentage) (<= 0 percentage 100))
    (error 'calculate-discount "percentage must be between 0 and 100"))
  (- price (* price (/ percentage 100.0))))

The corresponding test file:

#lang racket

(require rackunit
         "../my-app/utils.rkt")

(test-case
 "calculate-discount with valid inputs"
 (check-equal? (calculate-discount 100 10) 90.0)
 (check-equal? (calculate-discount 50 50) 25.0)
 (check-equal? (calculate-discount 200 0) 200.0)
 (check-equal? (calculate-discount 200 100) 0.0))

(test-case
 "calculate-discount rejects invalid inputs"
 (check-exn exn:fail? (lambda () (calculate-discount -10 5)))
 (check-exn exn:fail? (lambda () (calculate-discount 100 150)))
 (check-exn exn:fail? (lambda () (calculate-discount "100" 10))))

Testing Functions with Side Effects

When functions interact with external state—files, databases, or network—you need strategies to isolate them. One approach is dependency injection, where you pass in the side-effecting operations as parameters:

#lang racket

(provide process-orders)

(define (process-orders orders save-fn)
  (for ([order orders])
    (when (valid-order? order)
      (save-fn order))))

(define (valid-order? order)
  (and (hash? order)
       (hash-has-key? order 'id)
       (hash-has-key? order 'total)
       (positive? (hash-ref order 'total 0))))

In tests, you can pass a mock save function that records calls instead of writing to a real database:

#lang racket

(require rackunit
         "../my-app/handlers.rkt")

(test-case
 "process-orders saves only valid orders"
 (define saved '())
 (define mock-save (lambda (order) (set! saved (cons order saved))))

 (define orders
   (list (hash 'id 1 'total 100.0)
         (hash 'id 2 'total 0)
         (hash 'id 3 'total 50.0)))

 (process-orders orders mock-save)

 (check-equal? (length saved) 2)
 (check-equal? (hash-ref (first saved) 'id) 3)
 (check-equal? (hash-ref (second saved) 'id) 1))

Property-Based Testing

Property-based testing is a powerful technique where you define properties that should hold for all valid inputs, and the framework generates random test cases to verify them. Racket does not ship with a built-in property-based testing library, but the quickcheck package from the Racket package catalog provides this capability.

Install it with:

raco pkg install quickcheck

Example: Testing a Sorting Function

#lang racket

(require rackunit
         quickcheck
         quickcheck/generator)

(define (my-sort lst)
  (if (null? lst)
      '()
      (let ([pivot (car lst)]
            [rest (cdr lst)])
        (append
         (my-sort (filter (lambda (x) (< x pivot)) rest))
         (list pivot)
         (my-sort (filter (lambda (x) (>= x pivot)) rest))))))

;; Property: sorting produces a list in non-decreasing order
(define-property sorted-list-is-ordered
  ([lst (list-of integer)])
  (define sorted (my-sort lst))
  (or (null? sorted)
      (for/and ([a sorted] [b (cdr sorted)])
        (<= a b))))

;; Property: sorting preserves length
(define-property sort-preserves-length
  ([lst (list-of integer)])
  (= (length lst) (length (my-sort lst))))

;; Property: sorting is idempotent
(define-property sort-is-idempotent
  ([lst (list-of integer)])
  (equal? (my-sort lst) (my-sort (my-sort lst))))

(check-property sorted-list-is-ordered)
(check-property sort-preserves-length)
(check-property sort-is-idempotent)

Property-based testing excels at finding edge cases you might not think of manually. The framework will generate hundreds or thousands of random inputs, and if a property fails, it often shrinks the failing case to a minimal example.

Testing Macros

One of Racket's distinguishing features is its powerful macro system. Testing macros requires verifying both their expansion and their runtime behavior. Here is an example:

#lang racket

(provide unless-positive)

(define-syntax unless-positive
  (syntax-rules ()
    [(_ condition body ...)
     (if (positive? condition)
         (begin body ...)
         (void))]))

Testing the macro:

#lang racket

(require rackunit
         "../my-app/utils.rkt")

(test-case
 "unless-positive executes body when condition is positive"
 (define result '())
 (unless-positive 5
   (set! result (cons 'executed result)))
 (check-equal? result '(executed)))

(test-case
 "unless-positive does nothing when condition is not positive"
 (define result '())
 (unless-positive -3
   (set! result (cons 'executed result)))
 (check-equal? result '()))

(test-case
 "unless-positive handles zero"
 (define result '())
 (unless-positive 0
   (set! result (cons 'executed result)))
 (check-equal? result '()))

For more advanced macro testing, you can use expand-once or syntax->datum to inspect macro expansion programmatically:

#lang racket

(require rackunit
         "../my-app/utils.rkt")

(test-case
 "unless-positive expands correctly"
 (define stx #'(unless-positive 42 (displayln "hi")))
 (define expanded (expand-once stx))
 (check-equal? (syntax->datum expanded)
               '(if (positive? 42)
                    (begin (displayln "hi"))
                    (void))))

Integration Testing

While unit tests verify individual components, integration tests verify that multiple components work together correctly. For web applications built with the Racket web server, you can test HTTP endpoints without starting a real server by using the web-server/servlet and related testing utilities.

Testing Web Handlers

#lang racket

(require web-server/http
         web-server/servlet
         racket/contract)

(provide hello-handler)

(define (hello-handler req)
  (response/xexpr
   '(html (head (title "Hello"))
          (body (h1 "Hello, World!")))))

Testing the handler:

#lang racket

(require rackunit
         web-server/http
         racket/port
         "../my-app/handlers.rkt")

(test-case
 "hello-handler returns expected HTML"
 (define req (make-request #"GET" (string->url "http://localhost/hello")
                           '() '() #f "127.0.0.1" 80 #f))
 (define resp (hello-handler req))
 (check-equal? (response-code resp) 200)
 (define body-string
   (with-output-to-string
     (lambda () (write-bytes (response-body resp) (current-output-port)))))
 (check-true (string-contains? body-string "Hello, World!")))

Testing Database Interactions

For applications that use a database, integration tests should use a test database or an in-memory database. Here is an example using a simple in-memory data store:

#lang racket

(provide make-user-store
         user-store-add!
         user-store-get
         user-store-count)

(struct user-store (users [count #:mutable]))

(define (make-user-store)
  (user-store (make-hash) 0))

(define (user-store-add! store id name)
  (hash-set! (user-store-users store) id name)
  (set-user-store-count! store (add1 (user-store-count store))))

(define (user-store-get store id)
  (hash-ref (user-store-users store) id #f))

Integration test:

#lang racket

(require rackunit
         "../my-app/store.rkt")

(test-case
 "user-store add and retrieve"
 (define store (make-user-store))
 (user-store-add! store 1 "Alice")
 (user-store-add! store 2 "Bob")

 (check-equal? (user-store-get store 1) "Alice")
 (check-equal? (user-store-get store 2) "Bob")
 (check-equal? (user-store-get store 999) #f)
 (check-equal? (user-store-count store) 2))

(test-case
 "user-store is independent between tests"
 (define store (make-user-store))
 (check-equal? (user-store-count store) 0))

Test-Driven Development in Racket

Test-driven development (TDD) is a workflow where you write tests before implementation. Racket's REPL and fast feedback loop make TDD particularly pleasant. Here is a TDD workflow example:

Step 1: Write a failing test.

#lang racket

(require rackunit
         "../my-app/string-utils.rkt")

(test-case
 "slugify converts strings to URL-friendly format"
 (check-equal? (slugify "Hello World") "hello-world")
 (check-equal? (slugify "  Multiple   Spaces  ") "multiple-spaces")
 (check-equal? (slugify "Special!@#Characters") "special-characters")
 (check-equal? (slugify "") ""))

Step 2: Run the test and watch it fail (the module does not exist yet).

Step 3: Implement the function.

#lang racket

(provide slugify)

(define (slugify str)
  (define lowered (string-downcase str))
  (define trimmed (string-trim lowered))
  (define no-special (regexp-replace* #rx"[^a-z0-9\\s-]" trimmed ""))
  (define collapsed (regexp-replace* #rx"\\s+" no-special "-"))
  (string-trim collapsed "-"))

Step 4: Run the test again and watch it pass.

This red-green cycle keeps your code focused and well-tested from the start.

Mocking and Stubbing

Racket does not have a dedicated mocking library in the same way some other languages do, but its functional nature and module system make mocking straightforward. The key strategies are:

Using Parameters for Mocking

#lang racket

(provide current-api-endpoint
         fetch-user-data)

(define current-api-endpoint
  (make-parameter "https://api.example.com"))

(define (fetch-user-data user-id)
  (define url (format "~a/users/~a" (current-api-endpoint) user-id))
  ;; In production, this would make an HTTP request
  (call-input-request url))

In tests, you can override the endpoint:

#lang racket

(require rackunit
         "../my-app/api.rkt")

(test-case
 "fetch-user-data uses the configured endpoint"
 (parameterize ([current-api-endpoint "https://test-api.local"])
   ;; Test with a mock HTTP client or local test server
   (check-equal? (fetch-user-data 42) expected-test-data)))

Using Submodules for Test Overrides

Racket's submodule system allows you to define test code within the same file as your implementation, which is useful for testing private functions:

#lang racket

(provide public-function)

(define (private-helper x)
  (* x 2))

(define (public-function x)
  (+ (private-helper x) 1))

(module+ test
  (require rackunit)

  (test-case
   "private-helper doubles its input"
   (check-equal? (private-helper 5) 10)
   (check-equal? (private-helper 0) 0))

  (test-case
   "public-function uses private-helper"
   (check-equal? (public-function 5) 11)
   (check-equal? (public-function 0) 1)))

Running raco test on this file will automatically discover and run the test submodule.

Best Practices for Racket Testing

1. Keep Tests Fast

Slow tests discourage developers from running them frequently. Avoid network calls, file I/O, and sleeps in unit tests. Use mocks and in-memory data stores instead.

2. Test One Thing Per Test Case

Each test-case should focus on a single behavior. This makes failures easier to diagnose. If a test case has more than five or six assertions, consider splitting it.

3. Use Descriptive Test Names

The string passed to test-case should clearly describe what is being tested. Avoid generic names like "test 1" or "basic test."

;; Bad
(test-case "test1" ...)

;; Good
(test-case "calculate-discount returns zero for 100% discount" ...)

4. Test Edge Cases

Always test boundary conditions: empty lists, zero, negative numbers, very large inputs, and nil values. Property-based testing can help discover edge cases you might miss.

5. Separate Test and Production Code

Keep test dependencies out of your production modules. Use module+ submodules for inline tests, or keep tests in separate files. This keeps your distribution clean.

6. Use Contracts Alongside Tests

Racket's contract system provides runtime checks at module boundaries. Contracts and tests are complementary—contracts catch violations at runtime, while tests verify correct behavior:

#lang racket

(provide (contract-out
          [divide (/c number? (not/c zero?))]))

(define (divide a b)
  (/ a b))

7. Run Tests in CI

Integrate raco test into your CI pipeline. A typical GitHub Actions step might look like:

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Racket
        uses: Bogdanp/setup-racket@v1
        with:
          version: "8.10"
      - name: Install dependencies
        run: raco pkg install --auto --deps search-auto
      - name: Run tests
        run: raco test -p my-app

8. Measure Test Coverage

Racket provides built-in coverage analysis. Use raco cover (from the cover package) to identify untested code paths:

raco pkg install cover
raco cover -s my-app tests/

This generates an HTML report showing which lines of code are covered by your tests.

Advanced: Custom Check Forms

Racket's macro system lets you define custom check forms for domain-specific assertions. For example, if you frequently check that a list contains specific elements in any order:

#lang racket

(require rackunit)

(define-syntax check-set-equal?
  (syntax-rules ()
    [(_ actual expected)
     (check-true (and (= (length actual) (length expected))
                      (andmap (lambda (x) (member x actual)) expected))
                 (format "Expected ~a to contain same elements as ~a"
                         actual expected))]))

(test-case
 "custom set-equal check"
 (check-set-equal? '(3 1 2) '(1 2 3))
 (check-set-equal? '("a" "b" "c") '("c" "a" "b")))

Custom checks make your tests more readable and reduce boilerplate.

Conclusion

Testing in Racket is both practical and expressive, thanks to the rackunit framework, the module system, and the language's inherent flexibility. By combining unit tests, property-based testing, integration tests, and smart mocking strategies, you can build a robust test suite that gives you confidence in your application's correctness. The key is to start simple—write basic unit tests with rackunit—and gradually adopt more advanced techniques like property-based testing and custom check forms as your application grows. Remember that tests are an investment: the time you spend writing them pays dividends in fewer bugs, easier refactoring, and a clearer understanding of your own code. With Racket's fast REPL, powerful macro system, and seamless raco test integration, there is no reason not to make testing a first-class citizen in your development workflow.

— Ad —

Google AdSense will appear here after approval

← Back to all articles