├── .clj-kondo ├── README.md ├── babashka │ ├── fs │ │ └── config.edn │ └── sci │ │ ├── config.edn │ │ └── sci │ │ └── core.clj ├── com.github.metabase │ └── hawk │ │ └── config.edn ├── config.edn ├── methodical │ └── methodical │ │ ├── config.edn │ │ ├── hooks │ │ └── methodical │ │ │ ├── core.clj │ │ │ └── macros.clj │ │ └── macros │ │ └── methodical │ │ └── impl │ │ └── combo │ │ └── operator.clj ├── metosin │ └── malli │ │ └── config.edn └── prismatic │ └── schema │ └── config.edn ├── .dir-locals.el ├── .github ├── CODEOWNERS ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── deploy.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── VERSION ├── assets └── test_hawk.png ├── build.clj ├── deps.edn ├── docs └── approximately-equal.md ├── resources └── clj-kondo.exports │ └── com.github.metabase │ └── hawk │ └── config.edn ├── src └── mb │ └── hawk │ ├── assert_exprs.clj │ ├── assert_exprs │ └── approximately_equal.clj │ ├── core.clj │ ├── hooks.clj │ ├── init.clj │ ├── junit.clj │ ├── junit │ └── write.clj │ ├── parallel.clj │ ├── partition.clj │ ├── speak.clj │ └── util.clj └── test └── mb └── hawk ├── assert_exprs └── approximately_equal_test.clj ├── assert_exprs_test.clj ├── core_test.clj ├── parallel_test.clj ├── partition_test.clj └── speak_test.clj /.clj-kondo/README.md: -------------------------------------------------------------------------------- 1 | Update `clj-kondo` configs for libraries using 2 | 3 | ```sh 4 | clj-kondo --copy-configs --dependencies --lint "$(clojure -Spath)" 5 | ``` 6 | -------------------------------------------------------------------------------- /.clj-kondo/babashka/fs/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {babashka.fs/with-temp-dir clojure.core/let}} 2 | -------------------------------------------------------------------------------- /.clj-kondo/babashka/sci/config.edn: -------------------------------------------------------------------------------- 1 | {:hooks {:macroexpand {sci.core/copy-ns sci.core/copy-ns}}} 2 | -------------------------------------------------------------------------------- /.clj-kondo/babashka/sci/sci/core.clj: -------------------------------------------------------------------------------- 1 | (ns sci.core) 2 | 3 | (defmacro copy-ns 4 | ([ns-sym sci-ns] 5 | `(copy-ns ~ns-sym ~sci-ns nil)) 6 | ([ns-sym sci-ns opts] 7 | `[(quote ~ns-sym) 8 | ~sci-ns 9 | (quote ~opts)])) 10 | -------------------------------------------------------------------------------- /.clj-kondo/com.github.metabase/hawk/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:unresolved-symbol 3 | {:exclude 4 | [(clojure.test/is [partial= re= schema= =?])]}}} 5 | -------------------------------------------------------------------------------- /.clj-kondo/config.edn: -------------------------------------------------------------------------------- 1 | {:config-paths 2 | ["../resources/clj-kondo.exports/com.github.metabase/hawk"] 3 | 4 | :linters 5 | {:docstring-leading-trailing-whitespace {:level :warning} 6 | :missing-docstring {:level :warning} 7 | :unsorted-required-namespaces {:level :warning} 8 | 9 | :refer-all 10 | {:level :warning 11 | :exclude [clojure.test]} 12 | 13 | :consistent-alias 14 | {:aliases 15 | {methodical.core methodical}}} 16 | 17 | :config-in-comment 18 | {:linters {:unresolved-symbol {:level :off}}}} 19 | -------------------------------------------------------------------------------- /.clj-kondo/methodical/methodical/config.edn: -------------------------------------------------------------------------------- 1 | {:config-paths ["macros"] 2 | 3 | :lint-as 4 | {} 5 | 6 | :hooks 7 | {:analyze-call 8 | {methodical.core/defmethod hooks.methodical.macros/defmethod 9 | methodical.core/defmulti hooks.methodical.macros/defmulti 10 | methodical.macros/defmethod hooks.methodical.macros/defmethod 11 | methodical.macros/defmulti hooks.methodical.macros/defmulti} 12 | 13 | :macroexpand 14 | {methodical.impl.combo.operator/defoperator macros.methodical.impl.combo.operator/defoperator}}} 15 | -------------------------------------------------------------------------------- /.clj-kondo/methodical/methodical/hooks/methodical/core.clj: -------------------------------------------------------------------------------- 1 | (ns hooks.methodical.core 2 | (:require [clj-kondo.hooks-api :as hooks])) 3 | 4 | (defn add-next-method [fn-tail] 5 | (if (hooks/vector-node? (first fn-tail)) 6 | (let [[args & body] fn-tail] 7 | (list* 8 | (-> (hooks/vector-node 9 | (cons (hooks/token-node 'next-method) 10 | (:children args))) 11 | (with-meta (meta args))) 12 | ;; so Kondo stops complaining about it being unused. 13 | (hooks/token-node 'next-method) 14 | body)) 15 | (for [list-node fn-tail] 16 | (hooks/list-node (add-next-method (:children list-node)))))) 17 | 18 | (defn defmethod 19 | [{{[_ multimethod & [first-arg :as args]] :children, :as node} :node}] 20 | #_(clojure.pprint/pprint (hooks/sexpr node)) 21 | (let [[aux-qualifier dispatch-value & fn-tail] (if (#{:before :after :around} (hooks/sexpr first-arg)) 22 | (cons (hooks/sexpr first-arg) (rest args)) 23 | (cons nil args)) 24 | fn-tail (if (contains? #{:around nil} aux-qualifier) 25 | (add-next-method fn-tail) 26 | fn-tail) 27 | result (hooks/list-node 28 | (list* (hooks/token-node 'clojure.core/defmethod) 29 | multimethod 30 | dispatch-value 31 | fn-tail))] 32 | #_(println "=>") 33 | #_(clojure.pprint/pprint (hooks/sexpr result)) 34 | {:node result})) 35 | 36 | (defn defmulti 37 | [{{[_ multimethod-name & args] :children, :as node} :node}] 38 | #_(clojure.pprint/pprint (hooks/sexpr node)) 39 | (let [[docstring & args] (if (hooks/string-node? (first args)) 40 | args 41 | (cons nil args)) 42 | [attribute-map & args] (if (hooks/map-node? (first args)) 43 | args 44 | (cons nil args)) 45 | ;; if there wasn't a positional dispatch function arg passed just use (constantly nil) so Kondo won't complain 46 | [dispatch-fn & kv-options] (if (odd? (count args)) 47 | args 48 | (cons (hooks/list-node 49 | (list 50 | (hooks/token-node 'clojure.core/constantly) 51 | (hooks/token-node 'nil))) 52 | args))] 53 | (let [defmulti-form (hooks/list-node 54 | (filter 55 | some? 56 | [(hooks/token-node 'clojure.core/defmulti) 57 | multimethod-name 58 | docstring 59 | attribute-map 60 | dispatch-fn])) 61 | result (hooks/list-node 62 | (list* 63 | (hooks/token-node 'do) 64 | defmulti-form 65 | kv-options))] 66 | #_(println "=>") 67 | #_(clojure.pprint/pprint (hooks/sexpr result)) 68 | {:node result}))) 69 | -------------------------------------------------------------------------------- /.clj-kondo/methodical/methodical/hooks/methodical/macros.clj: -------------------------------------------------------------------------------- 1 | (ns hooks.methodical.macros 2 | (:refer-clojure :exclude [defmulti defmethod]) 3 | (:require 4 | [clj-kondo.hooks-api :as hooks])) 5 | 6 | ;;; The code below is basically simulating the spec for parsing defmethod args without using spec. It uses a basic 7 | ;;; backtracking algorithm to achieve a similar result. Parsing defmethod args is kinda complicated. 8 | ;;; 9 | ;;; Unfortunately this is hardcoded to `:before`, `:after`, and `:around` as the only allowed qualifiers for now... at 10 | ;;; some point in the future we'll have to figure out how to fix this and support other qualifiers too. 11 | 12 | (defn- bindings-vector? [x] 13 | (and (hooks/vector-node? x) 14 | (every? (some-fn hooks/token-node? 15 | hooks/map-node? 16 | hooks/vector-node?) 17 | (:children x)))) 18 | 19 | (defn- single-arity-fn-tail? [args] 20 | (bindings-vector? (first args))) 21 | 22 | (defn- n-arity-fn-tail? [args] 23 | (and (seq args) 24 | (every? (fn [x] 25 | (and (hooks/list-node? x) 26 | (single-arity-fn-tail? (:children x)))) 27 | args))) 28 | 29 | (defn- fn-tail? [args] 30 | (or (single-arity-fn-tail? args) 31 | (n-arity-fn-tail? args))) 32 | 33 | (defn- qualifier? [x] 34 | (and (hooks/keyword-node? x) 35 | (#{:before :after :around} (hooks/sexpr x)))) 36 | 37 | (defn- dispatch-value? 38 | "A dispatch value can be anything except for qualifier keyword or a list that looks like part of a n-arity function tail 39 | e.g. `([x] x)`." 40 | [x] 41 | (and (not (qualifier? x)) 42 | (or (not (hooks/list-node? x)) 43 | (not (single-arity-fn-tail? (:children x)))))) 44 | 45 | (defonce ^:private backtrack (Exception.)) 46 | 47 | (defn- parse-defmethod-args 48 | ([unparsed] 49 | (let [parses (atom [])] 50 | (try 51 | (parse-defmethod-args parses {} unparsed) 52 | (catch Exception _ 53 | (when (zero? (count @parses)) 54 | (throw (ex-info (format "Unable to parse defmethod args: %s" (pr-str (mapv hooks/sexpr unparsed))) 55 | {:args (mapv hooks/sexpr unparsed)}))) 56 | (when (> (count @parses) 1) 57 | (throw (ex-info (format "Ambiguous defmethod args: %s" (pr-str (mapv hooks/sexpr unparsed))) 58 | {:args (mapv hooks/sexpr unparsed) 59 | :parses @parses}))) 60 | (first @parses))))) 61 | 62 | ([parses parsed unparsed] 63 | (cond 64 | (and (not (contains? parsed :qualifier)) 65 | (qualifier? (first unparsed))) 66 | (try 67 | (parse-defmethod-args parses (assoc parsed :qualifier (first unparsed)) (rest unparsed)) 68 | (catch Exception _ 69 | (parse-defmethod-args parses (assoc parsed :qualifier nil) unparsed))) 70 | 71 | (and (not (contains? parsed :dispatch-value)) 72 | (dispatch-value? (first unparsed))) 73 | (parse-defmethod-args parses (assoc parsed :dispatch-value (first unparsed)) (rest unparsed)) 74 | 75 | (not (contains? parsed :dispatch-value)) 76 | (throw backtrack) 77 | 78 | (and (not (contains? parsed :unique-key)) 79 | (:qualifier parsed) ; can only have unique keys for aux methods 80 | (not (hooks/string-node? (first unparsed))) 81 | (not (hooks/list-node? (first unparsed))) 82 | (not (hooks/vector-node? (first unparsed)))) 83 | (try 84 | (parse-defmethod-args parses (assoc parsed :unique-key (first unparsed)) (rest unparsed)) 85 | (catch Exception _ 86 | (parse-defmethod-args parses (assoc parsed :unique-key nil) unparsed))) 87 | 88 | (and (not (contains? parsed :docstring)) 89 | (hooks/string-node? (first unparsed))) 90 | (try 91 | (parse-defmethod-args parses (assoc parsed :docstring (first unparsed)) (rest unparsed)) 92 | (catch Exception _ 93 | (parse-defmethod-args parses (assoc parsed :docstring nil) unparsed))) 94 | 95 | (fn-tail? unparsed) 96 | (do 97 | (swap! parses conj (assoc parsed :fn-tail unparsed)) 98 | (throw backtrack)) 99 | 100 | :else 101 | (throw backtrack)))) 102 | 103 | (defn defmethod 104 | [{{[_ multimethod & args] :children, :as node} :node}] 105 | (#_println) 106 | #_(clojure.pprint/pprint (hooks/sexpr node)) 107 | (let [parsed (parse-defmethod-args args)] 108 | #_(doseq [[k v] parsed] 109 | (println \newline k '=> (pr-str (some-> v hooks/sexpr)))) 110 | (let [fn-tail (:fn-tail parsed) 111 | other-stuff (dissoc parsed :fn-tail) 112 | result (hooks/list-node 113 | (concat 114 | [(hooks/token-node 'do) 115 | multimethod] 116 | (filter some? (vals other-stuff)) 117 | [(-> (hooks/list-node 118 | (list* 119 | (hooks/token-node 'fn) 120 | (hooks/token-node (if (contains? #{nil :around} (some-> (:qualifier parsed) hooks/sexpr)) 121 | 'next-method 122 | '__FN__NAME__THAT__YOU__CANNOT__REFER__TO__)) 123 | fn-tail)) 124 | (vary-meta update :clj-kondo/ignore conj :redundant-fn-wrapper))]))] 125 | #_(println "=>") 126 | #_(clojure.pprint/pprint (hooks/sexpr result)) 127 | {:node result}))) 128 | 129 | ;;; this stuff is for debugging things to make sure we didn't do something dumb 130 | (comment 131 | (defn defmethod* [form] 132 | (binding [*print-meta* true] 133 | (clojure.pprint/pprint 134 | (hooks/sexpr (:node (defmethod {:node (hooks/parse-string (str form))})))))) 135 | 136 | (defmethod* '(defmethod mf :second [& _] 2)) 137 | 138 | (defmethod* '(m/defmethod multi-arity :k 139 | ([x] 140 | {:x x}) 141 | ([x y] 142 | {:x x, :y y}))) 143 | 144 | (defmethod* '(m/defmethod mf1 :docstring 145 | "Docstring" 146 | [_x])) 147 | 148 | (defmethod* '(m/defmethod mf1 :around :dispatch-value 149 | "Docstring" 150 | [x] 151 | (next-method x)))) 152 | 153 | (defn defmulti 154 | [{{[_ multimethod-name & args] :children, :as node} :node}] 155 | #_(clojure.pprint/pprint (hooks/sexpr node)) 156 | (let [[docstring & args] (if (hooks/string-node? (first args)) 157 | args 158 | (cons nil args)) 159 | [attribute-map & args] (if (hooks/map-node? (first args)) 160 | args 161 | (cons nil args)) 162 | ;; if there wasn't a positional dispatch function arg passed just use (constantly nil) so Kondo won't complain 163 | [dispatch-fn & kv-options] (if (odd? (count args)) 164 | args 165 | (cons (hooks/list-node 166 | (list 167 | (hooks/token-node 'clojure.core/constantly) 168 | (hooks/token-node 'nil))) 169 | args))] 170 | (let [defmulti-form (hooks/list-node 171 | (filter 172 | some? 173 | [(hooks/token-node 'clojure.core/defmulti) 174 | multimethod-name 175 | docstring 176 | attribute-map 177 | dispatch-fn])) 178 | result (hooks/list-node 179 | (list* 180 | (hooks/token-node 'do) 181 | defmulti-form 182 | kv-options))] 183 | #_(println "=>") 184 | #_(clojure.pprint/pprint (hooks/sexpr result)) 185 | {:node result}))) 186 | -------------------------------------------------------------------------------- /.clj-kondo/methodical/methodical/macros/methodical/impl/combo/operator.clj: -------------------------------------------------------------------------------- 1 | (ns macros.methodical.impl.combo.operator) 2 | 3 | ;; not exactly what actually happens but this is close enough to be able to lint it 4 | (defmacro defoperator [operator-name [methods-binding invoke-binding] & body] 5 | `(defmethod methodical.impl.combo.operator/operator ~(keyword operator-name) 6 | [~methods-binding ~invoke-binding] 7 | ~@body)) 8 | -------------------------------------------------------------------------------- /.clj-kondo/metosin/malli/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {malli.experimental/defn schema.core/defn} 2 | :linters {:unresolved-symbol {:exclude [(malli.core/=>)]}}} 3 | -------------------------------------------------------------------------------- /.clj-kondo/prismatic/schema/config.edn: -------------------------------------------------------------------------------- 1 | {:lint-as {schema.test/deftest clojure.test/deftest}} 2 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((nil 2 | (indent-tabs-mode . nil) 3 | (require-final-newline . t) 4 | (fill-column . 120)) 5 | 6 | (clojure-mode 7 | (cider-clojure-cli-aliases . "dev") 8 | (clojure-indent-style . always-align) 9 | (cljr-favor-prefix-notation . nil) 10 | (clojure-docstring-fill-column . 120))) 11 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | *.* @camsaul 2 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Clojure 2 | inputs: 3 | clojure-version: 4 | required: true 5 | default: "1.11.1.1413" 6 | java-version: 7 | required: true 8 | default: "17" 9 | cache-key: 10 | required: true 11 | 12 | runs: 13 | using: composite 14 | steps: 15 | - name: Prepare JDK 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: ${{ inputs.java-version }} 19 | distribution: 'temurin' 20 | - name: Setup Clojure 21 | uses: DeLaGuardo/setup-clojure@12.5 22 | with: 23 | cli: ${{ inputs.clojure-version }} 24 | - name: Restore cache 25 | uses: actions/cache@v3 26 | with: 27 | path: | 28 | ~/.m2/repository 29 | ~/.gitlibs 30 | ~/.deps.clj 31 | key: v1-${{ hashFiles('./deps.edn') }}-${{ inputs.cache-key }} 32 | restore-keys: | 33 | v1-${{ hashFiles('./deps.edn') }}- 34 | v1- 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-20.04 11 | environment: Deployment 12 | steps: 13 | - uses: actions/checkout@v4.1.0 14 | with: 15 | fetch-depth: 0 16 | - uses: ./.github/actions/setup 17 | with: 18 | cache-key: deploy 19 | - name: Build Hawk 20 | run: >- 21 | clojure -T:build jar 22 | env: 23 | GITHUB_SHA: ${{ env.GITHUB_SHA }} 24 | - name: Deploy Hawk 25 | run: >- 26 | clojure -T:build deploy 27 | env: 28 | CLOJARS_USERNAME: ${{ secrets.CLOJARS_USERNAME }} 29 | CLOJARS_PASSWORD: ${{ secrets.CLOJARS_PASSWORD }} 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | kondo: 11 | runs-on: ubuntu-24.04 12 | timeout-minutes: 10 13 | steps: 14 | - uses: actions/checkout@v4.1.0 15 | - uses: ./.github/actions/setup 16 | with: 17 | cache-key: kondo 18 | - name: Run Kondo 19 | run: >- 20 | clojure -M:kondo --lint src test 21 | 22 | tests: 23 | runs-on: ubuntu-24.04 24 | timeout-minutes: 10 25 | steps: 26 | - uses: actions/checkout@v4.1.0 27 | - uses: ./.github/actions/setup 28 | with: 29 | cache-key: tests 30 | - name: Run tests 31 | run: >- 32 | clojure -X:dev:test 33 | env: 34 | CI: TRUE 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.jar 3 | *.~undo-tree~ 4 | .clj-kondo/.cache 5 | .cpcache/ 6 | .lein-deps-sum 7 | .lein-failures 8 | .lein-plugins/ 9 | .lein-repl-history 10 | .nrepl-port 11 | /.lsp/ 12 | /checkouts/ 13 | /classes/ 14 | /lib/ 15 | /target/ 16 | pom.xml 17 | pom.xml.asc 18 | # malli + clj-kondo: ignore malli-types 19 | .clj-kondo/metosin/malli-types-clj 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Eclipse Public License - v 2.0 2 | 3 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE 4 | PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION 5 | OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 6 | 7 | 1. DEFINITIONS 8 | 9 | "Contribution" means: 10 | 11 | a) in the case of the initial Contributor, the initial content 12 | Distributed under this Agreement, and 13 | 14 | b) in the case of each subsequent Contributor: 15 | i) changes to the Program, and 16 | ii) additions to the Program; 17 | where such changes and/or additions to the Program originate from 18 | and are Distributed by that particular Contributor. A Contribution 19 | "originates" from a Contributor if it was added to the Program by 20 | such Contributor itself or anyone acting on such Contributor's behalf. 21 | Contributions do not include changes or additions to the Program that 22 | are not Modified Works. 23 | 24 | "Contributor" means any person or entity that Distributes the Program. 25 | 26 | "Licensed Patents" mean patent claims licensable by a Contributor which 27 | are necessarily infringed by the use or sale of its Contribution alone 28 | or when combined with the Program. 29 | 30 | "Program" means the Contributions Distributed in accordance with this 31 | Agreement. 32 | 33 | "Recipient" means anyone who receives the Program under this Agreement 34 | or any Secondary License (as applicable), including Contributors. 35 | 36 | "Derivative Works" shall mean any work, whether in Source Code or other 37 | form, that is based on (or derived from) the Program and for which the 38 | editorial revisions, annotations, elaborations, or other modifications 39 | represent, as a whole, an original work of authorship. 40 | 41 | "Modified Works" shall mean any work in Source Code or other form that 42 | results from an addition to, deletion from, or modification of the 43 | contents of the Program, including, for purposes of clarity any new file 44 | in Source Code form that contains any contents of the Program. Modified 45 | Works shall not include works that contain only declarations, 46 | interfaces, types, classes, structures, or files of the Program solely 47 | in each case in order to link to, bind by name, or subclass the Program 48 | or Modified Works thereof. 49 | 50 | "Distribute" means the acts of a) distributing or b) making available 51 | in any manner that enables the transfer of a copy. 52 | 53 | "Source Code" means the form of a Program preferred for making 54 | modifications, including but not limited to software source code, 55 | documentation source, and configuration files. 56 | 57 | "Secondary License" means either the GNU General Public License, 58 | Version 2.0, or any later versions of that license, including any 59 | exceptions or additional permissions as identified by the initial 60 | Contributor. 61 | 62 | 2. GRANT OF RIGHTS 63 | 64 | a) Subject to the terms of this Agreement, each Contributor hereby 65 | grants Recipient a non-exclusive, worldwide, royalty-free copyright 66 | license to reproduce, prepare Derivative Works of, publicly display, 67 | publicly perform, Distribute and sublicense the Contribution of such 68 | Contributor, if any, and such Derivative Works. 69 | 70 | b) Subject to the terms of this Agreement, each Contributor hereby 71 | grants Recipient a non-exclusive, worldwide, royalty-free patent 72 | license under Licensed Patents to make, use, sell, offer to sell, 73 | import and otherwise transfer the Contribution of such Contributor, 74 | if any, in Source Code or other form. This patent license shall 75 | apply to the combination of the Contribution and the Program if, at 76 | the time the Contribution is added by the Contributor, such addition 77 | of the Contribution causes such combination to be covered by the 78 | Licensed Patents. The patent license shall not apply to any other 79 | combinations which include the Contribution. No hardware per se is 80 | licensed hereunder. 81 | 82 | c) Recipient understands that although each Contributor grants the 83 | licenses to its Contributions set forth herein, no assurances are 84 | provided by any Contributor that the Program does not infringe the 85 | patent or other intellectual property rights of any other entity. 86 | Each Contributor disclaims any liability to Recipient for claims 87 | brought by any other entity based on infringement of intellectual 88 | property rights or otherwise. As a condition to exercising the 89 | rights and licenses granted hereunder, each Recipient hereby 90 | assumes sole responsibility to secure any other intellectual 91 | property rights needed, if any. For example, if a third party 92 | patent license is required to allow Recipient to Distribute the 93 | Program, it is Recipient's responsibility to acquire that license 94 | before distributing the Program. 95 | 96 | d) Each Contributor represents that to its knowledge it has 97 | sufficient copyright rights in its Contribution, if any, to grant 98 | the copyright license set forth in this Agreement. 99 | 100 | e) Notwithstanding the terms of any Secondary License, no 101 | Contributor makes additional grants to any Recipient (other than 102 | those set forth in this Agreement) as a result of such Recipient's 103 | receipt of the Program under the terms of a Secondary License 104 | (if permitted under the terms of Section 3). 105 | 106 | 3. REQUIREMENTS 107 | 108 | 3.1 If a Contributor Distributes the Program in any form, then: 109 | 110 | a) the Program must also be made available as Source Code, in 111 | accordance with section 3.2, and the Contributor must accompany 112 | the Program with a statement that the Source Code for the Program 113 | is available under this Agreement, and informs Recipients how to 114 | obtain it in a reasonable manner on or through a medium customarily 115 | used for software exchange; and 116 | 117 | b) the Contributor may Distribute the Program under a license 118 | different than this Agreement, provided that such license: 119 | i) effectively disclaims on behalf of all other Contributors all 120 | warranties and conditions, express and implied, including 121 | warranties or conditions of title and non-infringement, and 122 | implied warranties or conditions of merchantability and fitness 123 | for a particular purpose; 124 | 125 | ii) effectively excludes on behalf of all other Contributors all 126 | liability for damages, including direct, indirect, special, 127 | incidental and consequential damages, such as lost profits; 128 | 129 | iii) does not attempt to limit or alter the recipients' rights 130 | in the Source Code under section 3.2; and 131 | 132 | iv) requires any subsequent distribution of the Program by any 133 | party to be under a license that satisfies the requirements 134 | of this section 3. 135 | 136 | 3.2 When the Program is Distributed as Source Code: 137 | 138 | a) it must be made available under this Agreement, or if the 139 | Program (i) is combined with other material in a separate file or 140 | files made available under a Secondary License, and (ii) the initial 141 | Contributor attached to the Source Code the notice described in 142 | Exhibit A of this Agreement, then the Program may be made available 143 | under the terms of such Secondary Licenses, and 144 | 145 | b) a copy of this Agreement must be included with each copy of 146 | the Program. 147 | 148 | 3.3 Contributors may not remove or alter any copyright, patent, 149 | trademark, attribution notices, disclaimers of warranty, or limitations 150 | of liability ("notices") contained within the Program from any copy of 151 | the Program which they Distribute, provided that Contributors may add 152 | their own appropriate notices. 153 | 154 | 4. COMMERCIAL DISTRIBUTION 155 | 156 | Commercial distributors of software may accept certain responsibilities 157 | with respect to end users, business partners and the like. While this 158 | license is intended to facilitate the commercial use of the Program, 159 | the Contributor who includes the Program in a commercial product 160 | offering should do so in a manner which does not create potential 161 | liability for other Contributors. Therefore, if a Contributor includes 162 | the Program in a commercial product offering, such Contributor 163 | ("Commercial Contributor") hereby agrees to defend and indemnify every 164 | other Contributor ("Indemnified Contributor") against any losses, 165 | damages and costs (collectively "Losses") arising from claims, lawsuits 166 | and other legal actions brought by a third party against the Indemnified 167 | Contributor to the extent caused by the acts or omissions of such 168 | Commercial Contributor in connection with its distribution of the Program 169 | in a commercial product offering. The obligations in this section do not 170 | apply to any claims or Losses relating to any actual or alleged 171 | intellectual property infringement. In order to qualify, an Indemnified 172 | Contributor must: a) promptly notify the Commercial Contributor in 173 | writing of such claim, and b) allow the Commercial Contributor to control, 174 | and cooperate with the Commercial Contributor in, the defense and any 175 | related settlement negotiations. The Indemnified Contributor may 176 | participate in any such claim at its own expense. 177 | 178 | For example, a Contributor might include the Program in a commercial 179 | product offering, Product X. That Contributor is then a Commercial 180 | Contributor. If that Commercial Contributor then makes performance 181 | claims, or offers warranties related to Product X, those performance 182 | claims and warranties are such Commercial Contributor's responsibility 183 | alone. Under this section, the Commercial Contributor would have to 184 | defend claims against the other Contributors related to those performance 185 | claims and warranties, and if a court requires any other Contributor to 186 | pay any damages as a result, the Commercial Contributor must pay 187 | those damages. 188 | 189 | 5. NO WARRANTY 190 | 191 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 192 | PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" 193 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR 194 | IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF 195 | TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR 196 | PURPOSE. Each Recipient is solely responsible for determining the 197 | appropriateness of using and distributing the Program and assumes all 198 | risks associated with its exercise of rights under this Agreement, 199 | including but not limited to the risks and costs of program errors, 200 | compliance with applicable laws, damage to or loss of data, programs 201 | or equipment, and unavailability or interruption of operations. 202 | 203 | 6. DISCLAIMER OF LIABILITY 204 | 205 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT 206 | PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS 207 | SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 208 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST 209 | PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 210 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 211 | ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE 212 | EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE 213 | POSSIBILITY OF SUCH DAMAGES. 214 | 215 | 7. GENERAL 216 | 217 | If any provision of this Agreement is invalid or unenforceable under 218 | applicable law, it shall not affect the validity or enforceability of 219 | the remainder of the terms of this Agreement, and without further 220 | action by the parties hereto, such provision shall be reformed to the 221 | minimum extent necessary to make such provision valid and enforceable. 222 | 223 | If Recipient institutes patent litigation against any entity 224 | (including a cross-claim or counterclaim in a lawsuit) alleging that the 225 | Program itself (excluding combinations of the Program with other software 226 | or hardware) infringes such Recipient's patent(s), then such Recipient's 227 | rights granted under Section 2(b) shall terminate as of the date such 228 | litigation is filed. 229 | 230 | All Recipient's rights under this Agreement shall terminate if it 231 | fails to comply with any of the material terms or conditions of this 232 | Agreement and does not cure such failure in a reasonable period of 233 | time after becoming aware of such noncompliance. If all Recipient's 234 | rights under this Agreement terminate, Recipient agrees to cease use 235 | and distribution of the Program as soon as reasonably practicable. 236 | However, Recipient's obligations under this Agreement and any licenses 237 | granted by Recipient relating to the Program shall continue and survive. 238 | 239 | Everyone is permitted to copy and distribute copies of this Agreement, 240 | but in order to avoid inconsistency the Agreement is copyrighted and 241 | may only be modified in the following manner. The Agreement Steward 242 | reserves the right to publish new versions (including revisions) of 243 | this Agreement from time to time. No one other than the Agreement 244 | Steward has the right to modify this Agreement. The Eclipse Foundation 245 | is the initial Agreement Steward. The Eclipse Foundation may assign the 246 | responsibility to serve as the Agreement Steward to a suitable separate 247 | entity. Each new version of the Agreement will be given a distinguishing 248 | version number. The Program (including Contributions) may always be 249 | Distributed subject to the version of the Agreement under which it was 250 | received. In addition, after a new version of the Agreement is published, 251 | Contributor may elect to Distribute the Program (including its 252 | Contributions) under the new version. 253 | 254 | Except as expressly stated in Sections 2(a) and 2(b) above, Recipient 255 | receives no rights or licenses to the intellectual property of any 256 | Contributor under this Agreement, whether expressly, by implication, 257 | estoppel or otherwise. All rights in the Program not expressly granted 258 | under this Agreement are reserved. Nothing in this Agreement is intended 259 | to be enforceable by any entity that is not a Contributor or Recipient. 260 | No third-party beneficiary rights are created under this Agreement. 261 | 262 | Exhibit A - Form of Secondary Licenses Notice 263 | 264 | "This Source Code may also be made available under the following 265 | Secondary Licenses when the conditions for such availability set forth 266 | in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), 267 | version(s), and exceptions or additional permissions here}." 268 | 269 | Simply including a copy of this Agreement, including this Exhibit A 270 | is not sufficient to license the Source Code under Secondary Licenses. 271 | 272 | If it is not possible or desirable to put the notice in a particular 273 | file, then You may include the notice in a location (such as a LICENSE 274 | file in a relevant directory) where a recipient would be likely to 275 | look for such a notice. 276 | 277 | You may add additional accurate notices of copyright ownership. 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Clojars Project](https://clojars.org/io.github.metabase/hawk/latest-version.svg)](https://clojars.org/io.github.metabase/hawk/) 2 | 3 | # Hawk 4 | 5 | It watches your code like a hawk! You like tests, right? Then run them with our state-of-the-art Clojure test runner. 6 | 7 | ![Test Hawk](https://github.com/metabase/hawk/raw/main/assets/test_hawk.png) 8 | 9 | ```clj 10 | ;;; this is not necessarily up to date; use the latest SHA in GitHub 11 | {io.github.metabase/hawk {:sha "ca1775da999ed066947bd37ca5710167f4adecaa"}} 12 | ``` 13 | 14 | Hawk is a Clojure-CLI friendly wrapper around [Eftest](https://github.com/weavejester/eftest) with some extra features 15 | and opinionated behavior. It started out as the [Metabase](https://github.com/metabase/metabase) test runner, but we 16 | spun it out so we can use it in other places too. 17 | 18 | ## Example `deps.edn` config 19 | 20 | ```clj 21 | {:aliases 22 | {:test 23 | {:extra-paths ["test"] 24 | :extra-deps {io.github.metabase/hawk {:sha "ca1775da999ed066947bd37ca5710167f4adecaa"}} 25 | :exec-fn mb.hawk.core/find-and-run-tests-cli}}} 26 | ``` 27 | 28 | ## Leiningen-style test selection 29 | 30 | You can run tests against a single namespace or directory, or one test specifically, by passing `:only [argument]`: 31 | 32 | Arguments to `clojure -X` are read in as EDN; for things other than plain symbols or numbers you usually need to wrap 33 | them in single quotes in your shell. Our test runner uses this argument to determine where to look for tests. Here's 34 | how different EDN forms are interpreted as our test runner: 35 | 36 | | Arg type | Example | Description | 37 | | --- | --- | --- | 38 | | Unqualified Symbol | `my.namespace-test` | Run all tests in this namespace | 39 | | Qualified Symbol | `my.namespace-test/my-test` | Run one specific test | 40 | | String | `'"test/metabase/api"'` | Run all tests in test namespaces in this directory (including subdirectories) | 41 | | Vector of symbols/strings | `'[my.namespace "test/metabase/some_directory"]'` | Union of tests found by the individual items in the vector | 42 | 43 | ### Example commands: 44 | 45 | | Description | Example | 46 | | --- | --- | 47 | | Run tests in a specific namespace | `clojure -X:test :only my.namespace-test` | 48 | | Run a specific test | `clojure -X:test :only my.namespace-test/my-test` | 49 | | Run tests in a specific directory (including subdirectories) | `clojure -X:test :only '"test/metabase/api"'` | 50 | | Run tests in 2 namespaces | `clojure -X:test :only '[my.namespace-test my.other.namespace-test]'` | 51 | 52 | 53 | ## Checking to make sure things don't happen during initialization 54 | 55 | You can use `mb.hawk.init/assert-tests-are-not-initializing` to make sure things that shouldn't be happening as a 56 | side-effect of loading namespaces, such as initializing a database, are not happening where they shouldn't be. 57 | 58 | ```clj 59 | (ns my.namespace 60 | (:require 61 | [mb.hawk.init])) 62 | 63 | (defn initialize-database! [] 64 | (mb.hawk.init/assert-tests-are-not-initializing "Don't initialize the database in a top-level form!") 65 | ...) 66 | ``` 67 | 68 | ## Fancy JUnit Output 69 | 70 | Hawk automatically generates JUnit output using bespoke JUnit output code that prints diffs using 71 | [humane-test-output](https://github.com/pjstadig/humane-test-output). JUnit output is automatically output to 72 | `target/junit`, and only in `:cli/ci` mode. Not currently configurable! Submit a PR if you want to output it somewhere 73 | else. 74 | 75 | ## Parallel Tests 76 | 77 | Unlike Eftest, parallelization in Hawk tests is opt-in. This is mostly a byproduct of it beginning life as the 78 | Metabase test runner. All tests are ran synchronously unless they are given `^:parallel` metadata (either the test 79 | itself, or the namespace). 80 | 81 | Hawk includes `mb.hawk.parallel/assert-test-is-not-parallel`, which you can use to make sure things that shouldn't be ran 82 | in parallel tests are not: 83 | 84 | ```clj 85 | (ns my.namespace 86 | (require [mb.hawk.parallel])) 87 | 88 | (defn do-with-something-redefined [thunk] 89 | (mb.hawk.parallel/assert-test-is-not-parallel "Don't use do-with-something-redefined inside parallel tests!") 90 | (with-redefs [something something-else] 91 | (thunk))) 92 | ``` 93 | 94 | ## Run tests from the REPL 95 | 96 | Run tests from the REPL the same way the CLI will run them: 97 | 98 | ```clj 99 | (mb.hawk.core/find-and-run-tests-repl {:only ['my.namespace-test]}) 100 | ``` 101 | 102 | ## Additional `is` assertion types 103 | 104 | * `re=`: checks whether a string is equal to a regular expression 105 | * `partial=`: like `=` but only compares stuff (using `clojure.data/diff`) that's in `expected`. Anything else is ignored. 106 | * `=?`: see [Approximately Equal](/docs/approximately-equal.md) 107 | 108 | ## Test modes: 109 | 110 | The Hawk test runner can run in one of three modes. 111 | 112 | | Mode | Test Suite Failure Behavior | Show Progress Bar? | 113 | |--|--|--| 114 | | `:repl` | Print summary | No | 115 | | `:cli/local` | call `(System/exit -1)` | Yes | 116 | | `:cli/ci` | call `(System/exit -1)` | No | 117 | 118 | The mode is determined as follows: 119 | 120 | 1. If an explicit `:mode` is passed to the options map (e.g. `:exec-args` or CLI args passed to `clojure -X`), it is 121 | used; 122 | 123 | 2. Otherwise, if the env var `HAWK_MODE` or Java system property `hawk.mode` is specified, it is used; 124 | 125 | 3. Otherwise, if the env var `CI` or system property `ci` is set, `:cli/ci` will be used; 126 | 127 | 4. If you use `mb.hawk.core/find-and-run-tests-cli` as your `:exec-fn`, `:cli/local` will be used; 128 | 129 | 5. If you run tests from the REPL with `mb.hawk.core/find-and-run-tests-repl`, `:repl` will be used. 130 | 131 | ## Matching Namespace Patterns 132 | 133 | Tell the test runner to only run tests against certain namespaces with `:namespace-pattern`: 134 | 135 | ```clj 136 | ;; only run tests against namespaces that start with `my-project` and end with `test` 137 | {:aliases 138 | {:test 139 | {:exec-fn mb.hawk.core/find-and-run-tests-cli 140 | :exec-args {:namespace-pattern "^my-project.*test$"}}}} 141 | ``` 142 | 143 | ## Excluding directories 144 | 145 | `:exclude-directories` passed in the options map will tell Hawk not to look for tests in those directories. This only 146 | works for directories on your classpath, i.e. things included in `:paths`! If you need something more sophisticated, 147 | please submit a PR. 148 | 149 | ```clj 150 | {:aliases 151 | {:test 152 | {:exec-fn mb.hawk.core/find-and-run-tests-cli 153 | :exec-args {:exclude-directories ["src" "resources" "shared/src"]}}}} 154 | ``` 155 | 156 | ## Skipping namespaces or vars with tags 157 | 158 | You can optionally exclude tests in namespaces with certain tags by specifying the `:exclude-tags` option: 159 | 160 | ```clj 161 | {:aliases 162 | {:test 163 | {:exec-fn mb.hawk.core/find-and-run-tests-cli 164 | :exec-args {:exclude-tags [:my-project/skip-namespace]}}}} 165 | ``` 166 | 167 | or 168 | 169 | ``` 170 | clj -X:test :exclude-tags '[:my-project/skip-namespace]' 171 | ``` 172 | 173 | And adding it to namespaces like 174 | 175 | ```clj 176 | (ns ^:my-project/skip-namespace my.namespace 177 | ...) 178 | ``` 179 | 180 | ## Only running tests against namespaces or vars with tags 181 | 182 | The opposite of `:exclude-tags` -- you can only run tests against a certain set of tags with `:only-tags`. If multiple 183 | `:only-tags` are specified, only namespaces or vars that have all of those tags will be run. 184 | 185 | `:only-tags` can be combined with `:only` and/or `:exclude-tags`. 186 | 187 | ``` 188 | clj -X:test :only-tags [:my-project/e2e-test] 189 | ``` 190 | 191 | will only run tests in namespaces like 192 | 193 | ```clj 194 | (ns ^:my-project/e2e-test my-namespace 195 | ...) 196 | ``` 197 | 198 | or ones individually marked `:my-project/e2e-test` like 199 | 200 | ```clj 201 | (deftest ^:my-project/e2e-test my-test 202 | ...) 203 | ``` 204 | 205 | ## Whole-Suite Hooks 206 | 207 | You can specify hooks to run before or after the entire test suite runs like so: 208 | 209 | ```clj 210 | (methodical/defmethod mb.hawk.hooks/before-run ::my-hook 211 | [_options] 212 | (do-something-before-test-suite-starts!)) 213 | 214 | (methodical/defmethod mb.hawk.hooks/after-run ::my-hook 215 | [_options] 216 | (do-cleanup-when-test-suite-finishes!)) 217 | ``` 218 | 219 | `options` are the same options passed to the test runner as a whole, i.e. a combination of those specified in your 220 | `deps.edn` aliases as well as additional command-line options. 221 | 222 | The dispatch value is not particularly important -- one hook will run for each dispatch value -- but you should probably 223 | make it a namespaced keyword to avoid conflicts, and give it a docstring so people know why it's there. The order the 224 | hooks are run in is indeterminate. The docstrings for `before-run` and `after-run` are updated automatically as new 225 | hooks are added; you can check it to see which hooks are in use. Note that hooks will not be ran unless the namespace 226 | they live in is loaded; this may be affected by `:only` options passed to the test runner. 227 | 228 | Return values of methods are ignored; they are done purely for side effects. 229 | 230 | ## Partitioning tests 231 | 232 | You can divide a test suite into multiple partitions using the `:partition/total` and `:partition/index` keys. This is 233 | an easy way to speed up CI by diving large test suites into multiple jobs. 234 | 235 | ``` 236 | clj -X:test '{:partition/total 10, :partition/index 8}' 237 | ... 238 | Running tests in partition 9 of 10 (575 tests of 5753)... 239 | Finding tests took 46.6 s. 240 | Running 575 tests 241 | ... 242 | ``` 243 | 244 | `:partition/index` is zero-based, e.g. if you have ten partitions (`:partiton/total 10`) then the first partition is `0` 245 | and the last is `9`. 246 | 247 | Tests are partitioned at the var (`deftest`) level after all tests are found the usual way, but all tests in any given 248 | namespace will always be split into the same partition. All namespaces that would be loaded if you were running the 249 | entire test suite are still loaded. Partitions are split as evenly as possible, but tests are guaranteed to be split 250 | deterministically into exactly the number of partitions you asked for. 251 | 252 | 253 | ## Additional options 254 | 255 | All other options are passed directly to [Eftest](https://github.com/weavejester/eftest); refer to its documentation 256 | for more information. 257 | 258 | ``` 259 | clj -X:test '{:fail-fast? true}' 260 | ``` 261 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.x 2 | -------------------------------------------------------------------------------- /assets/test_hawk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metabase/hawk/1fe839cfa48549e21b89c6418c747895eda7e7bc/assets/test_hawk.png -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | ;; Further info: https://clojure.org/guides/tools_build#_mixed_java_clojure_build 2 | 3 | (ns build 4 | (:refer-clojure :exclude [compile]) 5 | (:require 6 | [clojure.java.shell :as sh] 7 | [clojure.string :as str] 8 | [clojure.tools.build.api :as b] 9 | [deps-deploy.deps-deploy :as dd])) 10 | 11 | (def lib 'io.github.metabase/hawk) 12 | (def github-url "https://github.com/metabase/hawk") 13 | (def scm-url "git@github.com:metabase/hawk.git") 14 | 15 | (def version-template (str/trim (slurp "VERSION"))) 16 | (assert (re-matches #"\d+\.\d+\.x" version-template)) 17 | 18 | (def major-minor-version (str/replace version-template #"\.x$" "")) 19 | 20 | (defn sh [& args] 21 | (let [{:keys [exit out]} (apply sh/sh args)] 22 | (assert (zero? exit)) 23 | (str/trim out))) 24 | 25 | (defn- commits-since-version-changed [] 26 | (let [last-sha (sh "git" "log" "-1" "--format=%H" "--" "VERSION")] 27 | (parse-long (sh "git" "rev-list" "--count" (str last-sha "..HEAD"))))) 28 | 29 | (defn commit-number [] 30 | (if (= "main" (sh "git" "rev-parse" "--abbrev-ref" "HEAD")) 31 | (commits-since-version-changed) 32 | "9999-SNAPSHOT")) 33 | 34 | (def sha 35 | (or (not-empty (System/getenv "GITHUB_SHA")) 36 | (not-empty (-> (sh/sh "git" "rev-parse" "HEAD") 37 | :out 38 | str/trim)))) 39 | 40 | (def version (str major-minor-version \. (commit-number))) 41 | (def target "target") 42 | (def class-dir (format "%s/classes" target)) 43 | 44 | (def jar-file (format "target/%s-%s.jar" (name lib) version)) 45 | 46 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 47 | 48 | (def pom-template 49 | [[:description "It watches your code like a hawk!"] 50 | [:url github-url] 51 | [:licenses 52 | [:license 53 | [:name "Eclipse Public License"] 54 | [:url "http://www.eclipse.org/legal/epl-v20.html"]]] 55 | [:developers 56 | [:developer 57 | [:name "Cam Saul"]] 58 | [:developer 59 | [:name "John Cromartie"]] 60 | [:developer 61 | [:name "Nemanja Glumac"]] 62 | [:developer 63 | [:name "Cal Herries"]] 64 | [:developer 65 | [:name "Ngoc Khuat"]] 66 | [:developer 67 | [:name "Tim Macdonald"]] 68 | [:developer 69 | [:name "Case Nelson"]] 70 | [:developer 71 | [:name "Filipe Silva"]] 72 | [:developer 73 | [:name "Dan Sutton"]] 74 | [:developer 75 | [:name "Chris Truter"]]] 76 | [:scm 77 | [:url github-url] 78 | [:connection (str "scm:git:" scm-url)] 79 | [:developerConnection (str "scm:git:" scm-url)] 80 | [:tag sha]]]) 81 | 82 | (def default-options 83 | {:lib lib 84 | :version version 85 | :jar-file jar-file 86 | :basis @basis 87 | :class-dir class-dir 88 | :target target 89 | :src-dirs ["src"] 90 | :pom-data pom-template}) 91 | 92 | (defn clean [_] 93 | (b/delete {:path target})) 94 | 95 | (defn jar [opts] 96 | (println "\nStarting to build a JAR...") 97 | (println "\tWriting pom.xml...") 98 | (b/write-pom (merge default-options opts)) 99 | (println "\tCopying source...") 100 | (b/copy-dir {:src-dirs ["src" "resources"] 101 | :target-dir class-dir}) 102 | (printf "\tBuilding %s...\n" jar-file) 103 | (b/jar {:class-dir class-dir 104 | :jar-file jar-file}) 105 | (println "Done! 🦜")) 106 | 107 | 108 | (defn deploy [opts] 109 | (let [opts (merge default-options opts)] 110 | (printf "Deploying %s...\n" jar-file) 111 | (dd/deploy {:installer :remote 112 | :artifact (b/resolve-path jar-file) 113 | :pom-file (b/pom-path (select-keys opts [:lib :class-dir]))}) 114 | (println "Deployed! 🦅"))) 115 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths 2 | ["src" "resources"] 3 | 4 | :deps 5 | {commons-io/commons-io {:mvn/version "2.18.0"} 6 | eftest/eftest {:mvn/version "0.6.0"} 7 | environ/environ {:mvn/version "1.2.0"} 8 | methodical/methodical {:mvn/version "1.0.124"} 9 | pjstadig/humane-test-output {:mvn/version "0.11.0"} 10 | prismatic/schema {:mvn/version "1.4.1"} 11 | metosin/malli {:mvn/version "0.17.0"} 12 | org.clojure/java.classpath {:mvn/version "1.1.0"} 13 | org.clojure/tools.namespace {:mvn/version "1.5.0"}} 14 | 15 | :aliases 16 | {:dev 17 | {:extra-paths ["test"]} 18 | 19 | ;; clj -X:dev:test 20 | :test 21 | {:exec-fn mb.hawk.core/find-and-run-tests-cli 22 | :exec-args {:exclude-directories ["src" "resources"]}} 23 | 24 | ;; clojure -T:build 25 | :build 26 | {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"} 27 | slipset/deps-deploy {:mvn/version "0.2.2"}} 28 | :ns-default build} 29 | 30 | ;; clojure -M:kondo --lint src test 31 | ;; 32 | ;; clojure -M:kondo --version 33 | ;; 34 | ;; clojure -M:kondo --copy-configs --dependencies --lint "$(clojure -A:dev -Spath)" --skip-lint --parallel 35 | ;; 36 | ;; Run Kondo from the JVM using the pinned version. Preferable to running the installed command since we can pin the 37 | ;; version here which may be different from the version installed on your computer. 38 | :kondo 39 | {:replace-deps 40 | {clj-kondo/clj-kondo {:mvn/version "2024.08.29"}} 41 | 42 | :main-opts 43 | ["-m" "clj-kondo.main"]} 44 | 45 | :ci 46 | {:jvm-opts ["-Dhawk.mode" "cli/ci"]} 47 | 48 | ;; Find outdated versions of dependencies. Run with `clojure -M:outdated` 49 | :outdated {;; Note that it is `:deps`, not `:extra-deps` 50 | :deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} 51 | :main-opts ["-m" "antq.core" "--skip=github-action"]}}} 52 | -------------------------------------------------------------------------------- /docs/approximately-equal.md: -------------------------------------------------------------------------------- 1 | # `=?` 2 | 3 | This adds a new test expression type `=?` that uses a [Methodical](https://github.com/camsaul/methodical) multimethod 4 | to decide whether `expected` and `actual` should be "approximately equal". It dispatches on the types of `expected` 5 | and `actual`. 6 | 7 | Now while you can already write all the sort of "approximately equal" things you want in theory using `schema=` (defined 8 | in [`mb.hawk.assert-exprs`](https://github.com/metabase/hawk/blob/main/src/mb/hawk/assert_exprs.clj), in practice it's a 9 | bit of a hassle. Want to convert an `=` to `schema=` and change one key in a map to use `s/Int` instead of a specific 10 | number? Have fun wrapping every other value in `s/eq`. Want to ignore unused keys like `partial=`? You need to stick 11 | `s/Keyword s/Any` in every. single. map. `=?` takes the best of `schema=` and `partial=`, steals a few ideas from 12 | [Expectations](https://github.com/clojure-expectations/expectations), and is more powerful and easier to use than any of 13 | those three. 14 | 15 | `=` usages can be replaced with `=?` with no other changes -- you can replace that one single key with a predicate 16 | function and leave everything else the same. 17 | 18 | Here's some rules I've defined already: 19 | 20 | - Two regex patterns that are the exact same pattern should be considered =?. (For some wacko reason regex patterns 21 | aren't equal unless they're the same object) 22 | 23 | - An `expected` plain Clojure map should be approximately equal to an `actual` record type. We shouldn't need some 24 | hack like `mt/derecordize` to be able to write tests for this stuff 25 | 26 | - an `expected` regex pattern should be approximately equal to an `actual` string if the string matches the 27 | regex. (This is what `re=` currently does. We can replace `re=` with `=?` entirely.) 28 | 29 | - an `expected` function should be approximately equal to a an `actual` value if `(expected actual)` returns truthy. 30 | 31 | - an `expected` map should be approximately equal to an `actual` map if all the keys in `expected` are present in 32 | `actual` and their respective values are approximately equal. In other words, extra keys in `actual` should be 33 | ignored (this is what our `partial=` works) 34 | 35 | - Motivating example: two sublcasses of `Temporal` e.g. `OffsetDateTime` and `ZonedDateTime` should be `=?` if we 36 | would print them exactly the same way. 37 | 38 | Defining new `=?` behaviors is as simple as writing a new `defmethod`. 39 | 40 | ```clj 41 | (methodical/defmethod =?-diff [java.util.regex.Pattern String] 42 | [expected-regex s] 43 | (when-not (re-matches expected-regex s) 44 | (list 'not (list 're-matches expected-regex s)))) 45 | ``` 46 | 47 | Methods are expected to return `nil` if things are approximately equal, or a form explaining why they aren't if they 48 | aren't. In this case, it returns something like 49 | 50 | ```clj 51 | (not (re-matches #"\d+cans" "toucans"))) 52 | ``` 53 | 54 | This is printed in the correct place by humanized test output and other things that can print diffs. 55 | 56 | ## Built-in functions for other `=?` behaviors: 57 | 58 | Built-in functions for `=?` are defined in the `mb.hawk.assert-exprs.approximately-equal` namespace. You can create 59 | an alias for this namespace like so: 60 | ```clj 61 | (require '[mb.hawk.assert-exprs.approximately-equal :as =?]) 62 | ``` 63 | 64 | ### `exactly` 65 | 66 | `exactly` means results have to be exactly equal as if by `=`. Use this to get around the normal way `=?` would 67 | compare things. This works inside collections as well. 68 | 69 | ```clj 70 | (is (=? {:m (=?/exactly {:a 1})} 71 | {:m {:a 1, :b 2}})) 72 | ;; => 73 | expected: {:m (exactly {:a 1})} 74 | 75 | actual: {:m {:a 1, :b 2}} 76 | diff: - {:m (not (= (exactly {:a 1}) {:a 1, :b 2}))} 77 | + nil 78 | ``` 79 | 80 | ### `schema` 81 | 82 | `schema` compares things to a `schema.core` Schema: 83 | 84 | ```clj 85 | (is (=? {:a 1, :b (=?/schema {s/Keyword s/Int})} 86 | {:a 1, :b {:c 2}})) 87 | => ok 88 | 89 | (is (=? {:a 1, :b (=?/schema {s/Keyword s/Int})} 90 | {:a 1, :b {:c 2.0}})) 91 | => 92 | expected: {:a 1, :b (schema {(pred keyword?) (pred integer?)})} 93 | 94 | actual: {:a 1, :b {:c 2.0}} 95 | diff: - {:b {:c (not (integer? 2.0))}} 96 | + nil 97 | ``` 98 | 99 | ### `malli` 100 | 101 | `malli` compares things to a `malli` schema: 102 | 103 | ```clj 104 | (is (=? {:a 1, :b (=?/malli [:map-of :keyword :int])} 105 | {:a 1, :b {:c 2}})) 106 | => ok 107 | 108 | (is (=? {:a 1, :b (=?/malli [:map-of :keyword :int])} 109 | {:a 1, :b {:c 2.0}})) 110 | => 111 | expected: {:a 1, :b (malli [:map-of :keyword :int])} 112 | actual: {:a 1, :b {:c 2.0}} 113 | diff: - {:b {:c ["should be an integer"]}} 114 | 115 | ``` 116 | 117 | ### `approx` 118 | 119 | `approx` compares whether two numbers are approximately equal: 120 | 121 | ```clj 122 | ;; is the difference between actual and 1.5 less than ±0.1? 123 | (is (=? (=?/approx [1.5 0.1]) 124 | 1.51)) 125 | => true 126 | 127 | (is (=? (=?/approx [1.5 0.1]) 128 | 1.6)) 129 | => 130 | expected: (approx [1.5 0.1]) 131 | 132 | actual: 1.6 133 | diff: - (not (approx 1.5 1.6 #_epsilon 0.1)) 134 | + nil 135 | ``` 136 | -------------------------------------------------------------------------------- /resources/clj-kondo.exports/com.github.metabase/hawk/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:unresolved-symbol 3 | {:exclude 4 | [(clojure.test/is [partial= re= schema= =?])]}}} 5 | -------------------------------------------------------------------------------- /src/mb/hawk/assert_exprs.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.assert-exprs 2 | "Custom implementations of [[clojure.test/is]] expressions (i.e., implementations of [[clojure.test/assert-expr]]). 3 | `re=`, `schema=`, `=?`, and more." 4 | (:require 5 | [clojure.data :as data] 6 | [clojure.test :as t] 7 | [clojure.walk :as walk] 8 | [mb.hawk.assert-exprs.approximately-equal :as approximately-equal] 9 | [schema.core :as s])) 10 | 11 | (defmethod t/assert-expr 're= [msg [_ pattern actual]] 12 | `(let [pattern# ~pattern 13 | actual# ~actual 14 | matches?# (when (string? actual#) 15 | (re-matches pattern# actual#))] 16 | (assert (instance? java.util.regex.Pattern pattern#)) 17 | (t/do-report 18 | {:type (if matches?# :pass :fail) 19 | :message ~msg 20 | :expected pattern# 21 | :actual actual# 22 | :diffs (when-not matches?# 23 | [[actual# [pattern# nil]]])}))) 24 | 25 | (defmethod t/assert-expr 'schema= 26 | [message [_ schema actual]] 27 | `(let [schema# ~schema 28 | actual# ~actual 29 | pass?# (nil? (s/check schema# actual#))] 30 | (t/do-report 31 | {:type (if pass?# :pass :fail) 32 | :message ~message 33 | :expected (s/explain schema#) 34 | :actual actual# 35 | :diffs (when-not pass?# 36 | [[actual# [(s/check schema# actual#) nil]]])}))) 37 | 38 | (defn derecordize 39 | "Convert all record types in `form` to plain maps, so tests won't fail." 40 | [form] 41 | (walk/postwalk 42 | (fn [form] 43 | (if (record? form) 44 | (into {} form) 45 | form)) 46 | form)) 47 | 48 | (defn- remove-keys-not-in-expected 49 | "Remove all the extra stuff (i.e. extra map keys or extra sequence elements) from the `actual` diff that's not in the 50 | original `expected` form." 51 | [expected actual] 52 | (cond 53 | (and (map? expected) (map? actual)) 54 | (into {} 55 | (comp (filter (fn [[k _v]] 56 | (contains? expected k))) 57 | (map (fn [[k v]] 58 | [k (remove-keys-not-in-expected (get expected k) v)]))) 59 | actual) 60 | 61 | (and (sequential? expected) 62 | (sequential? actual)) 63 | (cond 64 | (empty? expected) [] 65 | (empty? actual) [] 66 | :else (into 67 | [(remove-keys-not-in-expected (first expected) (first actual))] 68 | (when (next expected) 69 | (remove-keys-not-in-expected (next expected) (next actual))))) 70 | 71 | :else 72 | actual)) 73 | 74 | (defn- partial=-diff [expected actual] 75 | (let [actual' (remove-keys-not-in-expected expected actual) 76 | [only-in-actual only-in-expected] (data/diff actual' expected)] 77 | {:only-in-actual only-in-actual 78 | :only-in-expected only-in-expected 79 | :pass? (if (coll? only-in-expected) 80 | (empty? only-in-expected) 81 | (nil? only-in-expected))})) 82 | 83 | (defn partial=-report 84 | "Impl for `partial=`. Don't call this directly." 85 | [message expected actual] 86 | (let [expected (derecordize expected) 87 | actual (derecordize actual) 88 | {:keys [only-in-actual only-in-expected pass?]} (partial=-diff expected actual)] 89 | {:type (if pass? :pass :fail) 90 | :message message 91 | :expected expected 92 | :actual actual 93 | :diffs [[actual [only-in-expected only-in-actual]]]})) 94 | 95 | (defmethod t/assert-expr 'partial= 96 | [message [_ expected actual :as form]] 97 | (assert (= (count (rest form)) 2) "partial= expects exactly 2 arguments") 98 | `(t/do-report 99 | (partial=-report ~message ~expected ~actual))) 100 | 101 | (defn =?-report 102 | "Implementation for `=?` -- don't use this directly." 103 | [message multifn expected actual] 104 | (let [diff (if multifn 105 | (approximately-equal/=?-diff* multifn expected actual) 106 | (approximately-equal/=?-diff* expected actual))] 107 | {:type (if (not diff) :pass :fail) 108 | :message message 109 | :expected expected 110 | :actual actual 111 | :diffs [[actual [diff nil]]]})) 112 | 113 | (defmethod t/assert-expr '=? 114 | [message [_ & form]] 115 | (let [[multifn expected actual] (case (count form) 116 | 2 (cons nil form) 117 | 3 form 118 | (throw (ex-info "=? expects either 2 or 3 arguments" {:form form})))] 119 | `(t/do-report (=?-report ~message ~multifn ~expected ~actual)))) 120 | -------------------------------------------------------------------------------- /src/mb/hawk/assert_exprs/approximately_equal.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.assert-exprs.approximately-equal 2 | "See documentation in `docs/approximately-equal.md`." 3 | (:require 4 | [clojure.pprint :as pprint] 5 | [malli.core :as m] 6 | [malli.error :as me] 7 | [methodical.core :as methodical] 8 | [schema.core :as s])) 9 | 10 | (set! *warn-on-reflection* true) 11 | 12 | #_{:clj-kondo/ignore [:dynamic-var-not-earmuffed]} 13 | (methodical/defmulti ^:dynamic =?-diff 14 | "Multimethod to use to diff two things with `=?`. Despite not having earmuffs, this is dynamic so it can be rebound at 15 | runtime." 16 | {:arglists '([expected actual])} 17 | (fn [expected actual] 18 | [(type expected) (type actual)])) 19 | 20 | (defn- add-primary-methods 21 | "Add primary methods in map `m` of dispatch value -> method fn to [[*impl*]]. Return a new multifn with those methods 22 | added." 23 | [m] 24 | (reduce 25 | (fn [multifn [dispatch-value f]] 26 | (methodical/add-primary-method multifn dispatch-value f)) 27 | =?-diff 28 | m)) 29 | 30 | (def ^:dynamic *debug* 31 | "Whether to enable Methodical method tracing for debug purposes." 32 | false) 33 | 34 | (def ^:private ^:dynamic *same* 35 | nil) 36 | 37 | (defn =?-diff* 38 | "Are `expected` and `actual` 'approximately' equal to one another?" 39 | ([expected actual] 40 | (=?-diff* =?-diff expected actual)) 41 | 42 | ([diff-fn expected actual] 43 | (let [diff-fn (if (map? diff-fn) 44 | (add-primary-methods diff-fn) 45 | diff-fn)] 46 | (binding [=?-diff diff-fn 47 | *same* (atom {})] 48 | (if *debug* 49 | (methodical/trace diff-fn expected actual) 50 | (diff-fn expected actual)))))) 51 | 52 | ;;;; Default method impls 53 | 54 | (methodical/defmethod =?-diff :default 55 | [expected actual] 56 | (when-not (= expected actual) 57 | (list 'not= expected actual))) 58 | 59 | (methodical/defmethod =?-diff [Class Object] 60 | [expected-class actual] 61 | (when-not (instance? expected-class actual) 62 | (list 'not (list 'instance? expected-class actual)))) 63 | 64 | (methodical/defmethod =?-diff [java.util.regex.Pattern String] 65 | [expected-regex s] 66 | (when-not (re-matches expected-regex s) 67 | (list 'not (list 're-matches expected-regex s)))) 68 | 69 | ;;; two regexes should be treated as equal if they're the same pattern. 70 | (methodical/defmethod =?-diff [java.util.regex.Pattern java.util.regex.Pattern] 71 | [expected actual] 72 | (when-not (= (str expected) (str actual)) 73 | (list 'not= (list 'str expected) (list 'str actual)))) 74 | 75 | (methodical/defmethod =?-diff [clojure.lang.AFunction Object] 76 | [pred actual] 77 | (when-not (pred actual) 78 | (list 'not (list pred actual)))) 79 | 80 | (methodical/defmethod =?-diff [clojure.lang.Sequential clojure.lang.Sequential] 81 | [expected actual] 82 | (let [same-size? (= (count expected) 83 | (count actual))] 84 | ;; diff items at each index, e.g. (=?-diff (first expected) (first actual)) then (=?-diff (second expected) (second 85 | ;; actual)) and so forth. Keep diffing until BOTH sequences are empty. 86 | (loop [diffs [] 87 | expected expected 88 | actual actual] 89 | (if (and (empty? expected) 90 | (empty? actual)) 91 | ;; If there are no more items then return the vector the diffs, if there were any 92 | ;; non-nil diffs, OR if the sequences were of different sizes. The diff between [1 2 nil] and [1 2] 93 | ;; in [[clojure.data/diff]] is [nil nil nil]; that's what we'll return in this situation too. 94 | (when (or (some some? diffs) 95 | (not same-size?)) 96 | diffs) 97 | ;; when there is at least element left in either `expected` or `actual`, diff the first item in each. If one of 98 | ;; these is empty, it will diff against `nil`, but that's ok, because we will still fail because `same-size?` 99 | ;; above will be false 100 | (let [this-diff (=?-diff (first expected) (first actual))] 101 | (recur (conj diffs this-diff) (rest expected) (rest actual))))))) 102 | 103 | (methodical/defmethod =?-diff [clojure.lang.IPersistentMap clojure.lang.IPersistentMap] 104 | [expected-map actual-map] 105 | (not-empty (into {} (for [[k expected] expected-map 106 | :let [actual (get actual-map k (symbol "nil #_\"key is not present.\"")) 107 | diff (=?-diff expected actual)] 108 | :when diff] 109 | [k diff])))) 110 | 111 | (deftype Exactly [expected]) 112 | 113 | (defn exactly 114 | "Used inside a =? expression. Results have to be exactly equal as if by =. Use this to get around the normal way =? 115 | would compare things. This works inside collections as well." 116 | [expected] 117 | (->Exactly expected)) 118 | 119 | (defmethod print-method Exactly 120 | [this writer] 121 | ((get-method print-dup Exactly) this writer)) 122 | 123 | (defmethod print-dup Exactly 124 | [^Exactly this ^java.io.Writer writer] 125 | (.write writer (format "(exactly %s)" (pr-str (.expected this))))) 126 | 127 | (defmethod pprint/simple-dispatch Exactly 128 | [^Exactly this] 129 | (pprint/pprint-logical-block 130 | :prefix "(exactly " :suffix ")" 131 | (pprint/write-out (.expected this)))) 132 | 133 | (methodical/defmethod =?-diff [Exactly :default] 134 | [^Exactly this actual] 135 | (let [expected (.expected this)] 136 | (when-not (= expected actual) 137 | (list 'not (list '= (list 'exactly expected) actual))))) 138 | 139 | (deftype Schema [schema]) 140 | 141 | (defn schema 142 | "Used inside a =? expression. Compares things to a schema.core schema." 143 | [schema] 144 | (->Schema schema)) 145 | 146 | (defmethod print-method Schema 147 | [this writer] 148 | ((get-method print-dup Schema) this writer)) 149 | 150 | (defmethod print-dup Schema 151 | [^Schema this ^java.io.Writer writer] 152 | (.write writer (format "(schema %s)" (pr-str (.schema this))))) 153 | 154 | (defmethod pprint/simple-dispatch Schema 155 | [^Schema this] 156 | (pprint/pprint-logical-block 157 | :prefix "(malli " :suffix ")" 158 | (pprint/write-out (.schema this)))) 159 | 160 | (methodical/defmethod =?-diff [Schema :default] 161 | [^Schema this actual] 162 | (s/check (.schema this) actual)) 163 | 164 | (deftype Malli [schema]) 165 | 166 | (defn malli 167 | "Used inside a =? expression. Compares things to a malli schema." 168 | [schema] 169 | (->Malli schema)) 170 | 171 | (defmethod print-dup Malli 172 | [^Malli this ^java.io.Writer writer] 173 | (.write writer (format "(malli %s)" (pr-str (.schema this))))) 174 | 175 | (defmethod print-method Malli 176 | [this writer] 177 | ((get-method print-dup Malli) this writer)) 178 | 179 | (defmethod pprint/simple-dispatch Malli 180 | [^Malli this] 181 | (pprint/pprint-logical-block 182 | :prefix "(malli " :suffix ")" 183 | (pprint/write-out (.schema this)))) 184 | 185 | (methodical/defmethod =?-diff [Malli :default] 186 | [^Malli this actual] 187 | (me/humanize (m/explain (.schema this) actual))) 188 | 189 | (deftype Approx [expected epsilon]) 190 | 191 | (defn approx 192 | "Used inside a =? expression. Compares whether two numbers are approximately equal." 193 | [form] 194 | (let [form (eval form) 195 | _ (assert (sequential? form) "Expected (approx [expected epsilon])") 196 | [expected epsilon] form] 197 | (assert (number? expected)) 198 | (assert (number? epsilon)) 199 | (->Approx expected epsilon))) 200 | 201 | (defmethod print-method Approx 202 | [this writer] 203 | ((get-method print-dup Approx) this writer)) 204 | 205 | (defmethod print-dup Approx 206 | [^Approx this ^java.io.Writer writer] 207 | (.write writer (format "(approx %s)" (pr-str [(.expected this) (.epsilon this)])))) 208 | 209 | (defmethod pprint/simple-dispatch Approx 210 | [^Approx this] 211 | (pprint/pprint-logical-block 212 | :prefix "(approx " :suffix ")" 213 | (pprint/write-out [(.expected this) (.epsilon this)]))) 214 | 215 | (defn- approx= 216 | "Return true if the absolute value of the difference between x and y is less than eps. 217 | Simple replacement for algo.generic.math/approx=" 218 | [x y eps] 219 | (< (Math/abs (- (double x) (double y))) (double eps))) 220 | 221 | (methodical/defmethod =?-diff [Approx Number] 222 | [^Approx this actual] 223 | (let [expected (.expected this) 224 | epsilon (.epsilon this)] 225 | (when-not (approx= expected actual epsilon) 226 | (list 'not (list 'approx expected actual (symbol "#_epsilon") epsilon))))) 227 | 228 | (deftype Same [k]) 229 | 230 | (defn same 231 | "Used inside a =? expression. Checks that all occurrences of the same [[k]] value are equal. 232 | 233 | On the first occurrence of `(same k)`, it saves the actual value under [[k]]. 234 | All other occurrences of `(same k)` are expected to be equal to that saved value. 235 | 236 | ``` 237 | (is (?= [(same :id) (same :id)}] [1 1])) ; => true 238 | (is (?= [(same :id) (same :id)}] [1 2])) ; => false 239 | ```" 240 | [k] 241 | (->Same k)) 242 | 243 | (defmethod print-dup Same 244 | [^Same this ^java.io.Writer writer] 245 | (.write writer (format "(same %s)" (pr-str (.k this))))) 246 | 247 | (defmethod print-method Same 248 | [this writer] 249 | ((get-method print-dup Same) this writer)) 250 | 251 | (defmethod pprint/simple-dispatch Same 252 | [^Same this] 253 | (pprint/pprint-logical-block 254 | :prefix "(same " :suffix ")" 255 | (pprint/write-out (.k this)))) 256 | 257 | (methodical/defmethod =?-diff [Same :default] 258 | [^Same this actual] 259 | (when *same* 260 | (if (contains? @*same* (.k this)) 261 | (let [previous-value (get @*same* (.k this))] 262 | (when-not (= previous-value actual) 263 | (list 'not= (symbol "#_") (list 'same (.k this)) previous-value actual))) 264 | (do 265 | (swap! *same* assoc (.k this) actual) 266 | nil)))) 267 | -------------------------------------------------------------------------------- /src/mb/hawk/core.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.core 2 | (:require 3 | [clojure.java.classpath :as classpath] 4 | [clojure.java.io :as io] 5 | [clojure.pprint :as pprint] 6 | [clojure.set :as set] 7 | [clojure.string :as str] 8 | [clojure.test :as t] 9 | [clojure.tools.namespace.find :as ns.find] 10 | [eftest.report.pretty] 11 | [eftest.report.progress] 12 | [eftest.runner] 13 | [environ.core :as env] 14 | [mb.hawk.assert-exprs] 15 | [mb.hawk.hooks :as hawk.hooks] 16 | [mb.hawk.init :as hawk.init] 17 | [mb.hawk.junit :as hawk.junit] 18 | [mb.hawk.parallel :as hawk.parallel] 19 | [mb.hawk.partition :as hawk.partition] 20 | [mb.hawk.speak :as hawk.speak] 21 | [mb.hawk.util :as u])) 22 | 23 | (set! *warn-on-reflection* true) 24 | 25 | (comment mb.hawk.assert-exprs/keep-me) 26 | 27 | ;;;; Finding tests 28 | 29 | (defmulti find-tests 30 | "Find test vars in `arg`, which can be a string directory name, symbol naming a specific namespace or test, or a 31 | collection of one or more of the above." 32 | {:arglists '([arg options])} 33 | (fn [arg _options] 34 | (type arg))) 35 | 36 | ;; collection of one of the things below 37 | (defmethod find-tests clojure.lang.Sequential 38 | [coll options] 39 | (mapcat #(find-tests % options) coll)) 40 | 41 | ;; directory name 42 | (defmethod find-tests String 43 | [dir-name options] 44 | (find-tests (io/file dir-name) options)) 45 | 46 | (defn- exclude-directory? [dir exclude-directories] 47 | (when (some (fn [directory] 48 | (str/starts-with? (str dir) directory)) 49 | exclude-directories) 50 | (println "Excluding directory" (pr-str (str dir))) 51 | true)) 52 | 53 | (defn- include-namespace? [ns-symbol namespace-pattern] 54 | (if namespace-pattern 55 | (re-matches (re-pattern namespace-pattern) (name ns-symbol)) 56 | true)) 57 | 58 | ;; directory 59 | (defmethod find-tests java.io.File 60 | [^java.io.File file {:keys [namespace-pattern exclude-directories], :as options}] 61 | (when (and (.isDirectory file) 62 | (not (str/includes? (str file) ".gitlibs/libs")) 63 | (not (exclude-directory? file exclude-directories))) 64 | (println "Looking for test namespaces in directory" (str file)) 65 | (->> (ns.find/find-namespaces-in-dir file) 66 | (filter #(include-namespace? % namespace-pattern)) 67 | (mapcat #(find-tests % options))))) 68 | 69 | (defn- load-test-namespace [ns-symb] 70 | (binding [hawk.init/*test-namespace-being-loaded* ns-symb] 71 | (require ns-symb))) 72 | 73 | (defn- find-tests-for-var-symbol 74 | [symb] 75 | (load-test-namespace (symbol (namespace symb))) 76 | [(or (resolve symb) 77 | (throw (ex-info (format "Unable to resolve test named %s" symb) {:test-symbol symb})))]) 78 | 79 | (defn- skip-by-tags? 80 | "Whether we should skip a namespace or test var because it has tags in `:exclude-tags` or is missing tags in 81 | `:only-tags`. Prints debug message as a side-effect." 82 | [ns-or-var options] 83 | (let [tags-set (fn [ns-or-var] 84 | (not-empty (set (keys (meta ns-or-var))))) 85 | excluded-tag? (when-let [exclude-tags (not-empty (set (:exclude-tags options)))] 86 | (when (not-empty (set/intersection exclude-tags (tags-set ns-or-var))) 87 | :exclude)) 88 | missing-tag? (when (var? ns-or-var) 89 | (let [varr ns-or-var] 90 | (when-let [only-tags (not-empty (set (:only-tags options)))] 91 | (when (not-empty (set/difference only-tags 92 | (tags-set (:ns (meta varr))) 93 | (tags-set varr))) 94 | :only))))] 95 | (or excluded-tag? missing-tag?))) 96 | 97 | (defn- find-tests-for-namespace-symbol 98 | [ns-symb options] 99 | (load-test-namespace ns-symb) 100 | (when-not (skip-by-tags? (find-ns ns-symb) options) 101 | (remove #(skip-by-tags? % options) 102 | (eftest.runner/find-tests ns-symb)))) 103 | 104 | ;; a test namespace or individual test 105 | (defmethod find-tests clojure.lang.Symbol 106 | [symb options] 107 | (if (namespace symb) 108 | ;; a actual test var e.g. `metabase.whatever-test/my-test` 109 | (find-tests-for-var-symbol symb) 110 | ;; a namespace e.g. `metabase.whatever-test` 111 | (find-tests-for-namespace-symbol symb options))) 112 | 113 | ;; default -- look in all dirs on the classpath 114 | (defmethod find-tests nil 115 | [_nil options] 116 | (find-tests (classpath/system-classpath) options)) 117 | 118 | (defn find-tests-with-options 119 | "Find tests using the options map as passed to `clojure -X`." 120 | [{:keys [only], :as options}] 121 | (println "Running tests with options" (pr-str options)) 122 | (when only 123 | (println "Running tests in" (pr-str only))) 124 | (let [start-time-ms (System/currentTimeMillis) 125 | tests (-> (find-tests only options) 126 | (hawk.partition/partition-tests options))] 127 | (printf "Finding tests took %s.\n" (u/format-milliseconds (- (System/currentTimeMillis) start-time-ms))) 128 | (println "Running" (count tests) "tests") 129 | tests)) 130 | 131 | ;;;; Running tests & reporting the output 132 | 133 | (defonce ^:private orig-test-var t/test-var) 134 | 135 | (def ^:private ^:dynamic *parallel-test-counter* 136 | nil) 137 | 138 | (defn run-test 139 | "Run a single test `test-var`. Wraps/replaces [[clojure.test/test-var]]." 140 | [test-var] 141 | (binding [hawk.parallel/*parallel?* (hawk.parallel/parallel? test-var)] 142 | (some-> *parallel-test-counter* (swap! update 143 | (if hawk.parallel/*parallel?* 144 | :parallel 145 | :single-threaded) 146 | (fnil inc 0))) 147 | (orig-test-var test-var))) 148 | 149 | (alter-var-root #'t/test-var (constantly run-test)) 150 | 151 | (defn- reporter 152 | "Create a new test reporter/event handler, a function with the signature `(handle-event event)` that gets called once 153 | for every [[clojure.test]] event, including stuff like `:begin-test-run`, `:end-test-var`, and `:fail`." 154 | [options] 155 | (let [stdout-reporter (case (:mode options) 156 | (:cli/ci :repl) eftest.report.pretty/report 157 | :cli/local eftest.report.progress/report)] 158 | (fn handle-event [event] 159 | (hawk.junit/handle-event! event) 160 | (hawk.speak/handle-event! event) 161 | (stdout-reporter event)))) 162 | 163 | (def ^:private env-mode 164 | (cond 165 | (env/env :hawk-mode) 166 | (keyword (env/env :hawk-mode)) 167 | 168 | (env/env :ci) 169 | :cli/ci)) 170 | 171 | (defn run-tests 172 | "Run `test-vars` with `options`, which are passed directly to [[eftest.runner/run-tests]]. 173 | 174 | To run tests from the REPL, use this function. 175 | 176 | ;; run tests in a single namespace 177 | (run (find-tests 'metabase.bad-test nil)) 178 | 179 | ;; run tests in a directory 180 | (run (find-tests \"test/hawk/query_processor_test\" nil))" 181 | ([test-vars] 182 | (run-tests test-vars nil)) 183 | 184 | ([test-vars options] 185 | (let [options (merge {:mode :repl} 186 | options)] 187 | (when-not (every? var? test-vars) 188 | (throw (ex-info "Invalid test vars" {:test-vars test-vars, :options options}))) 189 | ;; don't randomize test order for now please, thanks anyway 190 | (with-redefs [eftest.runner/deterministic-shuffle (fn [_ test-vars] test-vars)] 191 | (binding [*parallel-test-counter* (atom {})] 192 | (merge 193 | (eftest.runner/run-tests 194 | test-vars 195 | (merge 196 | {:capture-output? false 197 | :multithread? :vars 198 | :report (reporter options)} 199 | options)) 200 | @*parallel-test-counter*)))))) 201 | 202 | (defn- run-tests-n-times 203 | "[[run-tests]] but repeat `n` times. 204 | Returns the combined summary of all the individual test runs." 205 | [test-vars options n] 206 | (printf "Running tests %d times\n" n) 207 | (reduce (fn [acc test-result] (merge-with 208 | #(if (number? %2) 209 | (+ %1 %2) 210 | %2) 211 | acc 212 | test-result)) 213 | (for [i (range 1 (inc n))] 214 | (do 215 | (println "----------------------------") 216 | (printf "Starting test iteration #%d\n" i) 217 | (run-tests test-vars options))))) 218 | 219 | (defn- find-and-run-tests-with-options 220 | "Entrypoint for the test runner. `options` are passed directly to `eftest`; see https://github.com/weavejester/eftest 221 | for full list of options." 222 | [options] 223 | (let [start-time-ms (System/currentTimeMillis) 224 | test-vars (find-tests-with-options options) 225 | _ (hawk.hooks/before-run options) 226 | [summary fail?] (try 227 | (let [summary (if-let [n (get options :times)] 228 | (run-tests-n-times test-vars options n) 229 | (run-tests test-vars options)) 230 | fail? (pos? (+ (:error summary) (:fail summary)))] 231 | (pprint/pprint summary) 232 | (printf "Ran %d tests in parallel, %d single-threaded.\n" 233 | (:parallel summary 0) (:single-threaded summary 0)) 234 | (printf "Finding and running tests took %s.\n" 235 | (u/format-milliseconds (- (System/currentTimeMillis) start-time-ms))) 236 | (println (if fail? "Tests failed." "All tests passed.")) 237 | [summary fail?]) 238 | (finally 239 | (hawk.hooks/after-run options)))] 240 | (case (:mode options) 241 | (:cli/local :cli/ci) (System/exit (if fail? 1 0)) 242 | :repl summary))) 243 | 244 | (defn find-and-run-tests-repl 245 | "REPL entrypoint. Find and run tests with options." 246 | [options] 247 | (let [options (merge 248 | {:mode :repl} 249 | (when env-mode 250 | {:mode env-mode}) 251 | options)] 252 | (find-and-run-tests-with-options options))) 253 | 254 | (defn find-and-run-tests-cli 255 | "`clojure -X` entrypoint. Find and run tests with `options`." 256 | [options] 257 | (let [options (merge 258 | {:mode :cli/local} 259 | (when env-mode 260 | {:mode env-mode}) 261 | options)] 262 | (find-and-run-tests-with-options options))) 263 | -------------------------------------------------------------------------------- /src/mb/hawk/hooks.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.hooks 2 | (:require [methodical.core :as methodical])) 3 | 4 | (methodical/defmulti before-run 5 | "Hooks to run before starting the test suite. A good place to do setup that needs to happen before running ANY tests. 6 | Add a new hook like this: 7 | 8 | (methodical/defmethod mb.hawk.hooks/before-run ::my-hook 9 | [_options] 10 | ...) 11 | 12 | `options` are the same options passed to the test runner as a whole, i.e. a combination of those specified in your 13 | `deps.edn` aliases as well as additional command-line options. 14 | 15 | The dispatch value is not particularly important -- one hook will run for each dispatch value -- but you should 16 | probably make it a namespaced keyword to avoid conflicts, and give it a docstring so people know why it's there. The 17 | orders the hooks are run in is indeterminate. The docstring for [[before-run]] is updated automatically as new hooks 18 | are added; you can check it to see which hooks are in use. Note that hooks will not be ran unless the namespace they 19 | live in is loaded; this may be affected by `:only` options passed to the test runner. 20 | 21 | Return values of methods are ignored; they are done purely for side effects." 22 | {:arglists '([options]), :defmethod-arities #{1}} 23 | :none 24 | :combo (methodical/do-method-combination) 25 | :dispatcher (methodical/everything-dispatcher)) 26 | 27 | (methodical/defmethod before-run :default 28 | "Default hook for [[before-run]]; log a message about running before-run hooks." 29 | [_options] 30 | (println "Running before-run hooks...")) 31 | 32 | (methodical/defmulti after-run 33 | "Hooks to run after finishing the test suite, regardless of whether it passed or failed. A good place to do cleanup 34 | after finishing the test suite. Add a new hook like this: 35 | 36 | (methodical/defmethod mb.hawk.hooks/after-run ::my-hook 37 | [_options] 38 | ...) 39 | 40 | `options` are the same options passed to the test runner as a whole, i.e. a combination of those specified in your 41 | `deps.edn` aliases as well as additional command-line options. 42 | 43 | The dispatch value is not particularly important -- one hook will run for each dispatch value -- but you should 44 | probably make it a namespaced keyword to avoid conflicts, and give it a docstring so people know why it's there. The 45 | orders the hooks are run in is indeterminate. The docstring for [[after-run]] is updated automatically as new hooks 46 | are added; you can check it to see which hooks are in use. Note that hooks will not be ran unless the namespace they 47 | live in is loaded; this may be affected by `:only` options passed to the test runner. 48 | 49 | Return values of methods are ignored; they are done purely for side effects." 50 | {:arglists '([options]), :defmethod-arities #{1}} 51 | :none 52 | :combo (methodical/do-method-combination) 53 | :dispatcher (methodical/everything-dispatcher)) 54 | 55 | (methodical/defmethod after-run :default 56 | "Default hook for [[after-run]]; log a message about running after-run hooks." 57 | [_options] 58 | (println "Running after-run hooks...")) 59 | -------------------------------------------------------------------------------- /src/mb/hawk/init.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.init 2 | "Code related to [[mb.hawk.core]] initialization and utils for enforcing that code isn't allowed to run while 3 | loading namespaces." 4 | (:require 5 | [clojure.pprint :as pprint])) 6 | 7 | (def ^:dynamic *test-namespace-being-loaded* 8 | "Bound to the test namespace symbol that's currently getting loaded, if any." 9 | nil) 10 | 11 | (defn assert-tests-are-not-initializing 12 | "Check that we are not in the process of loading test namespaces when starting up [[mb.hawk.core]]. For 13 | example, you probably don't want to be doing stuff like creating application DB connections as a side-effect of 14 | loading test namespaces." 15 | [disallowed-message] 16 | (when *test-namespace-being-loaded* 17 | (let [e (ex-info (str (format "%s happened as a side-effect of loading namespace %s." 18 | disallowed-message *test-namespace-being-loaded*) 19 | " This is not allowed; make sure it's done in tests or fixtures only when running tests.") 20 | {:namespace *test-namespace-being-loaded*})] 21 | (pprint/pprint (Throwable->map e)) 22 | (throw e)))) 23 | -------------------------------------------------------------------------------- /src/mb/hawk/junit.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.junit 2 | (:require 3 | [clojure.test :as t] 4 | [mb.hawk.junit.write :as write])) 5 | 6 | (defmulti ^:private handle-event!* 7 | {:arglists '([event])} 8 | :type) 9 | 10 | (defn handle-event! 11 | "Write JUnit output for a `clojure.test` event such as success or failure." 12 | [{test-var :var, :as event}] 13 | (let [test-var (or test-var 14 | (when (seq t/*testing-vars*) 15 | (last t/*testing-vars*))) 16 | event (merge 17 | {:var test-var} 18 | event 19 | (when test-var 20 | {:ns (:ns (meta test-var))}))] 21 | (try 22 | (handle-event!* event) 23 | (catch Throwable e 24 | (throw (ex-info (str "Error handling event: " (ex-message e)) 25 | {:event event} 26 | e)))))) 27 | 28 | ;; for unknown event types (e.g. `:clojure.test.check.clojure-test/trial`) just ignore them. 29 | (defmethod handle-event!* :default 30 | [_]) 31 | 32 | (defmethod handle-event!* :begin-test-run 33 | [_] 34 | (write/clean-output-dir!) 35 | (write/create-thread-pool!)) 36 | 37 | (defmethod handle-event!* :summary 38 | [_] 39 | (write/wait-for-writes-to-finish)) 40 | 41 | (defmethod handle-event!* :begin-test-ns 42 | [{test-ns :ns}] 43 | (alter-meta! 44 | test-ns assoc ::context 45 | {:start-time-ms (System/currentTimeMillis) 46 | :timestamp (java.time.OffsetDateTime/now) 47 | :test-count 0 48 | :error-count 0 49 | :failure-count 0 50 | :results []})) 51 | 52 | (defmethod handle-event!* :end-test-ns 53 | [{test-ns :ns, :as event}] 54 | (let [context (::context (meta test-ns)) 55 | result (merge 56 | event 57 | context 58 | {:duration-ms (- (System/currentTimeMillis) (:start-time-ms context))})] 59 | (write/write-ns-result! result))) 60 | 61 | (defmethod handle-event!* :begin-test-var 62 | [{test-var :var}] 63 | (alter-meta! 64 | test-var assoc ::context 65 | {:start-time-ms (System/currentTimeMillis) 66 | :assertion-count 0 67 | :results []})) 68 | 69 | (defmethod handle-event!* :end-test-var 70 | [{test-ns :ns, test-var :var, :as event}] 71 | (let [context (::context (meta test-var)) 72 | result (merge 73 | event 74 | context 75 | {:duration-ms (- (System/currentTimeMillis) (:start-time-ms context))})] 76 | (alter-meta! test-ns update-in [::context :results] conj result))) 77 | 78 | (defn- inc-ns-test-counts! [{test-ns :ns, :as _event} & ks] 79 | (alter-meta! test-ns update ::context (fn [context] 80 | (reduce 81 | (fn [context k] 82 | (update context k inc)) 83 | context 84 | ks)))) 85 | 86 | (defn- record-assertion-result! [{test-var :var, :as event}] 87 | (let [event (assoc event :testing-contexts (vec t/*testing-contexts*))] 88 | (alter-meta! test-var update ::context 89 | (fn [context] 90 | (-> context 91 | (update :assertion-count inc) 92 | (update :results conj event)))))) 93 | 94 | (defmethod handle-event!* :pass 95 | [event] 96 | (inc-ns-test-counts! event :test-count) 97 | (record-assertion-result! event)) 98 | 99 | (defmethod handle-event!* :fail 100 | [event] 101 | (inc-ns-test-counts! event :test-count :failure-count) 102 | (record-assertion-result! event)) 103 | 104 | (defmethod handle-event!* :error 105 | [{test-var :var, :as event}] 106 | ;; some `:error` events happen because of errors in fixture initialization and don't have associated vars/namespaces 107 | (when test-var 108 | (inc-ns-test-counts! event :test-count :error-count) 109 | (record-assertion-result! event))) 110 | -------------------------------------------------------------------------------- /src/mb/hawk/junit/write.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.junit.write 2 | "Logic related to writing test results for a namespace to a JUnit XML file. See 3 | https://stackoverflow.com/a/9410271/1198455 for the JUnit output spec." 4 | (:require 5 | [clojure.java.io :as io] 6 | [clojure.pprint :as pprint] 7 | [clojure.string :as str] 8 | [pjstadig.print :as p]) 9 | (:import 10 | (java.util.concurrent Executors ThreadFactory ThreadPoolExecutor TimeUnit) 11 | (javax.xml.stream XMLOutputFactory XMLStreamWriter) 12 | (org.apache.commons.io FileUtils))) 13 | 14 | (def ^String ^:private output-dir "target/junit") 15 | 16 | (defn clean-output-dir! 17 | "Clear any files in the output dir; create it if needed." 18 | [] 19 | (let [file (io/file output-dir)] 20 | (when (and (.exists file) 21 | (.isDirectory file)) 22 | (FileUtils/deleteDirectory file)) 23 | (.mkdirs file))) 24 | 25 | ;; TODO -- not sure it makes sense to do this INSIDE OF CDATA ELEMENTS!!! 26 | (defn- escape-unprintable-characters 27 | [s] 28 | (str/join (for [^char c s] 29 | (if (and (Character/isISOControl c) 30 | (not (Character/isWhitespace c))) 31 | (format "&#%d;" (int c)) 32 | c)))) 33 | 34 | (defn- decolorize [s] 35 | (some-> s (str/replace #"\[[;\d]*m" ""))) 36 | 37 | (defn- decolorize-and-escape 38 | "Remove ANSI color escape sequences, then encode things as character entities as needed" 39 | ^String [s] 40 | (-> s decolorize escape-unprintable-characters)) 41 | 42 | (defn- print-result-description [{:keys [file line message testing-contexts], :as _result}] 43 | (println (format "%s:%d" file line)) 44 | (doseq [s (reverse testing-contexts)] 45 | (println (str/trim (decolorize-and-escape (str s))))) 46 | (when message 47 | (println (decolorize-and-escape message)))) 48 | 49 | (defn- print-expected [expected actual] 50 | (p/rprint "expected: ") 51 | (pprint/pprint expected) 52 | (p/rprint " actual: ") 53 | (pprint/pprint actual) 54 | (p/clear)) 55 | 56 | (defn- write-result-output! 57 | [^XMLStreamWriter w {:keys [expected actual diffs], :as result}] 58 | (.writeCharacters w "\n") 59 | (let [s (with-out-str 60 | (println) 61 | (print-result-description result) 62 | ;; this code is adapted from `pjstadig.util` 63 | (p/with-pretty-writer 64 | (fn [] 65 | (if (seq diffs) 66 | (doseq [[actual [a b]] diffs] 67 | (print-expected expected actual) 68 | (p/rprint " diff:") 69 | (if a 70 | (do (p/rprint " - ") 71 | (pprint/pprint a) 72 | (p/rprint " + ")) 73 | (p/rprint " + ")) 74 | (when b 75 | (pprint/pprint b)) 76 | (p/clear)) 77 | (print-expected expected actual)))))] 78 | (.writeCData w (decolorize-and-escape s)))) 79 | 80 | (defn- write-attributes! [^XMLStreamWriter w m] 81 | (doseq [[k v] m] 82 | (.writeAttribute w (name k) (str v)))) 83 | 84 | (defn- write-element! [^XMLStreamWriter w ^String element-name attributes write-children!] 85 | (.writeCharacters w "\n") 86 | (.writeStartElement w element-name) 87 | (when (seq attributes) 88 | (write-attributes! w attributes)) 89 | (write-children!) 90 | (.writeCharacters w "\n") 91 | (.writeEndElement w)) 92 | 93 | (defmulti ^:private write-assertion-result!* 94 | {:arglists '([^XMLStreamWriter w result])} 95 | (fn [_ result] (:type result))) 96 | 97 | (defmethod write-assertion-result!* :pass 98 | [_ _] 99 | nil) 100 | 101 | (defmethod write-assertion-result!* :fail 102 | [w result] 103 | (write-element! 104 | w "failure" 105 | nil 106 | (fn [] 107 | (write-result-output! w result)))) 108 | 109 | (defmethod write-assertion-result!* :error 110 | [w {:keys [actual], :as result}] 111 | (write-element! 112 | w "error" 113 | (when (instance? Throwable actual) 114 | {:type (.getCanonicalName (class actual))}) 115 | (fn [] 116 | (write-result-output! w result)))) 117 | 118 | (defn- write-assertion-result! [w result] 119 | (try 120 | (write-assertion-result!* w result) 121 | (catch Throwable e 122 | (throw (ex-info (str "Error writing XML for test assertion result: " (ex-message e)) 123 | {:result result} 124 | e))))) 125 | 126 | (defn- write-var-result! [^XMLStreamWriter w result] 127 | (try 128 | (.writeCharacters w "\n") 129 | (write-element! 130 | w "testcase" 131 | {:classname (name (ns-name (:ns result))) 132 | :name (name (symbol (:var result))) 133 | :time (/ (:duration-ms result) 1000.0) 134 | :assertions (:assertion-count result)} 135 | (fn [] 136 | (doseq [result (:results result)] 137 | (write-assertion-result! w result)))) 138 | (catch Throwable e 139 | (throw (ex-info (str "Error writing XML for test var result: " (ex-message e)) 140 | {:result result} 141 | e))))) 142 | 143 | ;; write one output file for each test namespace. 144 | 145 | (defn- write-ns-result!* 146 | ([{test-namespace :ns, :as result}] 147 | (let [filename (str (munge (ns-name (the-ns test-namespace))) ".xml")] 148 | (with-open [w (.createXMLStreamWriter (XMLOutputFactory/newInstance) 149 | (io/writer (io/file output-dir filename) 150 | :encoding "UTF-8"))] 151 | (.writeStartDocument w) 152 | (write-ns-result!* w result) 153 | (.writeEndDocument w)))) 154 | 155 | ([w {test-namespace :ns, :as result}] 156 | (try 157 | (write-element! 158 | w "testsuite" 159 | {:name (name (ns-name test-namespace)) 160 | :time (/ (:duration-ms result) 1000.0) 161 | :timestamp (str (:timestamp result)) 162 | :tests (:test-count result) 163 | :errors (:error-count result) 164 | :failures (:failure-count result)} 165 | (fn [] 166 | (doseq [result (:results result)] 167 | (write-var-result! w result)))) 168 | (catch Throwable e 169 | (throw (ex-info (str "Error writing XML for test namespace result: " (ex-message e)) 170 | {:result result} 171 | e)))))) 172 | 173 | (defonce ^:private thread-pool (atom nil)) 174 | 175 | (defn create-thread-pool! 176 | "Create a thread pool to write JUnit output with. JUnit output is written in background threads so tests are not 177 | slowed down by it." 178 | [] 179 | (let [[^ThreadPoolExecutor old-val] (reset-vals! thread-pool (Executors/newCachedThreadPool 180 | (reify ThreadFactory 181 | (newThread [_ r] 182 | (doto (Thread. r) 183 | (.setName "JUnit XML output writer") 184 | (.setDaemon true))))))] 185 | (when old-val 186 | (.shutdown old-val)))) 187 | 188 | (defn write-ns-result! 189 | "Submit a background thread task to write the JUnit output for the tests in a namespace when an `:end-test-ns` event 190 | is encountered." 191 | [result] 192 | (let [^Callable thunk (fn [] 193 | (write-ns-result!* result))] 194 | (.submit ^ThreadPoolExecutor @thread-pool thunk))) 195 | 196 | (defn wait-for-writes-to-finish 197 | "Wait up to 10 seconds for the thread pool that writes results to finish." 198 | [] 199 | (.shutdown ^ThreadPoolExecutor @thread-pool) 200 | (.awaitTermination ^ThreadPoolExecutor @thread-pool 10 TimeUnit/SECONDS) 201 | (reset! thread-pool nil)) 202 | -------------------------------------------------------------------------------- /src/mb/hawk/parallel.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.parallel 2 | "Code related to running parallel tests, and utilities for disallowing dangerous stuff inside them." 3 | (:require 4 | [clojure.test :as t] 5 | [eftest.runner])) 6 | 7 | (defn parallel? 8 | "Whether `test-var` can be ran in parallel with other parallel tests." 9 | [test-var] 10 | (let [metta (meta test-var)] 11 | (if-some [var-parallel (:parallel metta)] 12 | var-parallel 13 | (:parallel (-> metta :ns meta))))) 14 | 15 | (def ^:private synchronized? (complement parallel?)) 16 | 17 | (alter-var-root #'eftest.runner/synchronized? (constantly synchronized?)) 18 | 19 | (def ^:dynamic *parallel?* 20 | "Whether test currently being ran is being ran in parallel." 21 | nil) 22 | 23 | (defn assert-test-is-not-parallel 24 | "Throw an exception if we are inside a `^:parallel` test." 25 | [disallowed-message] 26 | (when *parallel?* 27 | (let [e (ex-info (format "%s is not allowed inside parallel tests." disallowed-message) {})] 28 | (t/is (throw e))))) 29 | -------------------------------------------------------------------------------- /src/mb/hawk/partition.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.partition 2 | (:require 3 | [clojure.math :as math])) 4 | 5 | (defn- namespace* 6 | "Like [[clojure.core/namespace]] but handles vars." 7 | [x] 8 | (cond 9 | (instance? clojure.lang.Named x) (namespace x) 10 | (var? x) (namespace (symbol x)) 11 | :else nil)) 12 | 13 | (defn- sort-tests-by-namespace 14 | "The test runner normally sorts the namespaces before running tests, so we should do the same before we partition things 15 | if we want them to make sense. Preserve the order of the vars inside each namespace." 16 | [test-vars] 17 | (let [test-var->sort-position (into {} 18 | (map-indexed 19 | (fn [i varr] 20 | [varr i])) 21 | test-vars)] 22 | (sort-by (juxt namespace* test-var->sort-position) 23 | test-vars))) 24 | 25 | (defn- namespace->num-tests 26 | "Return a map of 27 | 28 | namespace string => number of tests in that namespace" 29 | [test-vars] 30 | (reduce 31 | (fn [m test-var] 32 | (update m (namespace* test-var) (fnil inc 0))) 33 | {} 34 | test-vars)) 35 | 36 | (defn- test-var->ideal-partition 37 | "Return a map of 38 | 39 | test-var => ideal partition number 40 | 41 | 'Ideal partition number' is the partition it would live in ideally if we weren't worried about making sure namespaces 42 | are grouped together." 43 | [num-partitions test-vars] 44 | (let [target-partition-size (/ (count test-vars) num-partitions)] 45 | (into {} 46 | (map-indexed (fn [i test-var] 47 | (let [ideal-partition (long (math/floor (/ i target-partition-size)))] 48 | (assert (<= 0 ideal-partition (dec num-partitions))) 49 | [test-var ideal-partition])) 50 | test-vars)))) 51 | 52 | (defn- namespace->possible-partitions 53 | "Return a map of 54 | 55 | namespace string => set of possible partition numbers for its tests 56 | 57 | For most namespaces there should only be one possible partition but for some the ideal split happens in the middle of 58 | the namespace which means we have two possible candidate partitions to put it into." 59 | [num-partitions test-vars] 60 | (let [test-var->ideal-partition (test-var->ideal-partition num-partitions test-vars)] 61 | (reduce 62 | (fn [m test-var] 63 | (update m (namespace* test-var) #(conj (set %) (test-var->ideal-partition test-var)))) 64 | {} 65 | test-vars))) 66 | 67 | (defn- namespace->partition 68 | "Return a map of 69 | 70 | namespace string => canonical partition number for its tests 71 | 72 | If there are multiple possible candidate partitions for a namespace, choose the one that has the least tests in it." 73 | [num-partitions test-vars] 74 | (let [namespace->num-tests (namespace->num-tests test-vars) 75 | namespace->possible-partitions (namespace->possible-partitions num-partitions test-vars) 76 | ;; process all the namespaces that have no question about what partition they should go into first so we have as 77 | ;; accurate a picture of the size of each partition as possible before dealing with the ambiguous ones 78 | namespaces (distinct (map namespace* test-vars)) 79 | multiple-possible-partitions? (fn [nmspace] 80 | (> (count (namespace->possible-partitions nmspace)) 81 | 1)) 82 | namespaces (concat (remove multiple-possible-partitions? namespaces) 83 | (filter multiple-possible-partitions? namespaces))] 84 | ;; Keep track of how many tests are in each partition so far 85 | (:namespace->partition 86 | (reduce 87 | (fn [m nmspace] 88 | (let [partition (first (sort-by (fn [partition] 89 | (get-in m [:partition->size partition])) 90 | (namespace->possible-partitions nmspace)))] 91 | (-> m 92 | (update-in [:partition->size partition] (fnil + 0) (namespace->num-tests nmspace)) 93 | (assoc-in [:namespace->partition nmspace] partition)))) 94 | {} 95 | namespaces)))) 96 | 97 | (defn- make-test-var->partition 98 | "Return a function with the signature 99 | 100 | (f test-var) => partititon-number" 101 | [num-partitions test-vars] 102 | (let [namespace->partition (namespace->partition num-partitions test-vars)] 103 | (fn test-var->partition [test-var] 104 | (get namespace->partition (namespace* test-var))))) 105 | 106 | (defn- partition-tests-into-n-partitions 107 | "Split a sequence of `test-vars` into `num-partitions`, returning a map of 108 | 109 | partition number => sequence of tests 110 | 111 | Attempts to divide tests up into partitions that are as equal as possible, but keeps tests in the same namespace 112 | grouped together." 113 | [num-partitions test-vars] 114 | {:post [(= (count %) num-partitions)]} 115 | (let [test-vars (sort-tests-by-namespace test-vars) 116 | test-var->partition (make-test-var->partition num-partitions test-vars)] 117 | (reduce 118 | (fn [m test-var] 119 | (update m (test-var->partition test-var) #(conj (vec %) test-var))) 120 | (sorted-map) 121 | test-vars))) 122 | 123 | (defn- validate-partition-options [tests {num-partitions :partition/total, partition-index :partition/index, :as _options}] 124 | (assert (and num-partitions partition-index) 125 | ":partition/total and :partition/index must be set together") 126 | (assert (pos-int? num-partitions) 127 | "Invalid :partition/total - must be a positive integer") 128 | (assert (<= num-partitions (count tests)) 129 | "Invalid :partition/total - cannot have more partitions than number of tests") 130 | (assert (int? partition-index) 131 | "Invalid :partition/index - must be an integer") 132 | (assert (<= 0 partition-index (dec num-partitions)) 133 | (format "Invalid :partition/index - must be between 0 and %d" (dec num-partitions)))) 134 | 135 | (defn partition-tests 136 | "Return only `tests` to run for the current partition (if `:partition/total` and `:partition/index` are specified). If 137 | they are not specified this returns all `tests`." 138 | [tests {num-partitions :partition/total, partition-index :partition/index, :as options}] 139 | (if (or num-partitions partition-index) 140 | (do 141 | (validate-partition-options tests options) 142 | (let [partition-index->tests (partition-tests-into-n-partitions num-partitions tests) 143 | partition (get partition-index->tests partition-index)] 144 | (printf "Running tests in partition %d of %d (%d tests of %d)...\n" 145 | (inc partition-index) 146 | num-partitions 147 | (count partition) 148 | (count tests)) 149 | partition)) 150 | tests)) 151 | -------------------------------------------------------------------------------- /src/mb/hawk/speak.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.speak 2 | (:require [clojure.java.shell :as sh])) 3 | 4 | (defmulti handle-event! 5 | "Handles a test event by speaking(!?) it if appropriate" 6 | :type) 7 | 8 | (defn- enabled? [] (some? (System/getenv "SPEAK_TEST_RESULTS"))) 9 | 10 | (defmethod handle-event! :default [_] nil) 11 | 12 | (defmethod handle-event! :summary 13 | [{:keys [error fail]}] 14 | (when (enabled?) 15 | (apply sh/sh "say" 16 | (if (zero? (+ error fail)) 17 | "all tests passed" 18 | "tests failed") 19 | (for [[n s] [[error "error"] 20 | [fail "failure"]] 21 | :when (pos? n)] 22 | (str n " " s (when (< 1 n) "s")))))) 23 | -------------------------------------------------------------------------------- /src/mb/hawk/util.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.util) 2 | 3 | (defn format-nanoseconds 4 | "Format a time interval in nanoseconds to something more readable. (µs/ms/etc.)" 5 | ^String [nanoseconds] 6 | ;; The basic idea is to take `n` and see if it's greater than the divisior. If it is, we'll print it out as that 7 | ;; unit. If more, we'll divide by the divisor and recur, trying each successively larger unit in turn. e.g. 8 | ;; 9 | ;; (format-nanoseconds 500) ; -> "500 ns" 10 | ;; (format-nanoseconds 500000) ; -> "500 µs" 11 | (loop [n nanoseconds, [[unit divisor] & more] [[:ns 1000] 12 | [:µs 1000] 13 | [:ms 1000] 14 | [:s 60] 15 | [:mins 60] 16 | [:hours 24] 17 | [:days 7] 18 | [:weeks (/ 365.25 7)] 19 | [:years Double/POSITIVE_INFINITY]]] 20 | (if (and (> n divisor) 21 | (seq more)) 22 | (recur (/ n divisor) more) 23 | (format "%.1f %s" (double n) (name unit))))) 24 | 25 | (defn format-microseconds 26 | "Format a time interval in microseconds into something more readable." 27 | ^String [microseconds] 28 | (format-nanoseconds (* 1000.0 microseconds))) 29 | 30 | (defn format-milliseconds 31 | "Format a time interval in milliseconds into something more readable." 32 | ^String [milliseconds] 33 | (format-microseconds (* 1000.0 milliseconds))) 34 | -------------------------------------------------------------------------------- /test/mb/hawk/assert_exprs/approximately_equal_test.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.assert-exprs.approximately-equal-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [mb.hawk.assert-exprs :as test-runner.assert-exprs] 5 | [mb.hawk.assert-exprs.approximately-equal :as =?] 6 | [schema.core :as s])) 7 | 8 | (comment test-runner.assert-exprs/keep-me) 9 | 10 | (deftest ^:parallel passing-tests 11 | (testing "basic equality" 12 | (is (=? 100 100))) 13 | (testing "predicate function" 14 | (is (=? int? 100))) 15 | (testing "regexes" 16 | (is (=? #"cans$" "cans"))) 17 | (testing "classes" 18 | (is (=? String 19 | "toucans"))) 20 | (testing "regexes" 21 | (is (=? #"\d+cans$" 22 | #"\d+cans$"))) 23 | (testing "maps" 24 | (is (=? {:a int? 25 | :b {:c [int? String] 26 | :d #".*cans$"}} 27 | {:a 100 28 | :b {:c [2 "cans"] 29 | :d "toucans"}})) 30 | (testing "extra keys in actual" 31 | (is (=? {:a 100} 32 | {:a 100, :b 200}))))) 33 | 34 | (deftest ^:parallel sequences-test 35 | (is (=? [] 36 | [])) 37 | (is (=? [nil] 38 | [nil])) 39 | (is (=? [1 nil 2] 40 | [1 nil 2])) 41 | (is (=? [:a int?] 42 | [:a 100])) 43 | (testing "Should enforce that sequences are of the same length" 44 | (is (= [nil nil (list 'not= nil? nil)] 45 | (=?/=?-diff [int? string? nil?] 46 | [1 "two"]))) 47 | (is (= [nil nil (list 'not= nil? nil) (list 'not= nil "cans")] 48 | (=?/=?-diff [int? string? nil?] 49 | [1 "two" nil "cans"]))) 50 | (testing "Differentiate between [1 2 nil] and [1 2]" 51 | ;; these are the same answers [[clojure.data/diff]] would give in these situations. The output is a little more 52 | ;; obvious in failing tests because you can see the difference between expected and actual in addition to this 53 | ;; diff. 54 | (is (= [nil nil nil] 55 | (=?/=?-diff [1 2 nil] 56 | [1 2]))) 57 | (is (= [nil nil nil] 58 | (=?/=?-diff [1 2] 59 | [1 2 nil])))))) 60 | 61 | (deftest ^:parallel custom-approximately-equal-methods 62 | (is (=? {[String java.time.LocalDate] 63 | (fn [_next-method expected actual] 64 | (let [actual-str (str actual)] 65 | (when-not (= expected actual-str) 66 | (list 'not= expected (list 'java.time.LocalDate/parse actual-str)))))} 67 | "2022-07-14" 68 | (java.time.LocalDate/parse "2022-07-14"))) 69 | 70 | (is (=? {[String String] 71 | (fn [_next-method ^String expected ^String actual] 72 | (when-not (zero? (.compareToIgnoreCase expected actual)) 73 | (list 'not (list 'zero? (list '.compareToIgnoreCase expected actual)))))} 74 | {:a "AbC"} 75 | {:a "abc", :b 100}))) 76 | 77 | (deftest ^:parallel exactly-test 78 | (testing "=?/exactly" 79 | (is (=? {:a 1} 80 | {:a 1, :b 2})) 81 | (testing "Fail when things are not exactly the same, as if by `=`" 82 | ;; these serialize the results to strings because it makes it easier to see what the output will look like when 83 | ;; printed out which is what we actually care about. 84 | (is (= "(not (= (exactly {:a 1}) {:a 1, :b 2}))" 85 | (pr-str (=?/=?-diff (=?/exactly {:a 1}) {:a 1, :b 2})))) 86 | (testing "Inside a map" 87 | (is (= "{:b (not (= (exactly {:a 1}) {:a 1, :b 2}))}" 88 | (pr-str (=?/=?-diff {:a 1, :b (=?/exactly {:a 1})} 89 | {:a 1, :b {:a 1, :b 2}})))))) 90 | (testing "Should pass when things are exactly the same as if by `=`" 91 | (is (nil? (=?/=?-diff (=?/exactly 2) 2))) 92 | (is (=? (=?/exactly 2) 93 | 2)) 94 | (testing "should evaluate args" 95 | (is (=? (=?/exactly (+ 1 1)) 96 | 2))) 97 | (testing "Inside a map" 98 | (is (=? {:a 1, :b (=?/exactly 2)} 99 | {:a 1, :b 2})))))) 100 | 101 | (deftest ^:parallel schema-test 102 | (testing "=?/schema" 103 | (is (=? (=?/schema {:a s/Int}) 104 | {:a 1})) 105 | (testing "Nested inside a collection" 106 | (is (=? {:a 1, :b (=?/schema {s/Keyword s/Int})} 107 | {:a 1, :b {}})) 108 | (is (=? {:a 1, :b (=?/schema {s/Keyword s/Int})} 109 | {:a 1, :b {:c 2}})) 110 | (is (=? {:a 1, :b (=?/schema {s/Keyword s/Int})} 111 | {:a 1, :b {:c 2, :d 3}}))) 112 | (testing "failures" 113 | ;; serialize these to strings and read them back out because Schema actually returns weird classes like 114 | ;; ValidationError or whatever that aren't equal to their printed output 115 | (is (= '{:a (not (integer? 1.0))} 116 | (read-string (pr-str (=?/=?-diff (=?/schema {:a s/Int}) {:a 1.0}))))) 117 | (testing "Inside a collection" 118 | (is (= '{:b {:c (not (integer? 2.0))}} 119 | (read-string (pr-str (=?/=?-diff {:a 1, :b (=?/schema {:c s/Int})} 120 | {:a 1, :b {:c 2.0}}))))))))) 121 | 122 | (deftest ^:parallel malli-test 123 | (testing "=?/malli" 124 | (is (=? (=?/malli [:map [:a :int]]) 125 | {:a 1})) 126 | (testing "Nested inside a collection" 127 | (is (=? {:a 1, :b (=?/malli [:map-of :keyword :int])} 128 | {:a 1, :b {}})) 129 | (is (=? {:a 1, :b (=?/malli [:map-of :keyword :int])} 130 | {:a 1, :b {:c 2}})) 131 | (is (=? {:a 1, :b (=?/malli [:map-of :keyword :int])} 132 | {:a 1, :b {:c 2, :d 3}}))) 133 | (testing "failures" 134 | (is (= '{:a ["should be an integer"]} 135 | (read-string (pr-str (=?/=?-diff (=?/malli [:map [:a :int]]) {:a 1.0}))))) 136 | (testing "Inside a collection" 137 | (is (= '{:b {:c ["should be an integer"]}} 138 | (read-string (pr-str (=?/=?-diff {:a 1, :b (=?/malli [:map [:c :int]])} 139 | {:a 1, :b {:c 2.0}}))))))))) 140 | 141 | (deftest ^:parallel approx-test 142 | (testing "=?/approx" 143 | (is (=? (=?/approx [1.5 0.1]) 144 | 1.51)) 145 | (testing "Nested inside a collection" 146 | (is (=? {:a 1, :b (=?/approx [1.5 0.1])} 147 | {:a 1, :b 1.51}))) 148 | ;; failures below render stuff to strings so we can see it the way it will look in test failures with its nice 149 | ;; comment and whatnot 150 | (testing "failures" 151 | (is (= "(not (approx 1.5 1.6 #_epsilon 0.1))" 152 | (pr-str (=?/=?-diff (=?/approx [1.5 0.1]) 1.6)))) 153 | (testing "Inside a collection" 154 | (is (= "{:b (not (approx 1.5 1.6 #_epsilon 0.1))}" 155 | (pr-str (=?/=?-diff {:a 1, :b (=?/approx [1.5 0.1])} 156 | {:a 1, :b 1.6})))))) 157 | (testing "Eval the args" 158 | (is (=? (=?/approx [(+ 1.0 0.5) (- 1.0 0.9)]) 159 | 1.51))) 160 | (testing "A large epsilon" 161 | (is (=? (=?/approx [1 10.0]) 162 | 9.0)) 163 | (is (= "(not (approx 1 20.0 #_epsilon 10.0))" 164 | (pr-str (=?/=?-diff (=?/approx [1 10.0]) 20.0))))) 165 | (testing "nil should not match the =?/approx method -- fall back to the :default" 166 | (is (= "(not= (approx [1 0.1]) nil)" 167 | (pr-str (=?/=?-diff (=?/approx [1 0.1]) nil))))))) 168 | 169 | (deftest ^:parallel same-test 170 | (testing "pr-str" 171 | (is (= "(same :a)" (pr-str (=?/same :a))))) 172 | (testing "Same does nothing with 1 occurrence" 173 | (is (=? (=?/same :a) 1))) 174 | (testing "Multiple occurrences are the same" 175 | (is (=? [(=?/same :a) (=?/same :b) (=?/same :b) (=?/same :a)] [1 2 2 1]))) 176 | (testing "Works nested" 177 | (is (=? [(=?/same :a) {:nested (=?/same :a)}] [1 {:nested 1}]))) 178 | (testing "Works on complex values" 179 | (is (=? [(=?/same :a) {:nested (=?/same :a)}] [#{1 2 3} {:nested #{1 2 3}}]))) 180 | (testing "When not the same" 181 | (is (= "[nil (not= #_ (same :a) 1 2)]" 182 | (pr-str (=?/=?-diff* [(=?/same :a) (=?/same :a)] [1 2])))) 183 | (is (= "[nil (not= #_ (same :a) 1 2) nil (not= #_ (same :b) 2 3)]" 184 | (pr-str (=?/=?-diff* [(=?/same :a) (=?/same :a) 185 | (=?/same :b) (=?/same :b)] 186 | [1 2 2 3])))) 187 | (testing "Not the same and nested" 188 | (is (= "[nil {:nested (not= #_ (same :a) 1 2)}]" 189 | (pr-str (=?/=?-diff* [(=?/same :a) {:nested (=?/same :a)}] [1 {:nested 2}]))))))) 190 | -------------------------------------------------------------------------------- /test/mb/hawk/assert_exprs_test.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.assert-exprs-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [mb.hawk.assert-exprs :as test-runner.assert-exprs])) 5 | 6 | (deftest partial=-test 7 | (testing "Partial map" 8 | (is (partial= {:a 1} 9 | {:a 1, :b 2})) 10 | (testing "actual missing a key" 11 | (is (= {:only-in-actual nil 12 | :only-in-expected {:b 2} 13 | :pass? false} 14 | (#'test-runner.assert-exprs/partial=-diff {:a 1, :b 2} {:a 1})))) 15 | (testing "actual has wrong value for a key" 16 | (is (= {:only-in-actual {:a 2} 17 | :only-in-expected {:a 1} 18 | :pass? false} 19 | (#'test-runner.assert-exprs/partial=-diff {:a 1, :b 2} {:a 2, :b 2}))))) 20 | 21 | (testing "Partial sequence match" 22 | (is (partial= [1 2 3] 23 | [1 2 3 4 5 6])) 24 | (is (partial= {:a [1 2 3]} 25 | {:a [1 2 3 4 5 6]})) 26 | (testing "actual missing element" 27 | (is (= {:only-in-actual nil 28 | :only-in-expected {:a [nil nil 3]} 29 | :pass? false} 30 | (#'test-runner.assert-exprs/partial=-diff {:a [1 2 3]} {:a [1 2]}))) 31 | (is (= {:only-in-actual nil 32 | :only-in-expected [nil nil 3] 33 | :pass? false} 34 | (#'test-runner.assert-exprs/partial=-diff [1 2 3] [1 2]))) 35 | (is (= [] 36 | (#'test-runner.assert-exprs/remove-keys-not-in-expected ["A"] []))) 37 | (is (= {:only-in-actual nil 38 | :only-in-expected ["A"] 39 | :pass? false} 40 | (#'test-runner.assert-exprs/partial=-diff ["A"] [])))) 41 | (testing "actual has wrong element" 42 | (is (= {:only-in-actual {:a [nil nil 4]} 43 | :only-in-expected {:a [nil nil 3]} 44 | :pass? false} 45 | (#'test-runner.assert-exprs/partial=-diff {:a [1 2 3]} {:a [1 2 4]}))) 46 | (is (= {:only-in-actual [nil nil 4] 47 | :only-in-expected [nil nil 3] 48 | :pass? false} 49 | (#'test-runner.assert-exprs/partial=-diff [1 2 3] [1 2 4]))))) 50 | 51 | (testing "Empty sequence match" 52 | (is (= {:a 1, :b []} 53 | (#'test-runner.assert-exprs/remove-keys-not-in-expected 54 | {:a 1, :b []} 55 | {:a 1, :b [], :c [1]}))) 56 | (is (partial= {:a 1, :b []} 57 | {:a 1, :b []})))) 58 | -------------------------------------------------------------------------------- /test/mb/hawk/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns ^:exclude-tags-test ^:mic/test mb.hawk.core-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [mb.hawk.core :as hawk])) 5 | 6 | (deftest ^:exclude-this-test find-tests-test 7 | (testing "symbol naming" 8 | (testing "namespace" 9 | (let [tests (hawk/find-tests 'mb.hawk.assert-exprs-test nil)] 10 | (is (seq tests)) 11 | (is (every? var? tests)))) 12 | (testing "var" 13 | (is (= [(resolve 'mb.hawk.assert-exprs-test/partial=-test)] 14 | (hawk/find-tests 'mb.hawk.assert-exprs-test/partial=-test nil))))) 15 | (testing "directory" 16 | (let [tests (hawk/find-tests "test/mb/hawk" nil)] 17 | (is (seq tests)) 18 | (is (every? var? tests)) 19 | (is (contains? (set tests) (resolve 'mb.hawk.core-test/find-tests-test))) 20 | (is (contains? (set tests) (resolve 'mb.hawk.assert-exprs-test/partial=-test)))) 21 | (testing "Exclude directories" 22 | (is (empty? (hawk/find-tests nil {:exclude-directories ["src" "test"]})))) 23 | (testing "Namespace pattern" 24 | (is (some? (hawk/find-tests nil {:namespace-pattern "^mb\\.hawk\\.core-test$"}))) 25 | (is (empty? (hawk/find-tests nil {:namespace-pattern "^mb\\.hawk\\.corn-test$"}))))) 26 | (testing "everything" 27 | (let [tests (hawk/find-tests nil nil)] 28 | (is (seq tests)) 29 | (is (every? var? tests)) 30 | (is (contains? (set tests) (resolve 'mb.hawk.core-test/find-tests-test))))) 31 | (testing "sequence" 32 | (let [tests (hawk/find-tests ['mb.hawk.assert-exprs-test 33 | 'mb.hawk.assert-exprs-test/partial=-test 34 | "test/mb/hawk"] 35 | nil)] 36 | (is (seq tests)) 37 | (is (every? var? tests)) 38 | (is (contains? (set tests) (resolve 'mb.hawk.core-test/find-tests-test)))))) 39 | 40 | (def ^:private this-ns (ns-name *ns*)) 41 | 42 | (deftest exclude-tags-test 43 | (are [options] (every? (set (hawk/find-tests this-ns options)) [#'find-tests-test #'exclude-tags-test]) 44 | nil 45 | {:exclude-tags nil} 46 | {:exclude-tags []} 47 | {:exclude-tags #{}} 48 | {:exclude-tags [:another/tag]}) 49 | 50 | (are [options] (not (some (set (hawk/find-tests this-ns options)) [#'find-tests-test #'exclude-tags-test])) 51 | {:exclude-tags [:exclude-tags-test]} 52 | {:exclude-tags #{:exclude-tags-test}} 53 | {:exclude-tags [:exclude-tags-test :another/tag]}) 54 | 55 | (are [options] (let [tests (set (hawk/find-tests this-ns options))] 56 | (and (not (contains? tests #'find-tests-test)) 57 | (contains? tests #'exclude-tags-test))) 58 | {:exclude-tags [:exclude-this-test]} 59 | {:exclude-tags #{:exclude-this-test}} 60 | {:exclude-tags [:exclude-this-test :another/tag]})) 61 | 62 | (deftest only-tags-test 63 | (are [options] (= [] 64 | (hawk/find-tests this-ns options)) 65 | {:only-tags [:another/tag]} 66 | {:only-tags [:another/tag :exclude-tags-test]} 67 | {:only-tags [:another/tag :exclude-this-test]}) 68 | (are [options] (= (hawk/find-tests this-ns {}) 69 | (hawk/find-tests this-ns options)) 70 | {:only-tags [:exclude-tags-test]} 71 | {:only-tags #{:exclude-tags-test}}) 72 | (are [options] (= [#'find-tests-test] 73 | (hawk/find-tests this-ns options)) 74 | {:only-tags [:exclude-this-test]} 75 | {:only-tags #{:exclude-this-test}} 76 | {:only-tags [:exclude-this-test :exclude-tags-test]} 77 | {:only-tags #{:exclude-this-test :exclude-tags-test}}) 78 | (are [options] (= [#'find-tests-test] 79 | (hawk/find-tests this-ns options)) 80 | {:only-tags [:exclude-this-test]} 81 | {:only-tags #{:exclude-this-test}})) 82 | -------------------------------------------------------------------------------- /test/mb/hawk/parallel_test.clj: -------------------------------------------------------------------------------- 1 | (ns ^:parallel mb.hawk.parallel-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [mb.hawk.parallel :as parallel])) 5 | 6 | (deftest ns-parallel-test 7 | (is parallel/*parallel?*)) 8 | 9 | (deftest ^{:parallel false} var-not-parallel-test 10 | (is (not parallel/*parallel?*))) 11 | -------------------------------------------------------------------------------- /test/mb/hawk/partition_test.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.partition-test 2 | (:require 3 | [clojure.test :refer :all] 4 | [mb.hawk.core :as hawk] 5 | [mb.hawk.core-test] 6 | [mb.hawk.parallel-test] 7 | [mb.hawk.partition :as hawk.partition] 8 | [mb.hawk.speak-test])) 9 | 10 | (defn- partition-tests* [num-partitions tests] 11 | (into (sorted-map) 12 | (map (fn [i] 13 | [i (hawk.partition/partition-tests 14 | tests 15 | {:partition/index i, :partition/total num-partitions})])) 16 | (range num-partitions))) 17 | 18 | (deftest ^:parallel partition-tests-test 19 | (is (= '{0 [a/test b/test] 20 | 1 [c/test] 21 | 2 [d/test]} 22 | (partition-tests* 3 '[a/test b/test c/test d/test]))) 23 | (is (= '{0 [a/test b/test] 24 | 1 [c/test d/test] 25 | 2 [e/test]} 26 | (partition-tests* 3 '[a/test b/test c/test d/test e/test])))) 27 | 28 | (deftest ^:parallel partition-tests-evenly-test 29 | (testing "make sure we divide things roughly evenly" 30 | (is (= '{0 [n00/test n01/test n02/test] 31 | 1 [n03/test n04/test n05/test] 32 | 2 [n06/test n07/test] 33 | 3 [n08/test n09/test n10/test] 34 | 4 [n11/test n12/test] 35 | 5 [n13/test n14/test n15/test] 36 | 6 [n16/test n17/test n18/test] 37 | 7 [n19/test n20/test] 38 | 8 [n21/test n22/test n23/test] 39 | 9 [n24/test n25/test]} 40 | (partition-tests* 10 (map #(symbol (format "n%02d/test" %)) (range 26))))) 41 | ;; ideally the split happens in the middle of b here, but b should get put into 0 because 0 only has one other test 42 | ;; while 1 has two. 43 | (is (= '{0 [a/test-1 b/test-1 b/test-2 b/test-3] 44 | 1 [c/test-1 c/test-2]} 45 | (partition-tests* 2 '[a/test-1 b/test-1 b/test-2 b/test-3 c/test-1 c/test-2]))))) 46 | 47 | (deftest ^:parallel partition-should-not-split-in-the-middle-of-a-namespace-test 48 | (testing "Partitioning should not split in the middle of a namespace" 49 | (is (= '{0 [a/test-1 a/test-2 a/test-3] 50 | 1 [b/test-1]} 51 | (partition-tests* 2 '[a/test-1 a/test-2 a/test-3 b/test-1]))))) 52 | 53 | (deftest ^:parallel partition-preserve-order-test 54 | (testing "Partitioning should sort namespaces but preserve order of vars" 55 | (is (= '{0 [a/test-1 a/test-3 a/test-2] 56 | 1 [b/test-1 b/test-2 b/test-3]} 57 | (partition-tests* 2 '[b/test-1 b/test-2 b/test-3 a/test-1 a/test-3 a/test-2]))))) 58 | 59 | (deftest ^:parallel partition-test 60 | (is (= {0 [#'mb.hawk.core-test/find-tests-test 61 | #'mb.hawk.core-test/exclude-tags-test] 62 | 1 [#'mb.hawk.parallel-test/ns-parallel-test 63 | #'mb.hawk.parallel-test/var-not-parallel-test] 64 | 2 [#'mb.hawk.speak-test/speak-results-test]} 65 | (into (sorted-map) 66 | (map (fn [i] 67 | [i (hawk/find-tests-with-options 68 | {:only ['mb.hawk.core-test/find-tests-test 69 | 'mb.hawk.speak-test/speak-results-test 70 | ;; this var intentionally comes after a different var in a different 71 | ;; namespace to make sure we partition things in a way that groups 72 | ;; namespaces together 73 | 'mb.hawk.core-test/exclude-tags-test 74 | 'mb.hawk.parallel-test/ns-parallel-test 75 | 'mb.hawk.parallel-test/var-not-parallel-test] 76 | :partition/index i 77 | :partition/total 3})])) 78 | (range 3))))) 79 | -------------------------------------------------------------------------------- /test/mb/hawk/speak_test.clj: -------------------------------------------------------------------------------- 1 | (ns mb.hawk.speak-test 2 | (:require [clojure.java.shell :as sh] 3 | [clojure.test :refer :all] 4 | [mb.hawk.speak :as hawk.speak])) 5 | 6 | (deftest speak-results-test 7 | (are [error fail expected] (let [sh-args (atom nil)] 8 | (with-redefs [hawk.speak/enabled? (constantly true) 9 | sh/sh (fn [& args] (reset! sh-args (vec args)))] 10 | (hawk.speak/handle-event! {:type :summary :error error :fail fail}) 11 | (= (into ["say"] expected) @sh-args))) 12 | 0 0 ["all tests passed"] 13 | 1 0 ["tests failed" "1 error"] 14 | 2 0 ["tests failed" "2 errors"] 15 | 2 1 ["tests failed" "2 errors" "1 failure"] 16 | 0 2 ["tests failed" "2 failures"])) 17 | --------------------------------------------------------------------------------