Skip to content

Commit

Permalink
Merge pull request #27 from pitch-io/simplified-matchers
Browse files Browse the repository at this point in the history
Simplify matcher setup and fix bug related to negated matchers
  • Loading branch information
jo-sm authored May 30, 2023
2 parents ae2a3a8 + e668d83 commit 6093b64
Show file tree
Hide file tree
Showing 14 changed files with 220 additions and 125 deletions.
13 changes: 13 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion cljest/cljest.edn
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
{:setup-ns cljest.internal-test-setup-ns}
{:aliases ["test"]
:setup-ns cljest.internal-test-setup-ns}
6 changes: 3 additions & 3 deletions cljest/deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}}}}
1 change: 1 addition & 0 deletions cljest/src/cljest/compilation/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]]])

Expand Down
77 changes: 58 additions & 19 deletions cljest/src/cljest/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -125,34 +126,72 @@
(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]
(let [negated? (= 'not (first forms))
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.
Expand Down
7 changes: 0 additions & 7 deletions cljest/src/cljest/core.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
36 changes: 34 additions & 2 deletions cljest/src/cljest/core_test.cljs
Original file line number Diff line number Diff line change
@@ -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)"
Expand All @@ -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})))))))
3 changes: 2 additions & 1 deletion cljest/src/cljest/example_test.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -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")))))

Expand Down
14 changes: 14 additions & 0 deletions cljest/src/cljest/matchers.clj
Original file line number Diff line number Diff line change
@@ -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}))))
117 changes: 27 additions & 90 deletions cljest/src/cljest/matchers.cljs
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 2 additions & 1 deletion docs/component-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!!

Expand Down
Loading

0 comments on commit 6093b64

Please sign in to comment.