diff --git a/changelog.md b/changelog.md
index 487735e..ab30691 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,18 @@
# Changelog
+# Next
+
+## Features
+- [Allow users to define their own matchers.](https://github.com/pitch-io/cljest/pull/27)
+
+## Improvements
+- [Allow aliases to be defined in `cljest.edn` so that Jest can use it when getting the classpath.](https://github.com/pitch-io/cljest/pull/27)
+
+## Bugfixes
+- [Always reinstantiate mocks for each test case when using `setup-mocks`.](https://github.com/pitch-io/cljest/pull/28)
+- [Fix matcher negation.](https://github.com/pitch-io/cljest/pull/27)
+
+
# 1.0.0
## Features
diff --git a/cljest/cljest.edn b/cljest/cljest.edn
index 77ff124..800abca 100644
--- a/cljest/cljest.edn
+++ b/cljest/cljest.edn
@@ -1 +1,2 @@
-{:setup-ns cljest.internal-test-setup-ns}
+{:aliases ["test"]
+ :setup-ns cljest.internal-test-setup-ns}
diff --git a/cljest/deps.edn b/cljest/deps.edn
index 2762dc5..da6a05b 100644
--- a/cljest/deps.edn
+++ b/cljest/deps.edn
@@ -15,6 +15,6 @@
:aliases {:fmt {:extra-deps {cljfmt/cljfmt {:mvn/version "0.9.2"}
nsorg-cli/nsorg-cli {:mvn/version "0.3.1"}}}
:publish {:extra-deps {appliedscience/deps-library {:mvn/version "0.3.4"}}}
- :test {:extra-deps {net.clojars.cyrik/cljs-macroexpand {:mvn/version "0.1.1"}
- com.pitch/uix.core {:mvn/version "0.8.1"}
- com.pitch/uix.dom {:mvn/version "0.8.1"}}}}}
+ :test {:extra-deps {com.pitch/uix.core {:mvn/version "0.8.1"}
+ com.pitch/uix.dom {:mvn/version "0.8.1"}
+ net.clojars.cyrik/cljs-macroexpand {:mvn/version "0.1.1"}}}}}
diff --git a/cljest/src/cljest/compilation/config.clj b/cljest/src/cljest/compilation/config.clj
index 32c8a26..f4eeafb 100644
--- a/cljest/src/cljest/compilation/config.clj
+++ b/cljest/src/cljest/compilation/config.clj
@@ -18,6 +18,7 @@
[:test-src-dirs {:optional true} [:sequential :string]]
[:ns-suffixes [:sequential {:default ['-test]} :symbol]]
[:mode [:enum {:error/message "only :all is allowed" :default :all} :all]]
+ [:aliases {:optional true} [:sequential :string]]
[:setup-ns [:symbol {:default 'cljest.setup}]]
[:formatters-ns {:optional true} [:symbol]]])
diff --git a/cljest/src/cljest/core.clj b/cljest/src/cljest/core.clj
index d8622df..eae6c69 100644
--- a/cljest/src/cljest/core.clj
+++ b/cljest/src/cljest/core.clj
@@ -2,7 +2,8 @@
(:require [cljest.compilation.config :as config]
[cljest.format :as format]
[cljs.analyzer.api :as analyzer.api]
- cljs.env))
+ cljs.env
+ [malli.core :as malli]))
(def ^:private user-defined-formatters-ns (some-> (config/get-config!)
(get :formatters-ns)
@@ -125,19 +126,56 @@
(doseq ~seq-exprs (only ~name ~@body))
js/undefined))
-(defn ^:private value->resolved-sym
- "Resolves the given value to its fully qualified name. If it's a primitive (like true, false, a number) returns
- 'primitive."
- [env value]
- (if (symbol? value)
- (get (analyzer.api/resolve env value) :name 'unknown-symbol)
- 'primitive))
+(def ^:private matcher-resolved-info [:map
+ {:closed true}
+ [:type [:enum :matcher]]
+ [:value :any]
+ [:matcher-name :string]])
+(def ^:private non-matcher-resolved-info [:map
+ {:closed true}
+ [:type [:enum :symbol :primitive]]
+ [:value :any]
+ [:resolved :symbol]])
+(def ^:private resolved-info [:multi {:dispatch :type}
+ [:matcher matcher-resolved-info]
+ [:symbol non-matcher-resolved-info]
+ [:primitive non-matcher-resolved-info]])
+
+;; TODO: instrument using something like `malli.instrument/instrument!`
+;; so we can just use `defn`
+(def ^:private value->resolved-info
+ (malli/-instrument
+ {:schema [:=> [:cat :map :any] resolved-info]}
+ (fn [env value]
+ (if (symbol? value)
+ (let [resolved (analyzer.api/resolve env value)
+ matcher-name (get-in resolved [:meta :jest-matcher])]
+ (if matcher-name
+ {:value value
+ :type :matcher
+ :matcher-name matcher-name}
+ {:value value
+ :type :symbol
+ :resolved (get resolved :name (symbol 'unknown))}))
+ {:value value
+ :type :primitive
+ :resolved (symbol 'primitive)}))))
(defmacro ^:private primitive-is
+ "The form of `is` used when the value is primitive, i.e. not a sequence."
[form negated?]
- (let [resolved-sym (value->resolved-sym &env form)]
- `(binding [cljest.core/*inside-is?* true]
- (cljest.core/is-matcher #(do ~form) ~(format/formatter resolved-sym form negated?)))))
+ (let [{:keys [resolved]} (value->resolved-info &env form)]
+ `(.. (js/expect #(do ~form)) ~'-cljest__is (~'call nil ~(format/formatter resolved form negated?)))))
+
+(defmacro ^:private matcher-is
+ "The form of `is` used when the value is a Jest matcher."
+ [matcher-name body negated?]
+ (let [args (rest body)
+ asserted-value (first args)
+ matcher-options (rest args)]
+ (if negated?
+ `(.. (js/expect ~asserted-value) ~'-not ~(symbol (str "-" matcher-name)) (~'call nil ~@matcher-options))
+ `(.. (js/expect ~asserted-value) ~(symbol (str "-" matcher-name)) (~'call nil ~@matcher-options)))))
(defmacro ^:private complex-is
[forms]
@@ -145,14 +183,15 @@
body (if negated?
(second forms)
forms)
- resolved-sym (if (seq? body)
- (value->resolved-sym &env (first body))
- (value->resolved-sym &env body))]
- ;; For the actual assertion, we want the full body, but for the formatter, we want to pass the possibly inner part
- ;; of (not (...)) to simplify writing the macro.
- `(binding [cljest.core/*inside-is?* true
- cljest.core/*is-body-negated?* ~negated?]
- (cljest.core/is-matcher #(do ~forms) ~(format/formatter resolved-sym body negated?)))))
+ {:keys [resolved type matcher-name]} (if (seq? body)
+ (value->resolved-info &env (first body))
+ (value->resolved-info &env body))]
+ (if (= :matcher type)
+ `(matcher-is ~matcher-name ~body ~negated?)
+
+ ;; For the actual assertion, we want the full body, but for the formatter, we want to pass the possibly inner part
+ ;; of (not (...)) to simplify writing the macro.
+ `(.. (js/expect #(do ~forms)) ~'-cljest__is (~'call nil ~(format/formatter resolved body negated?))))))
(defmacro is
"A generic assertion macro for Jest. Asserts that `form` is truthy.
diff --git a/cljest/src/cljest/core.cljs b/cljest/src/cljest/core.cljs
index 79e52bb..24dfbe0 100644
--- a/cljest/src/cljest/core.cljs
+++ b/cljest/src/cljest/core.cljs
@@ -26,10 +26,3 @@
(spy-on object method-name js/undefined))
([object method-name access-type]
(.spyOn jest object method-name access-type)))
-
-(defn is-matcher
- "The underlying matcher for `is`.
-
- Don't use this directly, use the `cljest.core/is` macro."
- [body-fn formatter]
- (.. (js/expect body-fn) (cljest__is formatter)))
diff --git a/cljest/src/cljest/core_test.cljs b/cljest/src/cljest/core_test.cljs
index fa84ca2..b94b604 100644
--- a/cljest/src/cljest/core_test.cljs
+++ b/cljest/src/cljest/core_test.cljs
@@ -1,5 +1,8 @@
(ns cljest.core-test
- (:require [cljest.core :refer [describe is it]]))
+ (:require [cljest.core :refer [describe is it]]
+ [cljest.matchers :as m]
+ [cyrik.cljs-macroexpand :refer [cljs-macroexpand-all] :rename {cljs-macroexpand-all macroexpand-all}]
+ [malli.core :as malli]))
(describe "is"
(it "should support non-list forms (primitives)"
@@ -19,4 +22,33 @@
(try
(is false)
(catch :default e (reset! ex e)))
- (is @ex))))
+ (is @ex)))
+
+ (it "should create an `is` expect call when expanded with a complex value"
+ (let [expanded (macroexpand-all '(is (= 3 (+ 1 2))))]
+
+ (is (= (macroexpand-all '(. (js/expect #(do (= 3 (+ 1 2)))) -cljest__is)) (nth expanded 1)))
+
+ ;; While it's a bit clunky to validate with `:tuple`, we want to assert that we have a symbol
+ ;; (call), nil, and then a sequence, which is the formatter function. I tried to use `match` but
+ ;; couldn't get it to work.
+ ;;
+ ;; Suggestions for improving this assertion are welcome!
+ (is (malli/validate [:tuple symbol? nil? seq?] (into [] (nth expanded 2))))))
+
+ (it "should create an expect call when expanded with a primitive value"
+ (let [expanded (macroexpand-all '(is true))]
+ (is (= (macroexpand-all '(. (js/expect #(do true)) -cljest__is)) (nth expanded 1)))
+ (is (malli/validate [:tuple symbol? nil? seq?] (into [] (nth expanded 2))))))
+
+ (it "should create only one expect call if called with a matcher"
+ (is (= (macroexpand-all '(is (m/visible? (h.dom/get-by :text "hello"))))
+ (macroexpand-all '(.. (js/expect (h.dom/get-by :text "hello")) -toBeVisible (call nil))))))
+
+ (it "should correctly negate matchers"
+ (is (= (macroexpand-all '(is (not (m/visible? (h.dom/get-by :text "hello")))))
+ (macroexpand-all '(.. (js/expect (h.dom/get-by :text "hello")) -not -toBeVisible (call nil))))))
+
+ (it "should pass arguments to matchers"
+ (is (= (macroexpand-all '(is (m/has-text-content? (h.dom/get-by :text "hello") "world" {:normalizeWhitespace true})))
+ (macroexpand-all '(.. (js/expect (h.dom/get-by :text "hello")) -toHaveTextContent (call nil "world" {:normalizeWhitespace true})))))))
diff --git a/cljest/src/cljest/example_test.cljs b/cljest/src/cljest/example_test.cljs
index 09335f6..08c48f0 100644
--- a/cljest/src/cljest/example_test.cljs
+++ b/cljest/src/cljest/example_test.cljs
@@ -32,7 +32,8 @@
(it "should increment the count when the button is clicked"
(h/async
- (is (m/visible? (h.dom/get-by :text "0 cookies")))
+ ;; This is a bit contrived to illustrate that you can have negated matchers too
+ (is (not (m/has-text-content? (js/document.querySelector "h1") "1 cookies")))
(await (h.dom/click+ (h.dom/get-by :text "Bake some cookies")))
(is (m/visible? (h.dom/get-by :text "1 cookies")))))
diff --git a/cljest/src/cljest/matchers.clj b/cljest/src/cljest/matchers.clj
new file mode 100644
index 0000000..16d71be
--- /dev/null
+++ b/cljest/src/cljest/matchers.clj
@@ -0,0 +1,14 @@
+(ns cljest.matchers)
+
+(defmacro defmatcher
+ "A macro for defining a Jest matcher. Creates a function with metadata that will allow
+ `cljest.core/is` to treat this symbol as a Jest matcher, rather than a regular symbol.
+
+ This allows the compiler to generate simpler code, making one expect call for the matcher,
+ rather than two (the `is` and the underlying matcher).
+
+ When the function defined by `defmatcher` is called, it will throw as it is replaced when
+ compiled in `is`."
+ [sym matcher-name]
+ `(defn ~(with-meta sym {:jest-matcher matcher-name}) [& _#]
+ (throw (ex-info (str "You must call " ~(str sym) " inside of `cljest.core/is`.") {:matcher-name ~matcher-name}))))
diff --git a/cljest/src/cljest/matchers.cljs b/cljest/src/cljest/matchers.cljs
index 904d5b6..525e58b 100644
--- a/cljest/src/cljest/matchers.cljs
+++ b/cljest/src/cljest/matchers.cljs
@@ -1,95 +1,32 @@
(ns cljest.matchers
- (:require [applied-science.js-interop :as j]
- [cljest.core]))
-
-(defn make-matcher
- "Most matchers accept either 0, 1, or 2 arguments. This handles those cases.
-
- Used for matchers that follow these patterns:
-
- ```
- expect(actual).matcherName()
- expect(actual).matcherName(expected)
- expect(actual).matcherName(expected, secondExpected)
-
- ```
- "
- ([name actual]
- (make-matcher name actual js/undefined))
- ([name actual expected]
- (make-matcher name actual expected js/undefined))
- ([name actual expected second-expected]
- (if-not cljest.core/*inside-is?*
- (throw (ex-info (str "You must call " name " inside of `cljest.core/is`.") {}))
- (let [raw-expect-call (js/expect actual)
- expect-call (if cljest.core/*is-body-negated?*
- (j/get raw-expect-call :not)
- raw-expect-call)]
- (j/call expect-call name expected second-expected)
-
- ;; So that `is` will pass.
- true))))
-
-(defn make-optional-matcher
- "Some matchers, like `.toHaveAttribute`, optionally accept an argument (or second argument) to the matcher,
- and can't take js/undefined -- the argument must be unprovided.
-
- Used for matchers that follow these patterns:
-
- ```
- expect(actual).matcherName()
- expect(actual).matcherName(maybeValue)
-
- expect(actual).matcherName(value)
- expect(actual).matcherName(value, maybeExtraValue)
- ```"
- ([name actual maybe-value]
- (make-optional-matcher name actual maybe-value nil))
-
- ([name actual value maybe-extra-value]
- (if-not cljest.core/*inside-is?*
- (throw (ex-info (str "You must call " name " inside of `cljest.core/is`.") {}))
- (let [raw-expect-call (js/expect actual)
- expect-call (if cljest.core/*is-body-negated?*
- (j/get raw-expect-call :not)
- raw-expect-call)]
- (cond
- (nil? value)
- (j/call expect-call name)
-
- (nil? maybe-extra-value)
- (j/call expect-call name value)
-
- :else
- (j/call expect-call name value maybe-extra-value))
- true))))
+ (:require-macros [cljest.matchers :refer [defmatcher]]))
; jest.fn
-(defn called? [spy] (make-matcher "toHaveBeenCalled" spy))
-(defn called-times? [spy n] (make-matcher "toHaveBeenCalledTimes" spy n))
-(defn called-with? [spy & args] (make-matcher "customCalledWith" spy args))
+(defmatcher called? "toHaveBeenCalled")
+(defmatcher called-times? "toHaveBeenCalledTimes")
+(defmatcher called-with? "customCalledWith")
; jest-dom
-(defn disabled? [element] (make-matcher "toBeDisabled" element))
-(defn enabled? [element] (make-matcher "toBeEnabled" element))
-(defn empty-dom-element? [element] (make-matcher "toBeEmptyDOMElement" element))
-(defn in-the-document? [element] (make-matcher "toBeInTheDocument" element))
-(defn invalid? [element] (make-matcher "toBeInvalid" element))
-(defn required? [element] (make-matcher "toBeRequired" element))
-(defn valid? [element] (make-matcher "toBeValid" element))
-(defn visible? [element] (make-matcher "toBeVisible" element))
-(defn contains-element? [element descendent] (make-matcher "toContainElement" element descendent))
-(defn contains-html? [expected actual] (make-matcher "toContainHTML" actual expected))
-(defn has-attribute? [element attribute value] (make-matcher "toHaveAttribute" element attribute value))
-(defn has-class? [element class & [options]] (make-optional-matcher "toHaveClass" element class options))
-(defn has-focus? [element] (make-matcher "toHaveFocus" element))
-(defn has-style? [element css] (make-matcher "toHaveStyle" element css))
-(defn has-text-content? [element text] (make-matcher "toHaveTextContent" element text))
-(defn has-value? [element value] (make-matcher "toHaveValue" element value))
-(defn has-display-value? [element value] (make-matcher "toHaveDisplayValue" element value))
-(defn checked? [element] (make-matcher "toBeChecked" element))
-(defn partially-checked? [element] (make-matcher "toBePartiallyChecked" element))
-(defn has-error-msg? [element message] (make-matcher "toHaveErrorMessage" element message))
-(defn has-accessible-description? [element & [expected-desc]] (make-optional-matcher "toHaveAccessibleDescription" element expected-desc))
-(defn has-accessible-name? [element & [expected-name]] (make-optional-matcher "toHaveAccessibleName" element expected-name))
-(defn has-attr? [element attribute & [value]] (make-optional-matcher "toHaveAttribute" element attribute value))
+(defmatcher disabled? "toBeDisabled")
+(defmatcher enabled? "toBeEnabled")
+(defmatcher empty-dom-element? "toBeEmptyDOMElement")
+(defmatcher in-the-document? "toBeInTheDocument")
+(defmatcher invalid? "toBeInvalid")
+(defmatcher required? "toBeRequired")
+(defmatcher valid? "toBeValid")
+(defmatcher visible? "toBeVisible")
+(defmatcher contains-element? "toContainElement")
+(defmatcher contains-html? "toContainHTML")
+(defmatcher has-attribute? "toHaveAttribute")
+(defmatcher has-class? "toHaveClass")
+(defmatcher has-focus? "toHaveFocus")
+(defmatcher has-style? "toHaveStyle")
+(defmatcher has-text-content? "toHaveTextContent")
+(defmatcher has-value? "toHaveValue")
+(defmatcher has-display-value? "toHaveDisplayValue")
+(defmatcher checked? "toBeChecked")
+(defmatcher partially-checked? "toBePartiallyChecked")
+(defmatcher has-error-msg? "toHaveErrorMessage")
+(defmatcher has-accessible-description? "toHaveAccessibleDescription")
+(defmatcher has-accessible-name? "toHaveAccessibleName")
+(defmatcher has-attr? "toHaveAttribute")
diff --git a/docs/component-tests.md b/docs/component-tests.md
index 3d9f1b9..f94f6ec 100644
--- a/docs/component-tests.md
+++ b/docs/component-tests.md
@@ -87,8 +87,9 @@ Okay, how do we test it? To test it, we need to render our component and simulat
Looking at the above, you'll notice a few things:
- We're rendering our component, `main/the-bakery`, very similarly to how we do in our normal code, and you don't need to keep a reference to it later. Instead, `h.dom/get-by` (and other query functions) use the ["screen"](https://testing-library.com/docs/queries/about#screen), the `document.body`, when looking for elements.
-- We queried by text rather than a testing ID. Whenever possible, it's best to query for elements by their ARIA role, text, or something visible or spec defined, and query by the test ID as a last resort. https://testing-library.com/docs/queries/about#priority
+- We queried by text rather than a testing ID. Whenever possible, it's best to query for elements by their ARIA role, text, or something visible or spec defined, and query by the test ID as a last resort. For some details about when to use which queries, see the [`testing-library` query priority docs](https://testing-library.com/docs/queries/about#priority).
- We're testing the component from the end user's perspective. We don't know that internally it's using `use-state`, and for all we know, it could be using `re-frame` or another state management library.
+- We're using a matcher to assert that the element is visible, which is coming from `cljest.matchers`. For more details about matchers, please see the [`cljest.matchers` namespace](https://github.com/pitch-io/cljest/blob/master/cljest/src/cljest/matchers.cljs) and the [matchers docs](./matchers.md).
## Going further - more cookies!!
diff --git a/docs/matchers.md b/docs/matchers.md
new file mode 100644
index 0000000..ed24c82
--- /dev/null
+++ b/docs/matchers.md
@@ -0,0 +1,58 @@
+# Matchers
+
+[Matchers are the way Jest deals with assertions](https://jestjs.io/docs/using-matchers), and are the functions that are defined on `expect`:
+
+```js
+// `toBe` is the matcher here
+expect(1).toBe(1);
+```
+
+`cljest` has some built-in matchers, primarily coming from [`jest-dom`](https://github.com/testing-library/jest-dom), and is extendable, allowing for you to define your own custom matchers in your code without needing to include them in `cljest` directly.
+
+# How do I use matchers?
+
+Matchers act just like another assertion: you wrap it in `is` and Jest will assert when it's called:
+
+```clj
+(require '[cljest.core :refer [it is]]
+ '[cljest.helpers.dom :as h.dom]
+ '[cljest.matchers :as m]
+ '[uix.core :refer [$ defui]])
+
+(defui my-cool-component
+ []
+ ($ :div.blue "hello world"))
+
+(it "should have the `blue` class when initially rendered"
+ (h.dom/render ($ my-cool-component))
+ (is (m/has-class? (h.dom/get-by :text "hello world") "blue")))
+```
+
+If you don't wrap your matcher in `is`, you'll get an error; matchers must be wrapped in `is` to work.
+
+## Negation
+
+Matchers can also be negated using `not`:
+
+```clj
+(it "should not have the `red` class when initially rendered"
+ (h.dom/render ($ my-cool-component))
+ (is (not (m/has-class? (h.dom/get-by :text "hello world") "red"))))
+```
+
+# Built-in matchers
+
+By default, `cljest` includes matchers from [`jest-dom`](https://github.com/testing-library/jest-dom), such as `toBeVisible`, `toHaveClass`, `toBeValid`, as well as a few assertions for `spy` calls like `called-with?`. These matchers live in [`cljest.matchers`](https://github.com/pitch-io/cljest/blob/master/cljest/src/cljest/matchers.cljs), and so for more details about which matchers are available, please look at the defined matchers in the `cljest.matchers` namespace.
+
+# How do I make my own matcher?
+
+In the event there's a matcher you'd like to use that's not included in `cljest`, you can use the macro `cljest.matcher/defmatcher` to define your matcher. This macro is like `def`, and takes the symbol (like `has-class?`) and the underlying matcher name (like `toHaveClass`):
+
+```clj
+(ns app.custom-matchers
+ (:require-macros [cljest.matchers :refer [defmatcher]]))
+
+(defmatcher has-class? "toHaveClass")
+```
+
+That's all you need to do! The rest is handled internally, including support negation, and basically this macro defines a function that has some metadata that's used during compilation to treat it as a matcher rather than a non-matcher assertion inside of `is`.
diff --git a/jest-preset-cljest/utils.js b/jest-preset-cljest/utils.js
index 7e6df22..0a2f5ce 100644
--- a/jest-preset-cljest/utils.js
+++ b/jest-preset-cljest/utils.js
@@ -31,8 +31,12 @@ function withEnsuredProjectConfig(fn) {
}
function getClassPathDirs() {
+ const { aliases } = getCljestConfig();
+
return childProcess
- .execSync("clojure -Spath", { cwd: jestProjectDir })
+ .execSync(`clojure ${aliases && `-A:${aliases.join(":")}`} -Spath`, {
+ cwd: jestProjectDir,
+ })
.toString()
.trim()
.split(":")
diff --git a/readme.md b/readme.md
index fc37d1a..42cf784 100644
--- a/readme.md
+++ b/readme.md
@@ -27,6 +27,7 @@ For more details, as well as details about getting started and configuration, pl
[Migrating from `cljs.test` to `cljest` and differences](./docs/migrating.md)
[Unit tests](./docs/unit-tests.md)
[Component tests](./docs/component-tests.md)
+[Matchers](./docs/matchers.md)
[Mocking](./docs/mocking.md)
[Async code](./docs/async.md)
[General API docs](./docs/api.md)