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)