From 7a4b12acf4f06730ea9ec5375cee8600b8e4c29e Mon Sep 17 00:00:00 2001 From: Icaro Motta Date: Mon, 27 Mar 2023 11:54:56 -0300 Subject: [PATCH] Make component test helpers usable from the REPL (#15468) This commit makes the test-helpers.component namespace loadable in the REPL, plus other changes that allow for a reasonably enjoyable RDD (REPL-Driven Development) workflow. Why? I want to be able to get instant feedback when I render a component with the RN Testing Library (RNTL), and only once I'm satisfied with my findings is when I proceed to write/update the tests. This nearly instant feedback loop is only feasible using the ClojureScript REPL, and I'd rather not endure long recompilation cycles. Note that by REPL I mean connecting to the CLJS REPL of the Shadow-CLJS :mobile target. Essentially, this is what this commit does: - [x] Allow the test-helpers.component namespace to be evaluated in the REPL. This is now possible because I changed all functions that assumed js/jest existed with a guard clause using the CLJS macro exists?. Without the guard clauses, evaluating the namespace explodes due to stuff like js/jest.useFakeTimers that fail in compile time (it's a syntax sugar macro). - [x] Change the family of functions to get the translation by text to either translate using i18n/label or translate with the dummy prefix tx:, depending if the code is running inside the Jest runtime or not. - [x] Wrap remaining RNTL query functions, except for the find-* ones, since they don't work at all outside the Jest runtime. - [x] All wrapped functions support the original arguments supported by RNTL. Arguments are always converted with clj->js. - [x] All wrapped functions can optionally take a node (ReactTestInstance) as their first argument, otherwise the global screen object will be used. This is very important! See the explanation on section Doesn't RNTL recommend using the screen object? - [x] Update Shadow-CLJS preloads, so that (in development) you can fire off the REPL and always be ready to call component test helpers. This is critical! What else would be possible? Just an idea, but now that we can easily render components using the same machinery provided by RNTL in the tests, we can roughly implement Storybook's Play function https://storybook.js.org/docs/react/writing-stories/play-function Lesson learned: In the REPL, you may need to call (re-frame.core/clear-subscription-cache!), otherwise you will experience subscriptions returning the same value if their arguments are the same. For example, I faced this while playing with the namespace status-im2.contexts.communities.menus.community-options.component-spec. There are better ways to solve this particular problem in the context of tests if we use the tooling provided by day8.re-frame.test. Doesn't RNTL recommend using the screen object? Indeed, it is recommended to use the screen object instead of destructuring the results of RNTL render. It's just easier and less error prone, but this only works reliably within the Jest runtime, since it automatically cleans up rendered state after each test. When using the REPL this is no longer the case, and I faced some errors, like Unable to find node on an unmounted component, where RNTL would refuse to re-render components, even if I explicitly unmounted them or called cleanup. The only reliable solution I found was to store the result of render (a node) and pass it to every subsequent call. This is not a workaround, it's officially supported, but it's a tad less convenient. You can also not pass the node reference and it should work most of the time. Practical examples Workflow suggestion: write your local experiments in the same namespace as the component spec and within the comment macro. This way, you can have the Jest watcher running and a REPL connected to :mobile, and they won't step on each other. For the test watcher, I usually change quo2-core-spec or status-im2.core-spec to only require what I'm interested, otherwise Jest consumes way too many resources. ```clojure ;; Namespace quo2.components.colors.color-picker.component-spec (h/test "color picker color changed" (let [selected (reagent/atom nil)] (h/render [color-picker/view {:on-change #(reset! selected %)}]) (h/fire-event :press (get (h/get-all-by-label-text :color-picker-item) 0)) (-> (h/expect @selected) (.toStrictEqual :blue)))) (comment (def selected (atom nil)) (def c (h/render [color-picker/view {:on-change #(reset! selected %)}])) (h/fire-event :press (get (h/get-all-by-label-text c :color-picker-item) 0)) ;; Options are passed down converted to JS types. (h/debug c {:message "Rendering header"}) @selected ; => :blue ) ``` ```clojure ;; Namespace quo2.components.tags.--tests--.status-tags-component-spec (h/test "renders status tag with pending type" (render-status-tag {:status {:type :pending} :label "Pending" :size :small}) (-> (h/expect (h/get-all-by-label-text :status-tag-pending)) (.toBeTruthy)) (-> (h/expect (h/get-by-text "Pending")) (.toBeTruthy))) (comment (def c (render-status-tag {:status {:type :pending} :label "Pending" :size :small})) (h/get-all-by-label-text c :status-tag-pending)) ``` ```clojure ;; Namespace status-im2.contexts.communities.menus.community-options.component-spec (h/test "joined and muted community" (setup-subs {:communities/my-pending-request-to-join nil :communities/community {:joined true :muted true :token-gated? true}}) (h/render [options/community-options-bottom-sheet {:id "test"}]) (-> (h/expect (h/get-by-translation-text :unmute-community)) (.toBeTruthy))) (comment (setup-subs {:communities/my-pending-request-to-join nil :communities/community {:joined true :muted true :token-gated? true}}) (def c (h/render [options/community-options-bottom-sheet {:id "test"}])) (some? (h/get-by-translation-text c :invite-people-from-contacts)) ; => true ) ``` --- shadow-cljs.edn | 8 +- src/test_helpers/component.cljs | 186 +++++++++++++++++++++++++------- 2 files changed, 156 insertions(+), 38 deletions(-) diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 6c1bc54dca..0ed1c7bc5e 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -45,7 +45,13 @@ :devtools {:autobuild #shadow/env ["SHADOW_AUTOBUILD_ENABLED" :default true :as :bool]} :dev {:devtools {:after-load status-im2.setup.hot-reload/reload :build-notify status-im2.setup.hot-reload/build-notify - :preloads [re-frisk-remote.preload]} + :preloads [re-frisk-remote.preload + ;; In order to use component test helpers in + ;; the REPL we need to preload namespaces + ;; that are not normally required by + ;; production code, such as + ;; @testing-library/react-native. + test-helpers.component]} :closure-defines {status-im2.config/POKT_TOKEN #shadow/env "POKT_TOKEN" status-im2.config/OPENSEA_API_KEY #shadow/env "OPENSEA_API_KEY"} diff --git a/src/test_helpers/component.cljs b/src/test_helpers/component.cljs index 22fbbc4362..ac41beb107 100644 --- a/src/test_helpers/component.cljs +++ b/src/test_helpers/component.cljs @@ -1,69 +1,181 @@ (ns test-helpers.component "Helpers for writing component tests using React Native Testing Library." (:require-macros test-helpers.component) - (:require ["@testing-library/react-native" :as rtl] - [camel-snake-kebab.core :as camel-snake-kebab] - [reagent.core :as reagent])) + (:require + ["@testing-library/react-native" :as rtl] + [camel-snake-kebab.core :as camel-snake-kebab] + [reagent.core :as reagent] + [utils.i18n :as i18n])) + +;;;; React Native Testing Library + +(defn- with-node-or-screen + "Wrap RN Testing Library `method-name` and call it either on a ReactTestInstance + or directly on the screen object. + + `method-name` can be either the name of the native method, or a kebab case + keyword. + + It is often necessary in REPL sessions to call methods on the returned + instance of RNTL `render` method, otherwise you can get weird errors, like + 'Unable to find node on an unmounted component'. This happens because RNTL was + mainly conceptualized to run inside a test runner that automatically cleans up + everything after each test. + + Usage: + (def get-by-text (wrap-screen-or-node :get-by-text)) + + In another file, and with the REPL running with the Shadow-CLJS `:mobile` + target: + + (comment + ;; Consider using a shorter var name when playing in a REPL. + (def component (h/render [quo/counter {} 50])) + (h/get-by-text component \"50\") + + ;; Or without the node it works too, but it is only reliable inside + ;; a test runner. + (h/get-by-text \"50\")) + " + [method-name] + (let [method-name (camel-snake-kebab/->camelCaseString method-name)] + (fn [& args] + (if (= js/Object (type (first args))) ; Check if it's a node instance. + (let [method (aget (first args) method-name)] + (apply method (clj->js (rest args)))) + (let [method (aget rtl/screen method-name)] + (apply method (clj->js args))))))) (defn render [component] (rtl/render (reagent/as-element component))) +(def unmount + "Unmount rendered component. + Sometimes useful to be called in a REPL, but unnecessary when rendering + components with Jest, since components are automatically unmounted after each + test." + (with-node-or-screen :unmount)) + +(def debug + "Pretty-print to STDOUT the current component tree." + (with-node-or-screen :debug)) + (defn fire-event - ([event-name element] - (fire-event event-name element nil)) - ([event-name element data] + ([event-name node] + (fire-event event-name node nil)) + ([event-name node data] (rtl/fireEvent - element + node (camel-snake-kebab/->camelCaseString event-name) (clj->js data)))) -(defn debug - [element] - (rtl/screen.debug element)) +;;; Queries: find-* +;; +;; find-* functions don't work in the REPL because the returned promise is +;; always rejected with ReferenceError: Can't find variable: MessageChannel +;; +;; For this reason, find-* functions only work within the Jest runtime, hence +;; using the wrapper function `with-node-or-screen` is unnecessary. -(defn get-by-test-id - [test-id] - (rtl/screen.getByTestId (name test-id))) +(def find-by-text (comp rtl/screen.findByText name)) -(defn get-by-text - [text] - (rtl/screen.getByText text)) +;;; Queries that work with a REPL and with Jest -(defn find-by-text - [text] - (rtl/screen.findByText text)) +(def get-all-by-text (with-node-or-screen :get-all-by-text)) +(def get-by-text (with-node-or-screen :get-by-text)) +(def query-all-by-text (with-node-or-screen :query-all-by-text)) +(def query-by-text (with-node-or-screen :query-by-text)) -(defn get-by-label-text - [label] - (rtl/screen.getByLabelText (name label))) +(def get-all-by-label-text (with-node-or-screen :get-all-by-label-text)) +(def get-by-label-text (with-node-or-screen :get-by-label-text)) +(def query-all-by-label-text (with-node-or-screen :query-all-by-label-text)) +(def query-by-label-text (with-node-or-screen :query-by-label-text)) -(defn query-by-label-text - "Returns `nil` when label is not found." - [label] - (rtl/screen.queryByLabelText (name label))) +(def get-all-by-display-value (with-node-or-screen :get-all-by-display-value)) +(def get-by-display-value (with-node-or-screen :get-by-display-value)) +(def query-all-by-display-value (with-node-or-screen :query-all-by-display-value)) +(def query-by-display-value (with-node-or-screen :query-by-display-value)) + +(def get-all-by-placeholder-text (with-node-or-screen :get-all-by-placeholder-text)) +(def get-by-placeholder-text (with-node-or-screen :get-by-placeholder-text)) +(def query-all-by-placeholder-text (with-node-or-screen :query-all-by-placeholder-text)) +(def query-by-placeholder-text (with-node-or-screen :query-by-placeholder-text)) + +(def get-all-by-role (with-node-or-screen :get-all-by-role)) +(def get-by-role (with-node-or-screen :get-by-role)) +(def query-all-by-role (with-node-or-screen :query-all-by-role)) +(def query-by-role (with-node-or-screen :query-by-role)) + +(def get-all-by-test-id (with-node-or-screen :get-all-by-test-id)) +(def get-by-test-id (with-node-or-screen :get-by-test-id)) +(def query-all-by-test-id (with-node-or-screen :query-all-by-test-id)) +(def query-by-test-id (with-node-or-screen :query-by-test-id)) + +(defn- prepare-translation + [translation] + (if (exists? js/jest) + ;; Translations are treated differently when running with Jest. See + ;; test/jest/jestSetup.js for more details. + (str "tx:" (name translation)) + (i18n/label translation))) + +(defn get-all-by-translation-text + ([translation] + (get-all-by-translation-text rtl/screen translation)) + ([^js node translation & args] + (apply (with-node-or-screen :get-all-by-text) node (prepare-translation translation) args))) (defn get-by-translation-text - [keyword] - (get-by-text (str "tx:" (name keyword)))) + ([translation] + (get-by-translation-text rtl/screen translation)) + ([^js node translation & args] + (apply (with-node-or-screen :get-by-text) node (prepare-translation translation) args))) -(defn get-all-by-label-text - [label] - (rtl/screen.getAllByLabelText (name label))) +(defn query-by-translation-text + ([translation] + (query-by-translation-text rtl/screen translation)) + ([^js node translation & args] + (apply (with-node-or-screen :query-by-text) node (prepare-translation translation) args))) -(defn expect [match] (js/expect match)) +(defn query-all-by-translation-text + ([translation] + (query-all-by-translation-text rtl/screen translation)) + ([^js node translation & args] + (apply (with-node-or-screen :query-all-by-text) node (prepare-translation translation) args))) -(defn use-fake-timers [] (js/jest.useFakeTimers)) +;;; Jest utilities -(defn clear-all-timers [] (js/jest.clearAllTimers)) +(def ^:private jest? + (exists? js/jest)) -(defn use-real-timers [] (js/jest.useRealTimers)) +(defn expect + [match] + (js/expect match)) + +(defn use-fake-timers + [] + (when jest? + (js/jest.useFakeTimers))) + +(defn clear-all-timers + [] + (when jest? + (js/jest.clearAllTimers))) + +(defn use-real-timers + [] + (when jest? + (js/jest.useRealTimers))) (defn advance-timers-by-time [time-ms] - (js/jest.advanceTimersByTime time-ms)) + (when jest? + (js/jest.advanceTimersByTime time-ms))) -(def mock-fn js/jest.fn) +(def mock-fn + (when jest? + js/jest.fn)) (defn is-truthy [element]