mvxcvi/test.carly

0.4.1


Generative test harness for stateful system behavior.

dependencies

org.clojure/clojure
1.8.0
org.clojure/test.check
0.9.0
mvxcvi/puget
1.0.2



(this space intentionally left almost blank)
 

Suite of tests to verify that a given system implementation conforms to the spec during a sequence of operations performed on it.

(ns test.carly.core
  (:require
    [clojure.test :as ctest]
    [clojure.test.check.generators :as gen]
    (test.carly
      [check :as check]
      [op :as op]
      [report :as report]
      [search :as search])))

Test Operation Definition

Macro helper to build a generator constructor.

(defn- generator-body
  [op-name [form :as body]]
  (cond
    (and (= 1 (count body)) (vector? form))
      `(gen/fmap
         (partial apply ~(symbol (str "->" (name op-name))))
         (gen/tuple ~@form))
    (and (= 1 (count body)) (map? form))
      `(gen/fmap
         ~(symbol (str "map->" (name op-name)))
         (gen/hash-map ~@(apply concat form)))
    :else
      `(gen/fmap
         ~(symbol (str "map->" (name op-name)))
         (do ~@body))))

Defines a new specification for a system operation test.

(defmacro defop
  [op-name attr-vec & forms]
  (let [defined (zipmap (map first forms) forms)]
    (when-let [unknown-forms (seq (dissoc defined 'gen-args 'apply-op 'check 'update-model))]
      (throw (ex-info "Unknown forms defined in operation body"
                      {:unknown (map first unknown-forms)})))
    `(do
       (defrecord ~op-name
         ~attr-vec
         op/TestOperation
         ~(or (defined 'apply-op)
              '(apply-op [op system] nil))
         ~(if-let [[sym args & body] (defined 'check)]
            (list sym args (report/wrap-report-check body))
            '(check [op model result] true))
         ~(or (defined 'update-model)
              '(update-model [op model] model)))
       (defn ~(symbol (str "gen->" (name op-name)))
         ~(str "Constructs a " (name op-name) " operation generator.")
         ~@(if-let [[_ args & body] (defined 'gen-args)]
             [args (generator-body op-name body)]
             [['context]
              `(gen/return (~(symbol (str "->" (name op-name)))))])))))
(defop Wait
  [duration]
  (gen-args
    [_]
    [(gen/choose 1 100)])
  (apply-op
    [this system]
    (Thread/sleep duration)))

Takes a function from context to vector of op generators and returns a new function which additionally returns the wait op as the first result

(defn- waitable-ops
  [op-generators]
  (comp (partial cons (gen->Wait nil)) op-generators))

Test Harness

Create a generator for inputs to a system under test. This generator produces a context and a collection of sequences of operations generated from the context.

(defn- gen-test-inputs
  [context-gen ctx->op-gens max-concurrency]
  (gen/bind
    (gen/tuple
      context-gen
      (if (<= max-concurrency 1)
        (gen/return 1)
        (gen/choose 1 max-concurrency)))
    (fn [[context concurrency]]
      (gen/tuple
        (gen/return context)
        (-> (ctx->op-gens context)
            (gen/one-of)
            (gen/list)
            (gen/not-empty)
            (gen/vector concurrency))))))

Returns the number of milliseconds elapsed since an initial start time in system nanoseconds.

(defn- elapsed-since
  [start]
  (/ (- (System/nanoTime) start) 1000000.0))

Construct a system, run a collection of op sequences on the system (possibly concurrently), and shut the system down. Returns a map from thread index to operations updated with results.

(defn- run-ops!
  [constructor finalize! op-seqs]
  (let [start (System/nanoTime)
        system (constructor)]
    (try
      (case (count op-seqs)
        0 op-seqs
        1 {0 (op/apply-ops! system (first op-seqs))}
          (op/run-threads! system op-seqs))
      (finally
        (when finalize!
          (finalize! system))
        (ctest/do-report
          {:type ::report/run-ops
           :op-count (reduce + 0 (map count op-seqs))
           :concurrency (count op-seqs)
           :elapsed (elapsed-since start)})))))

Runs a generative test iteration. Returns a test result map.

(defn- run-test!
  [constructor finalize! model thread-count op-seqs]
  (ctest/do-report
    {:type ::report/test-start})
  (let [op-results (run-ops! constructor finalize! op-seqs)
        result (search/search-worldlines thread-count model op-results)]
    (ctest/do-report
      (assoc result :type (if (:world result)
                            ::report/test-pass
                            ::report/test-fail)))
    (assoc result :op-results op-results)))

Run a generative trial involving multiple test repetitions.

(defn- run-trial!
  [repetitions runner-fn op-seqs]
  (let [start (System/nanoTime)]
    (ctest/do-report
      {:type ::report/trial-start
       :repetitions repetitions
       :concurrency (count op-seqs)
       :op-count (reduce + 0 (map count op-seqs))})
    (loop [i 0
           result nil]
      (if (== repetitions i)
        (do (ctest/do-report
              {:type ::report/trial-pass
               :repetitions repetitions
               :elapsed (elapsed-since start)})
            result)
        (let [result (runner-fn op-seqs)]
          (if (:world result)
            (recur (inc i) result)
            (do (ctest/do-report
                  {:type ::report/trial-fail
                   :repetition (inc i)
                   :elapsed (elapsed-since start)})
                result)))))))

Emit clojure.test reports for the summarized results of the generative tests.

(defn- report-test-summary
  [summary]
  (if (and (:result summary) (not (instance? Throwable (:result summary))))
    (ctest/report
      (assoc summary :type ::report/summary))
    (ctest/report
      (assoc summary
             :type ::report/shrunk
             :shrunk-result (::check/result (meta (get-in summary [:shrunk :smallest])))))))

Uses generative tests to validate the behavior of a system under a linear sequence of operations.

Takes a test message, a single-argument constructor function which takes the context and produces a new system for testing, and a function which will return a vector of operation generators when called with the test context. The remaining options control the behavior of the tests:

  • :context-gen Generator for the operation test context.
  • :init-model Function which returns a fresh model when called with the context.
  • finalize! Called with the system after running all operations. This function may contain additional test assertions and should clean up any resources by stopping the system.
  • concurrency Maximum number of operation threads to run in parallel.
  • repetitions Number of times to run per generation to ensure repeatability.
  • search-threads Number of threads to run to search for valid worldlines.
  • report Options to override the default report configuration.
(defn check-system
  [message
   iteration-opts
   init-system
   ctx->op-gens
   & {:keys [context-gen init-model finalize!
             concurrency repetitions search-threads]
      :or {context-gen (gen/return {})
           init-model (constantly {})
           concurrency 4
           repetitions 5
           search-threads (. (Runtime/getRuntime) availableProcessors)}
      :as opts}]
  {:pre [(fn? init-system) (fn? ctx->op-gens)]}
  (ctest/testing message
    (binding [report/*options* (merge report/*options* (:report opts))]
      (report-test-summary
        (check/check-and-report
          iteration-opts
          (gen-test-inputs
            context-gen
            (cond-> ctx->op-gens
              (< 1 concurrency) (waitable-ops))
            concurrency)
          (fn [ctx op-seqs]
            (let [model (init-model ctx)
                  constructor (fn system-constructor
                                []
                                (try
                                  (init-system ctx)
                                  (catch clojure.lang.ArityException ae
                                    (init-system))))]
              (run-trial!
                repetitions
                (partial run-test!
                         constructor
                         finalize!
                         model
                         search-threads)
                op-seqs))))))))
 

Functions for running concurrent workers to search for valid worldlines.

(ns test.carly.search
  (:require
    [clojure.test :as ctest]
    [test.carly.world :as world])
  (:import
    (java.util.concurrent
      PriorityBlockingQueue
      TimeUnit)))

Ranks two worlds by the number of possible futures they have. Worlds with fewer futures will rank earlier.

(defn- compare-futures
  [a b]
  (compare (:futures a) (:futures b)))

Steps a world forward to completion along a linear track. Returns a valid terminal world if the operations end in a valid state, otherwise nil. Calls f with each world visited.

(defn- run-linear
  [world f]
  (when world
    (f world)
    (if (world/end-of-line? world)
      ; Made it to the end of the world line with consistent results.
      world
      ; Step world forward. A nil here means the next operation result
      ; is invalid, so the observed worldline is inconsistent with the
      ; model.
      (recur (world/step world) f))))

Poll the queue for a world, calculate next states, and add valid ones back into the queue. The result promise will be delivered with the first valid terminal world found. Once result has been realized, the loop will exit.

(defn- spawn-worker!
  [^PriorityBlockingQueue queue report-fn visited result]
  (future
    (loop []
      (when-not (realized? result)
        (if-let [world (.poll queue 100 TimeUnit/MILLISECONDS)]
          (let [mark-visited! #(swap! visited conj (world/visit-key %))
                visited? #(contains? @visited (world/visit-key %))]
            (when-not (visited? world)
              ; Add world to visited set.
              (mark-visited! world)
              ; Compute steps.
              (binding [ctest/report report-fn]
                (if (<= (:futures world) 1)
                  ; Optimization to run the linear sequence directly when there is only one
                  ; possible future worldline.
                  (when-let [end (run-linear world mark-visited!)]
                    (deliver result end))
                  ; Otherwise, calculate the next possible states and add any unvisited
                  ; ones to the queue.
                  (->> (world/next-steps world)
                       (remove visited?)
                       (run! #(.offer queue %))))))
            (recur))
          ; Didn't find a world; if the queue is still empty, deliver nil.
          (when (empty? queue)
            (deliver result nil)))))))

Run a collection of worker threads to consume the given queue of worlds. Blocks and returns the first valid world found, or nil, once all the threads have terminated.

(defn- run-workers!
  [n queue report-fn visited]
  (when-not (empty? queue)
    (let [result (promise)
          workers (repeatedly n #(spawn-worker! queue report-fn visited result))]
      (dorun workers)
      (run! deref workers)
      @result)))

Run a world directly on-thread to perform a linear search. Only appropriate when the world has a single worldline left.

(defn- linear-search
  [origin]
  (let [visited (volatile! 0)
        reports (volatile! [])
        start (System/nanoTime)
        valid-world (binding [ctest/report #(vswap! reports conj %)]
                      (run-linear origin (fn [world] (vswap! visited inc))))
        elapsed (/ (- (System/nanoTime) start) 1000000.0)]
    {:world valid-world
     :threads 1
     :futures 1
     :visited @visited
     :reports @reports
     :elapsed elapsed}))

Run up to thread-count worker threads to search through worldlines starting from the given origin world to find valid linearizations of the pending operations.

(defn- parallel-search
  [origin thread-count]
  (let [visited (atom #{})
        reports (atom [])
        queue (doto (PriorityBlockingQueue. 20 compare-futures)
                (.offer origin))
        start (System/nanoTime)
        valid-world (run-workers! thread-count queue (partial swap! reports conj) visited)
        elapsed (/ (- (System/nanoTime) start) 1000000.0)]
      {:world valid-world
       :threads thread-count
       :futures (:futures origin)
       :visited (count @visited)
       :reports @reports
       :elapsed elapsed}))

Run up to thread-count worker threads to search through worldlines starting from the given model to find valid linearizations of the thread-results.

(defn search-worldlines
  [thread-count model thread-results]
  (when (empty? thread-results)
    (throw (RuntimeException.
             "Cannot search the worldless void (thread results were empty)")))
  (let [origin (world/initialize model thread-results)]
    (if (= 1 (:futures origin))
      (linear-search origin)
      (parallel-search origin thread-count))))
 

Test operations and related functionality.

(ns test.carly.op)

Protocol for a test operation on a system.

(defprotocol TestOperation
  (apply-op
    [operation system]
    "Apply the operation to the system, returning a result value.")
  (check
    [operation model result]
    "Validate an operation given the model state and the response from the
    system being tested. May include `clojure.test/is` assertions, and should
    return a boolean value indicating overall success or failure.")
  (update-model
    [operation model]
    "Apply an update to the model based on the operation."))

Apply a sequence of operations to a system, returning a vector of pairs of the operations with their results.

(defn apply-ops!
  [system ops]
  (mapv
    (fn [op]
      (assoc
        op ::result
        (try
          (apply-op op system)
          (catch Throwable t
            t))))
    ops))

Applies a sequence of operations in a separate thread. Returns a promise for the results of the application.

(defn run-ops!
  [latch system thread-id ops]
  (future @latch (apply-ops! system ops)))

Run each of the given operation sequences in a separate thread. Returns a vector of the operation results for each thread.

(defn run-threads!
  [system op-seqs]
  (let [latch (promise)
        threads (map (partial run-ops! latch system) (range) op-seqs)]
    (dorun threads)
    (deliver latch :start)
    ; TODO: timeout on deref?
    (->> (map deref threads)
         (map vector (range))
         (into {}))))
 

Record and functions for simulating world states. Each world is represented by an immutable map containing the model state, current history, and map of pending thread operations.

(ns test.carly.world
  (:require
    [test.carly.op :as op]))

A world represents a point in time along a possible history. The model holds the current representation of the system, the history is the sequence of operations which have already happened, and pending is a map from thread ids to lists of pending operations for each thread.

(defrecord World
  [model history pending futures])

Determine whether the worldline has ended.

(defn end-of-line?
  [world]
  (empty? (:pending world)))

Get the next pending operation for the identified thread, if any.

(defn- peek-pending
  [pending thread-id]
  (first (get pending thread-id)))

Remove the next pending operation for the identified thread. Returns an updated map with the remaining ops, or without the thread if no ops were left.

(defn- pop-pending
  [pending thread-id]
  (let [[_ & more] (get pending thread-id)]
    (if (seq more)
      (assoc pending thread-id more)
      (dissoc pending thread-id))))

Calculate the number of possible futures a world has based on a map of thread ids to pending operations.

(defn- future-count
  [pending]
  (if (<= (count pending) 1)
    1
    (let [fact (fn fact [n] (reduce * 1N (map inc (range n))))
          op-counts (map count (vals pending))]
      (apply / (fact (reduce + op-counts)) (map fact op-counts)))))

Initialize a new world state given the initial model and a map of pending operations.

(defn initialize
  [model pending]
  (map->World
    {:model model
     :history []
     :pending pending
     :futures (future-count pending)}))

Compute a step by applying the next operation from the identified thread to the world. Returns an updated world state, or nil if the operation result was invalid.

(defn step
  ([world]
   (step world (first (keys (:pending world)))))
  ([world thread-id]
   (let [op (peek-pending (:pending world) thread-id)]
     (when-not op
       (throw (IllegalStateException.
                (format "Cannot step thread %d - no ops pending" thread-id))))
     (when-not (contains? op ::op/result)
       (throw (IllegalStateException.
                (format "Cannot step op %s with no result" (pr-str op)))))
     (when (op/check op (:model world) (::op/result op))
       (-> world
           (update :model (partial op/update-model op))
           (update :history conj (assoc op ::thread thread-id))
           (update :pending pop-pending thread-id)
           (as-> w (assoc w :futures (future-count (:pending w)))))))))

Compute all possible valid next steps for the world, returning a sequence of new world states.

(defn next-steps
  [world]
  (keep (partial step world) (keys (:pending world))))

Return the key used to compare worlds which are equivalent nodes in the graph of possible futures.

(defn visit-key
  [world]
  [(:model world) (:pending world)])
 

Integration code for wedding generative test.check functions and clojure.test assertion macros.

(ns test.carly.check
  (:require
    [clojure.test.check :as tc]
    [clojure.test.check.clojure-test :as tcct]
    [clojure.test.check.generators :as gen]))

Helper to produce a property by applying the test function to a realized set of arguments from some bound generator. The function should accept a context map and a collection of op sequences, and return a result map with a :world entry containing a valid terminal world on success, or nil on failure.

(defn- apply-test
  [function]
  (fn [args]
    (try
      (let [result (apply function args)]
        {:result (boolean (:world result))
         :function function
         ; XXX: Super gross, but we need to do this to get result metadata
         ; through for failing results until test.check 0.10.0. ಠ_ಠ
         :args (vary-meta args assoc ::result result)})
      (catch ThreadDeath td
        (throw td))
      (catch Throwable ex
        {:result ex
         :function function
         :args args}))))

Apply generative tests to the given input generator and testing function, returning a test.check summary.

(defn check-and-report
  [opts gen-inputs f]
  (let [opts (tcct/process-options opts)]
    (apply tc/quick-check
      (:num-tests opts 20)
      (gen/fmap (apply-test f) gen-inputs)
      (apply concat (dissoc opts :num-tests)))))
 

Integration code for wedding generative test.check functions and clojure.test assertion macros.

(ns test.carly.report
  (:require
    [clojure.string :as str]
    [clojure.test :as ctest]
    [puget.color.ansi :as ansi]
    [puget.printer :as puget]))

Looks up an environment variable and returns a keywordized value if it is present, or nil if not.

(defn- env-keyword
  [env-key default]
  (if-let [env-val(System/getenv env-key)]
    (keyword (str/lower-case env-val))
    default))

Report output options.

(def ^:dynamic *options*
  {:style (env-keyword "TEST_CARLY_STYLE" :dots)
   :print-color (not (contains? #{:0 :false :no}
                                (env-keyword "TEST_CARLY_COLOR" true)))
   :puget {}})

Applies ANSI coloring to the given text, if the print-color option is true.

(defn- colorize
  [text & codes]
  (if (:print-color *options*)
    (apply ansi/sgr text codes)
    text))

Pretty-print the given value using the dynamic reporting options.

(defn- pprint
  [x]
  (puget/pprint x (assoc (:puget *options*) :print-color (:print-color *options*))))

Apply common formatting to elapsed times.

(defn- format-duration
  [elapsed]
  (colorize (format "%.2f ms" elapsed) :cyan))

Report Methods

Report the beginning of a new test trial. Check forms produce one trial per generative iteration, so there may be more if shrinking is necessary.

(defmethod ctest/report ::trial-start
  [result]
  (ctest/with-test-out
    (case (:style *options*)
      :verbose
        (printf "\n%s Starting %s trial%s with %s operations%s %s\n"
                (colorize "<<<" :bold :blue)
                (colorize "test.carly" :magenta)
                (if (< 1 (:repetitions result))
                  (colorize (str " x" (:repetitions result)) :cyan)
                  "")
                (colorize (:op-count result) :bold :yellow)
                (if (< 1 (:concurrency result))
                  (str " across " (colorize (:concurrency result) :cyan) " threads")
                  "")
                (colorize ">>>" :bold :blue))
      :terse
        (do (printf "%12s/%-10s | "
                    (colorize (:op-count result) :cyan)
                    (colorize (:concurrency result) :bold :yellow))
            (flush))
      ; otherwise silent
      nil)))

Start of a test repetition. This is mainly useful as a marker.

(defmethod ctest/report ::test-start
  [result]
  (ctest/with-test-out
    (ctest/inc-report-counter :test)
    (case (:style *options*)
      :verbose
        (println "  Starting test repetition...")
      ; otherwise silent
      nil)))

Elapsed time to run the operations against a real system.

(defmethod ctest/report ::run-ops
  [result]
  (ctest/with-test-out
    (case (:style *options*)
      :verbose
        (printf "    Ran ops in %s\n" (format-duration (:elapsed result)))
      ; otherwise silent
      nil)))

Common reporting code for assertions at the end of a test.

(defn- report-assertions
  [result]
  (let [assertions (frequencies (map :type (:reports result)))
        total (reduce + 0 (vals assertions))]
    ; Record every assertion as a :pass, since failures during searches are not
    ; real failures.
    (when ctest/*report-counters*
      (dosync
        (commute ctest/*report-counters* update :pass (fnil + 0) total)))
    ; Print out assertion counts
    (case (:style *options*)
      :verbose
        (printf "    Checked %s assertions (%d passed, %d failed, %d errors)\n"
                (colorize total :cyan)
                (:pass assertions 0)
                (:fail assertions 0)
                (:error assertions 0))
      ; Otherwise no-op
      nil)))

Report that a test repetition passed successfully, indicating that a valid worldline was found.

(defmethod ctest/report ::test-pass
  [result]
  ; TODO: option to show valid linearization
  (ctest/with-test-out
    (ctest/inc-report-counter :pass)
    (report-assertions result)
    (case (:style *options*)
      :verbose
        (printf "    Found valid worldline among %s futures in %s after visiting %s worlds.\n"
                (colorize (:futures result) :cyan)
                (format-duration (:elapsed result))
                (colorize (:visited result) :cyan))
      (:terse :dots)
        (do (print (colorize "." :green))
            (flush))
      ; Otherwise no-op
      nil)))

Report that a test repetition failed, indicating that no valid worldline could be found for the observed operation results.

(defmethod ctest/report ::test-fail
  [result]
  (ctest/with-test-out
    (ctest/inc-report-counter :fail)
    (report-assertions result)
    (case (:style *options*)
      :verbose
        (printf "    Exhausted valid worldlines among %s futures in %s after visiting %s worlds!\n"
                (colorize (:futures result) :cyan)
                (format-duration (:elapsed result))
                (colorize (:visited result) :cyan))
      (:terse :dots)
        (do (print (colorize "." :red))
            (flush))
      ; Otherwise no-op
      nil)))

An entire test trial passed, meaning every repetition was successful.

(defmethod ctest/report ::trial-pass
  [result]
  (ctest/with-test-out
    (case (:style *options*)
      :verbose
        (printf "Trial %s in %s\n"
                (colorize "PASSED" :bold :green)
                (format-duration (:elapsed result)))
      :terse
        (do (print (colorize "✓" :bold :green)
                   " "
                   (format-duration (:elapsed result)))
            (newline)
            (flush))
      ; Otherwise no-op
      nil)))

An entire trial failed, indicating that one or more repetitions was unsuccessful.

(defmethod ctest/report ::trial-fail
  [result]
  (ctest/with-test-out
    (case (:style *options*)
      :verbose
        (printf "Trial %s in %s after %s tests\n"
                (colorize "FAILED" :bold :red)
                (format-duration (:elapsed result))
                (colorize (:repetition result) :cyan))
      :terse
        (do (print (colorize "X" :bold :red))
            (newline)
            (flush))
      ; Otherwise no-op
      nil)))

Report a successful generative test summary.

(defmethod ctest/report ::summary
  [summary]
  (ctest/with-test-out
    ; TODO: summarize total assertion counts if possible
    (printf "\nGenerative tests passed after %s trials with seed %s\n"
            (colorize (:num-tests summary) :cyan)
            (colorize (:seed summary) :green))))

Report the shrunk value of a failed test summary.

(defmethod ctest/report ::shrunk
  [summary]
                            #_ ; fail
                            {:result false-or-exception
                             :seed seed
                             :failing-size size
                             :num-tests (inc trial-number)
                             :fail (vec failing-args)
                             :shrunk {:total-nodes-visited total-nodes-visited
                                      :depth depth
                                      :result (:result smallest)
                                      :smallest (:args smallest)}
                             :shrunk-result {:world nil
                                             :threads n
                                             :futures n
                                             :visited n
                                             :reports [,,,]
                                             :elapsed ms}}
  (ctest/with-test-out
    (newline)
    (printf "Tests failed with seed %s\n"
            (colorize (:seed summary) :red))
    (when-let [shrunk (:shrunk summary)]
      (printf "Shrank inputs %s steps after searching %s nodes\n"
              (colorize (:depth shrunk) :cyan)
              (colorize (:total-nodes-visited shrunk) :cyan)))
    (when-let [[context op-seqs] (get-in summary
                                         [:shrunk :smallest]
                                         (:fail summary))]
      (newline)
      (println "Context:")
      (pprint context)
      (newline)
      (println "Operation sequences:")
      (doseq [[i ops] (or (get-in summary [:shrunk-result :op-results])
                          (zipmap (range) op-seqs))]
        (printf "thread #%d\n" (inc i))
        (doseq [op ops]
          (if (contains? op :test.carly.op/result)
            (pprint [(dissoc op :test.carly.op/result) '=> (:test.carly.op/result op)])
            (pprint op)))))
    (newline)
    (println "Failing assertions:")
    (run!
      (fn [report]
        ; {:file "table_test.clj", :line 131, :type :fail, :expected (coll? result), :actual (not (coll? nil)), :message nil}
        (when (#{:fail :error} (:type report))
          (printf "%s:%s %s\n\tExpected: %s\tActual: %s"
                  (:file report) (:line report)
                  (or (:message report) "")
                  (with-out-str (pprint (:expected report)))
                  (with-out-str (pprint (:actual report))))))
      (get-in summary [:shrunk-result :reports]))
    (newline)
    (println "Result:")
    (let [result (get-in summary [:shrunk :result] (:result summary))]
      (if (instance? Throwable result)
        (clojure.stacktrace/print-cause-trace result)
        (pprint result)))))

Helper Functions

Capture any clojure.test reports generated by the body. Returns a vector containing the evaluated result, followed by a vector of reports.

(defmacro capture-reports
  [& body]
  `(let [reports# (atom [])]
     [(binding [ctest/report (partial swap! reports# conj)]
        ~@body)
      @reports#]))

Publish a collection of reports to clojure.test.

(defn publish!
  [reports]
  (run! ctest/report reports))

Wrap the given sequence of forms such that it returns false if there is a failed clojure.test assertion in the body. If there are no assertions, returns the result of evaluating the body.

(defn wrap-report-check
  [body]
  `(let [[result# reports#] (capture-reports ~@body)]
     (publish! reports#)
     (if (empty? reports#)
       result#
       (not (some (comp #{:fail :error} :type) reports#)))))