Randomized Test Inputs in "Game of Life"

A case for using random inputs in certain testing scenarios.

September 3, 2021

After lots of preparation and practice I presented John Conway's "Game of Life" as a coding kata to my fellow Clean Coders, including my mentor Micah Martin and our Master Craftsman, Robert "Uncle Bob" Martin. After a few practice runs over the last week I had come up with an end result I liked, including all of the steps to go from no code to the final result.

The presentation was well-received. When I asked what anyone would do differently Uncle Bob expressed something about my approach that was initially concerning (and therefore a distraction). Before sharing his comment I'll need to give you some additional background. To begin with, I based the entire test suite on a 'home' cell, which was the subject of most of the test cases.

(def home [0 0])

The first test case verified that, given a cell (just a vector with an x/y coordinate), all eight neighboring cells (on a cartesion grid) were returned.

(describe "Conway's Game of Life"
  (it "has awareness of the potential active neighbors of a cell"
    (should= #{[-1 -1] [0 -1] [1 -1]
               [-1 0] #_home [1 0]
               [-1 1] [0 1] [1 1]} (neighbors-of cell)))

Starting with the next test, the (neighbors-of cell) was also extracted to a var:

(def neighbors (neighbors-of home))

The next test, expressed using a list comprehension, verified that a (count-active-neighbors function was aware of the active neighbors of a given cell.

(for [n (range 9)]
  (it (format "has awareness of %d active neighbors of a cell" n)
    (let [grid (set (take n neighbors))
          grid (conj grid [42 43])] ; outlier, doesn't count
      (should= n (count-active-neighbors home grid)))))

There were several other tests forms that all repeated the (set (take n neighbors)) expression. Uncle Bob's comment had to do with the fact that these "list-comprehension-driven" tests always 'took' from the beginning of the neighbors collection, seemingly favoring the initial members of the collection in usage.

Now, he and everyone else understood and acknowledged that in the game of life it does not matter which cell is active, just how many are active, so it didn't really matter that I was taking from the front every time, but it's something the viewer might have to think through, creating a distracted viewing experience.

So, Micah then suggested doing a (shuffle), a good idea to mitigate the distracted line of thinking. But to add a shuffle call to the expression in each test seemed inconvient, so why not create a function?

(defn take-neighbors [n]
  (set (take n (shuffle (neighbors-of home)))))

Here's how it feels to use it in the test:

(for [n (range 9)]
    (it (format "has awareness of %d active neighbors of a cell" n)
      (let [grid (take-neighbors n)
            grid (conj grid [42 43])]
        (should= n (count-active-neighbors home grid)))))

Using randomized inputs is not something I do very often in unit testing, but in this case it's a surprisingly nice improvement for each test! Thanks, Uncle Bob and Micah!