Composing test assertions in a pipeline

Have you ever wanted to test the steps in a long pipeline of operations?

"Edward Teach asking a co-worker why his threaded pipeline expression is failing in production" - By Jean Leon Gerome FerrisPublic Domain.

(-> starting-state

Wouldn't it be great if we could just insert some checks in between? We could write a test that checked the steps throughout the chain without creating a hand crafted state for each step:

(-> starting-state
  (step 1)
  (has x (contains? x :foo))
  (step 2)
  (has x (not (empty? x)))
  (step 3)
  (has x (and (contains? x :bar) (pos? (:baz x)))))

Conceptually has should return whatever we give it, after making an assertion about it. This can be achieved by a function, but using a function means that our test output will not contain the original form that we used to define the testing expression. If we want to preserve the original form of the assertion, we need to pass it all the way down to the clojure.test/is assertion. We will need to use a macro.

Clojure has two useful concepts to help us:

(doto x & forms)
Evaluates x then calls all of the methods and functions with the value of x supplied at the front of the given arguments. The forms are evaluated in order. Returns x.

(as-> expr name & forms)
Binds name to expr, evaluates the first form in the lexical context of that binding, then binds name to that result, repeating for each successive form, returning the result of the last form.

So we can write has as a doto as-> is combination:

(defmacro has
  ([actual sym form]
   `(has ~actual ~sym ~form nil))
  ([actual sym form msg]
   `(doto ~actual
      (as-> ~sym (is ~form ~msg)))))

Now let's use our new superpower in a more realistic setting:

(deftest activating-abilities-test
    (-> {}
      (world/with-status (Date. 0) "Blackbeard" [0 0 0 0 0 0 0] :fire)
      (has w (world/activating w "Blackbeard")
      (ticker/tick (Date. 2000))
      (has w (and (not (world/activating w "Blackbeard"))
                  (get-in w [:players "Blackbeard" :cooldowns :fire])
                  (get-in w [:players "Blackbeard" :action-taken]))
           "warmup complete")
      (ticker/tick (Date. 7000))
      (has w (not (get-in w [:players "Blackbeard" :cooldowns :fire]))
           "cooldown complete")))

We now have a way to make logical assertions throughout a chain of events. Keep in mind that doto prn is also a useful combination to insert into threaded expressions:

(-> starting-state
  (doto (prn "***"))

This can be handy as a quick way to dump the intermediate values to stdout.

When constructing pipeline tests it is convenient to be able to insert assertions that don't change the flow of execution and preserve the assertions original form when reporting failures.

No comments:

Post a Comment