How do I unit test clojure.core.async go macros?
Asked Answered
I

3

17

I'm trying to write unit tests when using core.async go macros. Writing the test naively, as follows, it appears that the code inside the go blocks don't get executed.

(ns app.core-test
  (:require [clojure.test :refer :all]
            [clojure.core.async :as async]))

(deftest test1 []
  (let [chan (async/chan)]
    (async/go
     (is (= (async/<! chan) "Hello")))
    (async/go
     (async/>! chan "Hello"))))

I've managed to get the following working, but it's extremely hacky.

(deftest test1 []
  (let [result (async/chan)
        chan (async/chan)]
    (async/go
     (is (= (async/<! chan) "Hello"))
     (async/>! result true))
    (async/go
     (async/>! chan "Hello"))
    (async/alts!! [result (async/timeout 10000)])))

Any suggestions as to how I can do this properly?

Instinctive answered 10/6, 2015 at 20:3 Comment(0)
T
4

your test is finishing, and then failing. This happens more reliably if I put a sleep in and then make it fail:

user> (deftest test1 []
        (async/<!!
         (let [chan (async/chan)]
           (async/go
             (async/go
               (async/<! (async/timeout 1000))
               (is (= (async/<! chan) "WRONG")))
             (async/go
               (async/>! chan "Hello"))))))
#'user/test1
user> (clojure.test/run-tests)

Testing user

Ran 1 tests containing 0 assertions.
0 failures, 0 errors.
{:test 1, :pass 0, :fail 0, :error 0, :type :summary}
user> 
FAIL in (test1) (form-init8563497779572341831.clj:5)
expected: (= (async/<! chan) "WRONG")
  actual: (not (= "Hello" "WRONG"))

here we can see it report that nothing fails, then it prints the failure message. We can fix this by explicitly coordinating the end of the test and that action finishing by, like most solutions in core.async, adding one more chan.

user> (deftest test1 []
        (async/<!!
         (let [all-done-chan (async/chan)
               chan (async/chan)]
           (async/go
             (async/go
               (async/<! (async/timeout 1000))
               (is (= (async/<! chan) "WRONG"))
               (async/close! all-done-chan ))
             (async/go
               (async/>! chan "Hello"))
             (async/<! all-done-chan)))))
#'user/test1
user> (clojure.test/run-tests)

Testing user

FAIL in (test1) (form-init8563497779572341831.clj:6)
expected: (= (async/<! chan) "WRONG")
  actual: (not (= "Hello" "WRONG"))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.
{:test 1, :pass 0, :fail 1, :error 0, :type :summary}

Which is equivalent to your solution using alts. I don't think your solution is hackey. With asynchronous code it's always required to pay attention to when things finish, even if you conciously decide to ignore the result.

Thirtytwomo answered 10/6, 2015 at 23:46 Comment(0)
S
26

Tests are executed synchronously, so if you go async the test-runner won't. In Clojure you need to block the test runner via <!!, in ClojureScript you have to return an async test object. This is a generic helper function I use in all my async CLJC tests:

(defn test-async
  "Asynchronous test awaiting ch to produce a value or close."
  [ch]
  #?(:clj
     (<!! ch)
     :cljs
     (async done
       (take! ch (fn [_] (done))))))

Your test using it, CLJC compatible and looking way less "hacky":

(deftest test1
  (let [ch (chan)]
    (go (>! ch "Hello"))
    (test-async
      (go (is (= "Hello" (<! ch)))))))

It is good practice to assert that the test unblocks, especially during test driven development where you want to avoid locking your test runner. Also, locking is a common cause of failure in async programming, so testing against it is highly reasonable.

To do that I wrote a helper similar to your timeout thing:

(defn test-within
  "Asserts that ch does not close or produce a value within ms. Returns a
  channel from which the value can be taken."
  [ms ch]
  (go (let [t (timeout ms)
            [v ch] (alts! [ch t])]
        (is (not= ch t)
            (str "Test should have finished within " ms "ms."))
        v)))

You can use it to write your test like:

(deftest test1
  (let [ch (chan)]
    (go (>! ch "Hello"))
    (test-async
      (test-within 1000
        (go (is (= "Hello" (<! ch)))))))
Scorn answered 11/6, 2015 at 12:49 Comment(0)
T
4

your test is finishing, and then failing. This happens more reliably if I put a sleep in and then make it fail:

user> (deftest test1 []
        (async/<!!
         (let [chan (async/chan)]
           (async/go
             (async/go
               (async/<! (async/timeout 1000))
               (is (= (async/<! chan) "WRONG")))
             (async/go
               (async/>! chan "Hello"))))))
#'user/test1
user> (clojure.test/run-tests)

Testing user

Ran 1 tests containing 0 assertions.
0 failures, 0 errors.
{:test 1, :pass 0, :fail 0, :error 0, :type :summary}
user> 
FAIL in (test1) (form-init8563497779572341831.clj:5)
expected: (= (async/<! chan) "WRONG")
  actual: (not (= "Hello" "WRONG"))

here we can see it report that nothing fails, then it prints the failure message. We can fix this by explicitly coordinating the end of the test and that action finishing by, like most solutions in core.async, adding one more chan.

user> (deftest test1 []
        (async/<!!
         (let [all-done-chan (async/chan)
               chan (async/chan)]
           (async/go
             (async/go
               (async/<! (async/timeout 1000))
               (is (= (async/<! chan) "WRONG"))
               (async/close! all-done-chan ))
             (async/go
               (async/>! chan "Hello"))
             (async/<! all-done-chan)))))
#'user/test1
user> (clojure.test/run-tests)

Testing user

FAIL in (test1) (form-init8563497779572341831.clj:6)
expected: (= (async/<! chan) "WRONG")
  actual: (not (= "Hello" "WRONG"))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.
{:test 1, :pass 0, :fail 1, :error 0, :type :summary}

Which is equivalent to your solution using alts. I don't think your solution is hackey. With asynchronous code it's always required to pay attention to when things finish, even if you conciously decide to ignore the result.

Thirtytwomo answered 10/6, 2015 at 23:46 Comment(0)
P
0

I'm using an approach similar to Leon's, but with no extra go blocks:

(defn <!!?
  "Reads from chan synchronously, waiting for a given maximum of milliseconds.
  If the value does not come in during that period, returns :timed-out. If
  milliseconds is not given, a default of 1000 is used."
  ([chan]
   (<!!? chan 1000))
  ([chan milliseconds]
   (let [timeout (async/timeout milliseconds)
         [value port] (async/alts!! [chan timeout])]
     (if (= chan port)
       value
       :timed-out))))

You can use it simply as:

(is (= 42 (<!!? result-chan)))

Most of the time I just want to read the value from the channel without any extra hassle.

Paternalism answered 14/8, 2017 at 14:18 Comment(1)
My solution is designed to work in both Clojure and Clojurescript to allow writing cross-platform unit tests.Scorn

© 2022 - 2024 — McMap. All rights reserved.