The Little Schemer: Practical Concerns

I've finished working my way through The Little Schemer, plus many of the exercises from The Little Lisper. One of the first hurdles I ran into was organization--as I do the coding involved in these exercises and problems, how could I configure my code suite to meet my learning needs? In rough order, here are my needs:
  1. I want to run the code in the book so that I can test my understanding.
  2. I want to be able to extend the book's code with my own as needed (e.g. write a function derived from one in the book).
  3. I want to minimize typing--I want to copy code samples from the text at most once.
  4. I want to re-use functions I've tested as building blocks for more complicated functions.
  5. Chapters in the book are made up of numerous entries of the form "what is (rember a lat)?", each building on previous entries. I want some sort of testing framework in place so that I can be certain that my code is in agreement with the text.
  6. I want this testing framework to expand as needed to include additional tests that I may need to understand a function's behavior.
  7. It would be nice if the test framework supported batching the tests and aggregating the results.
  8. The whole thing has to be in some sort of version control.
The iterations below should make it clear that I didn't know I would need all these features at first. After a few tries, I did manage to organize things to meet all of these needs. I'm tempted to think this would be a suitable structure for other technical studies.

Attempt #1: Git 'er done!


First off, it all had to go into code management (this was CVS when I started the project, subversion later on). My first thought for the file structure was to combine function definitions and relevant tests in a single file, e.g.:
(define atom?
(lambda (x)
(and (not (pair? x)) (not (null? x)))))

(display (atom? 'foo))
(display (atom? 42))
(display (not (atom? '(a b c))))
This was sufficient for about the first half of the book--few functions are introduced, and they have few dependencies and tests. But once the more complicated functions are introduced, this style raises all sorts of problems:
  • Any attempt to re-load the code will re-run the tests. What a mess!
  • The system is starting to scale poorly. How am I going to test the code in later chapters?
Attempt #2: lib/ and t/

OK, the code and tests would have to go in different files. Function definitions go in lib/ (e.g. lib/atom.scm) and tests go in t/. Now how to load the functions from the test files? A little poking around in Guile's documentation yielded this mess:
(set! %load-path (cons "../lib" %load-path))
(set! %load-path (cons "lib" %load-path))
But a little more poking around uncovered guile's -L command-line option; now I could invoke tests from the command line like this:
% guile -L lib t/test-atom.scm
Where test-atom.scm would look like this:
(load-from-path "atom.scm")
; ... tests go here ...
At this point, I found the Little Lisper exercises and tried some of them. I'd solved the worst of the code reuse problem, but now I had even more tests to manage. Eyeballing the output from some shell command was too painful, even after cobbling together a makefile to handle the most common commands

This scales poorly with the number of functions and tests. Even running a few dozen tests was painful--I needed something better!

Attempt #3: TAP and runtests

At this point the number of functions and tests went through the roof. My eyes were watering from scanning all the test output, so I turned to a tool I was already familiar with from working with Perl: the Test Anything Protocol ("TAP"). TAP is a machine-friendly format for displaying test results. Here's an example of TAP output generated by my Little Schemer codebase:
% guile -L lib t/00-atom.scm
1..3
ok
ok
ok
%
And here's the Scheme code that generated it:
(load-from-path "test-tap.scm")
(load-from-path "atom.scm")
(plan 3)
(is (atom? 'abc) #t)
(is (atom? '()) #f)
(is (atom? '(foo)) #f)
Here's test-tap.scm, which defines a handful of TAP utility functions (the interface was stolen shamelessly from Perl's test modules):
(define plan
(lambda (x)
(display '1..)
(display x)
(newline)))

(define diag
(lambda (msg)
(display "# ")
(display msg)
(newline)))

(define display-ok
(lambda ()
(display "ok\n")))

(define display-not-ok
(lambda (actual correct)
(display "not ok\n")
(diag (string-concatenate (cons "actual: " (cons (object->string
actual) '()))))
(diag (string-concatenate (cons "correct: " (cons (object->string
correct) '()))))))

;; for now use "equal?" as equality test
(define is
(lambda (s1 s2)
(cond
((equal? s1 s2) (display-ok))
(else (display-not-ok s1 s2)))))

(define isnt
(lambda (s1 s2)
(cond
((equal? s1 s2) (display-not-ok))
(else (display-ok)))))
Now that our tests are generating proper TAP, we can use the runtests script (provided by Perl's TAP::Parser module) to run suites of tests and aggregate the results:
% runtests --exec 'guile -L lib' t/00-atom.scm
t/00-atom........ok
All tests successful.
Files=1, Tests=3, 0 wallclock secs (0.09 cusr + 0.03 csys = 0.12 CPU)
%
Here's the result of running my whole test suite:
% find t -name '*.scm' | runtests --parse --exec 'guile -L lib' -
t/00-atom.................ok
t/ch01/00-atom............ok
t/ch02/00-lat.............ok
t/ch02/01-member..........ok
t/ch03/00-rember..........ok
t/ch03/01-firsts..........ok
t/ch03/02-seconds.........ok
t/ch04/00-add1............ok
t/ch04/01-sub1............ok
t/ch04/02-zero............ok
t/ch09/20-y-length........ok
t/ch10/00-load............ok
t/ch10/05-cond............ok
t/ch10/10-closure.........ok
All tests successful.
Files=14, Tests=44, 2 wallclock secs (0.98 cusr + 0.70 csys = 1.69 CPU)
Next steps

If I pursue more Scheme programming (most likely SICP or SICM), my Scheme/TAP interface code could use the following:
  • better test planning (i.e. a more sophisticated plan function)
  • numbered tests
  • release of the TAP output code as a proper Scheme module
Miscellanea:

Putting the following in ~/.guile (this is documented in the Guile documentation):
(use-modules (ice-9 history))
(use-modules (ice-9 readline))
(activate-readline)

Comments