Lisp Testing Fun

lisp code .....


Earlier: To The Metal... Compiling Your Own Language(s)

I've been making some progress with steelcut, my Common Lisp template generator (does every Lisper write one of these as a rite of passage?), and I keep running into some interesting problems which have been fun to solve.

Whenever my code starts to accumulate any sort of complexity, I start trying to err on the side of writing more tests, even for "recreational" projects such as this one. But test code can be fairly repetitive, often with many small variations and lots of boilerplate, in the midst of which it can be hard to see the forest for the trees.

Such was the case for steelcut tests. I found myself remembering Paul Graham's line, "Any other regularity in the code is a sign, to me at least, that I'm using abstractions that aren't powerful enough – often that I'm generating by hand the expansions of some macro that I need to write."

In my current non-Lisp programming work, I most often miss macros when writing test code. But in this case, the macro I (apparently) needed to write is here. Very roughly speaking, with-setup creates a temporary directory, runs the template generator with a given list of requested features, and allows the user to check properties of the generated files.

The macro grew and shrank as I DRYed out and simplified the test code. I realized many of my tests were checking the generated dependencies for the projects (from the .ASD file), so it would be helpful if the macro would make those available to the test code in the form of a function the user could name.

This wound up being a sweet bit of sugar for my tests, but not every test needed such a helper function. Stubborn "unused variable" warnings ensued, for those tests which don't use the deps symbol bound by the macro. I went back and forth with ChatGPT on this one (it made several wrong suggestions) until I realized I could give the variable an explicit _ name and change the behavior based on the name. This is something I've seen in other languages and was tickled I could get it so easily here.

I find the resulting tests pretty easy to read:

(test needed-files-created
  (with-setup appname "testingapp" appdir _ +default-features+
    (loop for file in '("Makefile"
                        "Dockerfile"
                        ".github/workflows/build.yml"
                        "build.sh"
                        "test.sh"
                        "src/main.lisp"
                        "src/package.lisp"
                        "test/test.lisp"
                        "test/package.lisp")
          do
             (is (uiop:file-exists-p (join/ appdir file))))))

(test cmd-feature-adds-dependency-and-example
  (with-setup appname "testingapp" appdir deps +default-features+
    (let ((main-text (slurp (join/ appdir "src/main.lisp"))))
      ;; :cmd is not there:
      (is (not (find :cmd (deps))))
      ;; It doesn't contain the example function:
      (is (not (has-cmd-example-p main-text)))))

  ;; Add :cmd to features, should get the new dependency:
  (with-setup appname "test2" appdir deps (cons :cmd +default-features+)
    (let ((main-text (slurp (join/ appdir "src/main.lisp"))))
      ;; :cmd is now there:
      (is (find :cmd (deps)))
      ;; It contains the example function:
      (is (has-cmd-example-p main-text)))))

N.B.: A keyword argument would also work, obviously. For a macro in production code, I would probably go that route instead.

Fun

I've also been thinking about a recent post on Hacker News, and the ensuing discussion, in which both the author and commenters point out that programming Lisp is fun. While I must acknowledge that Lisp has been a life-long attraction/distraction/diversion, I'm noticing that the way it is fun for me is evolving somewhat.

First, I used to find Common Lisp pretty painful because of all its sharp edges – the language is so powerful and flexible, you can shoot yourself in the foot pretty much any way you like, and it's not as easy as some newer languages to get started with. But now, with LLMs, it's much easier to get help when you get into the weeds. Sure, "the corpus of training data was smaller for Lisp than for mainstream languages," blah, blah. But often even the wrong answers from ChatGPT point me in the right direction (some of this is probably just rubberducking). When one's own ignorance is less of an obstacle, the pointy bits become less intimidating.

Second, there are some really good, and fun, books on Lisp. PAIP, Let Over Lambda, Practical Common Lisp, Land of Lisp (plus video!). Lisp has some pretty mind-bending things in it, and I'm enjoying taking the time to dig into some of these books again, understanding things better than the first time I swung by.

Third, I'm regularly stunned by how fast Lisp is. All the tests I've been writing run effectively instantly at the REPL on my M1 Macbook Air. And now that I'm starting to get good enough at the language that it is getting out of my way, that speed is more noticeable, and, well, fun.

Finally, I am intrigued by the history of computing, of which Lisp has been an important part. This is worth saying more about in a future post, but for the moment I find a stable, highly-performant, fun language that has intrigued people for decades to be more of interest than the bleeding edge. (I'm not hating on Rust. Rust is nice. But I don't find it fun, at least not yet.)

Beyond fun, I'm also enjoying "escaping the hamster wheel of backwards incompatibility" for awhile. While so many things around us crumble and whirl, and new forms of AI scare the pants out of us as much as they intrigue, older tools and traditions start to feel more like comfortable tools that weigh down one's hands satisfyingly and invite calm creation.


Earlier: To The Metal... Compiling Your Own Language(s)