chore(tests)_: Facilitate writing event tests (#20424)

Introduces a new macro deftest-event to facilitate writing tests for event
handlers. Motivation came from the _problem of having to always extract event
handlers as vars in order to test them_.

Although the implementation of deftest-sub and deftest-event are similar,
deftest-sub is critically important because it guarantees changes in one
subscription can be caught by tests from all other related subscriptions in the
graph (reference: PR https://github.com/status-im/status-mobile/pull/14472).

This is not the case for the new deftest-event macro. deftest-event is
essentially a way of make testing events less ceremonial by not requiring event
handlers to be extracted to vars. But there are a few other small benefits:

- The macro uses re-frame and "finds" the event handler by computing the
  interceptor chain (except :do-fx), so in a way, the tests are covering a bit
  more ground.
- Slightly easier way to find event tests in the repo since you can just find
  references to deftest-event.
- Possibly slightly easier to maintain by devs because now event tests and sub
  tests are written in a similar fashion.
- Less code diff. Whether an event has a test or not, there's no var to
  add/remove.
- The dispatch function provided by the macro makes reading the tests easier
  over time. For example, when we read subscription tests, the Act section of
  the test is always the same (rf/sub [sub-name]). Similarly for events, the
  Act section is always (dispatch [event-id arg1 arg2]).
- Makes the re-frame code look more idiomatic because it's more common to define
  handlers as anonymous functions.

Downside: deftest-sub and deftest-event are relatively complicated macros.

Note: The test suite runs just as fast and clj-kondo can lint code within the
macro just as well.

Before:

```clojure
(deftest process-account-from-signal-test
  (testing "process account from signal"
    (let [cofx             {:db {:wallet {:accounts {}}}}
          effects          (events/process-account-from-signal cofx [raw-account])
          expected-effects {:db {:wallet {:accounts {address account}}}
                            :fx [[:dispatch [:wallet/get-wallet-token-for-account address]]
                                 [:dispatch
                                  [:wallet/request-new-collectibles-for-account-from-signal address]]
                                 [:dispatch [:wallet/check-recent-history-for-account address]]]}]
      (is (match? expected-effects effects)))))
```

After

```clojure
(h/deftest-event :wallet/process-account-from-signal
  [event-id dispatch]
  (let [expected-effects
        {:db {:wallet {:accounts {address account}}}
         :fx [[:dispatch [:wallet/get-wallet-token-for-account address]]
              [:dispatch [:wallet/request-new-collectibles-for-account-from-signal address]]
              [:dispatch [:wallet/check-recent-history-for-account address]]]}]
    (reset! rf-db/app-db {:wallet {:accounts {}}})
    (is (match? expected-effects (dispatch [event-id raw-account])))))
```
This commit is contained in:
Icaro Motta 2024-06-13 22:03:02 -03:00 committed by GitHub
parent 435bf3dbd5
commit 96d98c62ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 200 additions and 160 deletions

View File

@ -10,6 +10,8 @@
legacy.status-im.utils.styles/def clojure.core/def legacy.status-im.utils.styles/def clojure.core/def
legacy.status-im.utils.styles/defn clojure.core/defn legacy.status-im.utils.styles/defn clojure.core/defn
test-helpers.unit/deftest-sub clojure.core/defn test-helpers.unit/deftest-sub clojure.core/defn
test-helpers.unit/deftest-event clojure.core/defn
taoensso.tufte/defnp clojure.core/defn} taoensso.tufte/defnp clojure.core/defn}
:linters {:case-duplicate-test {:level :error} :linters {:case-duplicate-test {:level :error}
:case-quoted-test {:level :error} :case-quoted-test {:level :error}

View File

@ -41,6 +41,7 @@
"reg-fx" :arg1-pair "reg-fx" :arg1-pair
"testing" :arg1-body "testing" :arg1-body
"deftest-sub" :arg1-body "deftest-sub" :arg1-body
"deftest-event" :arg1-body
"test-async" :arg1-body "test-async" :arg1-body
"wait-for" :arg1-body "wait-for" :arg1-body
"with-deps-check" :arg1-body "with-deps-check" :arg1-body

View File

@ -105,14 +105,12 @@
:on-success [:wallet/get-accounts-success] :on-success [:wallet/get-accounts-success]
:on-error [:wallet/log-rpc-error {:event :wallet/get-accounts}]}]]]})) :on-error [:wallet/log-rpc-error {:event :wallet/get-accounts}]}]]]}))
(defn process-account-from-signal (rf/reg-event-fx :wallet/process-account-from-signal
[{:keys [db]} [{:keys [address] :as account}]] (fn [{:keys [db]} [{:keys [address] :as account}]]
{:db (assoc-in db [:wallet :accounts address] (data-store/rpc->account account)) {:db (assoc-in db [:wallet :accounts address] (data-store/rpc->account account))
:fx [[:dispatch [:wallet/get-wallet-token-for-account address]] :fx [[:dispatch [:wallet/get-wallet-token-for-account address]]
[:dispatch [:wallet/request-new-collectibles-for-account-from-signal address]] [:dispatch [:wallet/request-new-collectibles-for-account-from-signal address]]
[:dispatch [:wallet/check-recent-history-for-account address]]]}) [:dispatch [:wallet/check-recent-history-for-account address]]]}))
(rf/reg-event-fx :wallet/process-account-from-signal process-account-from-signal)
(rf/reg-event-fx (rf/reg-event-fx
:wallet/save-account :wallet/save-account
@ -156,26 +154,22 @@
:on-success [:wallet/remove-account-success toast-message] :on-success [:wallet/remove-account-success toast-message]
:on-error [:wallet/log-rpc-error {:event :wallet/remove-account}]}]]]})) :on-error [:wallet/log-rpc-error {:event :wallet/remove-account}]}]]]}))
(defn get-wallet-token-for-all-accounts (rf/reg-event-fx :wallet/get-wallet-token-for-all-accounts
[{:keys [db]}] (fn [{:keys [db]}]
{:fx (->> (get-in db [:wallet :accounts]) {:fx (->> (get-in db [:wallet :accounts])
vals vals
(mapv (mapv
(fn [{:keys [address]}] (fn [{:keys [address]}]
[:dispatch [:wallet/get-wallet-token-for-account address]])))}) [:dispatch [:wallet/get-wallet-token-for-account address]])))}))
(rf/reg-event-fx :wallet/get-wallet-token-for-all-accounts get-wallet-token-for-all-accounts) (rf/reg-event-fx :wallet/get-wallet-token-for-account
(fn [{:keys [db]} [address]]
(defn get-wallet-token-for-account
[{:keys [db]} [address]]
{:db (assoc-in db [:wallet :ui :tokens-loading address] true) {:db (assoc-in db [:wallet :ui :tokens-loading address] true)
:fx [[:json-rpc/call :fx [[:json-rpc/call
[{:method "wallet_getWalletToken" [{:method "wallet_getWalletToken"
:params [[address]] :params [[address]]
:on-success [:wallet/store-wallet-token address] :on-success [:wallet/store-wallet-token address]
:on-error [:wallet/get-wallet-token-for-account-failed address]}]]]}) :on-error [:wallet/get-wallet-token-for-account-failed address]}]]]}))
(rf/reg-event-fx :wallet/get-wallet-token-for-account get-wallet-token-for-account)
(rf/reg-event-fx (rf/reg-event-fx
:wallet/get-wallet-token-for-account-failed :wallet/get-wallet-token-for-account-failed
@ -401,14 +395,12 @@
[{:method "wallet_startWallet" [{:method "wallet_startWallet"
:on-error [:wallet/log-rpc-error {:event :wallet/start-wallet}]}]]]})) :on-error [:wallet/log-rpc-error {:event :wallet/start-wallet}]}]]]}))
(defn check-recent-history-for-all-accounts (rf/reg-event-fx :wallet/check-recent-history-for-all-accounts
[{:keys [db]}] (fn [{:keys [db]}]
{:fx (->> (get-in db [:wallet :accounts]) {:fx (->> (get-in db [:wallet :accounts])
vals vals
(mapv (fn [{:keys [address]}] (mapv (fn [{:keys [address]}]
[:dispatch [:wallet/check-recent-history-for-account address]])))}) [:dispatch [:wallet/check-recent-history-for-account address]])))}))
(rf/reg-event-fx :wallet/check-recent-history-for-all-accounts check-recent-history-for-all-accounts)
(rf/reg-event-fx (rf/reg-event-fx
:wallet/check-recent-history-for-account :wallet/check-recent-history-for-account
@ -479,14 +471,12 @@
:text (i18n/label :t/provider-is-down {:chains chain-names}) :text (i18n/label :t/provider-is-down {:chains chain-names})
:duration constants/toast-chain-down-duration}]])]}))) :duration constants/toast-chain-down-duration}]])]})))
(defn reset-selected-networks (rf/reg-event-fx :wallet/reset-selected-networks
[{:keys [db]}] (fn [{:keys [db]}]
{:db (assoc-in db [:wallet :ui :network-filter] db/network-filter-defaults)}) {:db (assoc-in db [:wallet :ui :network-filter] db/network-filter-defaults)}))
(rf/reg-event-fx :wallet/reset-selected-networks reset-selected-networks) (rf/reg-event-fx :wallet/update-selected-networks
(fn [{:keys [db]} [network-name]]
(defn update-selected-networks
[{:keys [db]} [network-name]]
(let [selected-networks (get-in db [:wallet :ui :network-filter :selected-networks]) (let [selected-networks (get-in db [:wallet :ui :network-filter :selected-networks])
selector-state (get-in db [:wallet :ui :network-filter :selector-state]) selector-state (get-in db [:wallet :ui :network-filter :selector-state])
contains-network? (contains? selected-networks network-name) contains-network? (contains? selected-networks network-name)
@ -505,9 +495,8 @@
{:fx [[:dispatch [:wallet/reset-selected-networks]]]} {:fx [[:dispatch [:wallet/reset-selected-networks]]]}
:else :else
{:db (update-in db [:wallet :ui :network-filter :selected-networks] update-fn network-name)}))) {:db
(update-in db [:wallet :ui :network-filter :selected-networks] update-fn network-name)}))))
(rf/reg-event-fx :wallet/update-selected-networks update-selected-networks)
(rf/reg-event-fx (rf/reg-event-fx
:wallet/get-crypto-on-ramps-success :wallet/get-crypto-on-ramps-success

View File

@ -1,9 +1,11 @@
(ns status-im.contexts.wallet.events-test (ns status-im.contexts.wallet.events-test
(:require (:require
[cljs.test :refer-macros [deftest is testing]] [cljs.test :refer-macros [is testing]]
matcher-combinators.test matcher-combinators.test
[re-frame.db :as rf-db]
[status-im.constants :as constants] [status-im.constants :as constants]
[status-im.contexts.wallet.events :as events])) status-im.contexts.wallet.events
[test-helpers.unit :as h]))
(def address "0x2ee6138eb9344a8b76eca3cf7554a06c82a1e2d8") (def address "0x2ee6138eb9344a8b76eca3cf7554a06c82a1e2d8")
@ -53,114 +55,98 @@
"0x04ee7c47e4b68cc05dcd3377cbd5cde6be3c89fcf20a981e55e0285ed63a50f51f8b423465eee134c51bb0255e6041e9e5b006054b0fa72a7c76942a5a1a3f4e7e" "0x04ee7c47e4b68cc05dcd3377cbd5cde6be3c89fcf20a981e55e0285ed63a50f51f8b423465eee134c51bb0255e6041e9e5b006054b0fa72a7c76942a5a1a3f4e7e"
:removed false}) :removed false})
(deftest scan-address-success-test (h/deftest-event :wallet/scan-address-success
(let [db {}] [event-id dispatch]
(testing "scan-address-success" (is (match? {:wallet {:ui {:scanned-address address}}}
(let [expected-db {:wallet {:ui {:scanned-address address}}} (:db (dispatch [event-id address])))))
effects (events/scan-address-success {:db db} address)
result-db (:db effects)]
(is (match? result-db expected-db))))))
(deftest clean-scanned-address-test (h/deftest-event :wallet/clean-scanned-address
(let [db {:wallet {:ui {:scanned-address address}}}] [event-id dispatch]
(testing "clean-scanned-address" (reset! rf-db/app-db {:wallet {:ui {:scanned-address address}}})
(let [expected-db {:wallet {:ui {:send nil (is (match? {:wallet {:ui {}}}
:scanned-address nil}}} (:db (dispatch [event-id])))))
effects (events/clean-scanned-address {:db db})
result-db (:db effects)]
(is (match? result-db expected-db))))))
(deftest reset-selected-networks-test (h/deftest-event :wallet/reset-selected-networks
(testing "reset-selected-networks" [event-id dispatch]
(let [db {:wallet {}} (reset! rf-db/app-db {:wallet {}})
expected-db {:wallet {:ui {:network-filter {:selector-state :default (is (match? {:wallet
{:ui {:network-filter {:selector-state :default
:selected-networks :selected-networks
(set constants/default-network-names)}}}} (set constants/default-network-names)}}}}
effects (events/reset-selected-networks {:db db}) (:db (dispatch [event-id])))))
result-db (:db effects)]
(is (match? result-db expected-db)))))
(deftest update-selected-networks-test (h/deftest-event :wallet/update-selected-networks
[event-id dispatch]
(testing "update-selected-networks" (testing "update-selected-networks"
(let [db {:wallet {:ui {:network-filter {:selected-networks (let [network-name constants/arbitrum-network-name
#{constants/optimism-network-name}
:selector-state :changed}}}}
network-name constants/arbitrum-network-name
expected-db {:wallet {:ui {:network-filter {:selected-networks expected-db {:wallet {:ui {:network-filter {:selected-networks
#{constants/optimism-network-name #{constants/optimism-network-name
network-name} network-name}
:selector-state :changed}}}} :selector-state :changed}}}}]
props [network-name] (reset! rf-db/app-db
effects (events/update-selected-networks {:db db} props) {:wallet {:ui {:network-filter {:selected-networks
result-db (:db effects)] #{constants/optimism-network-name}
(is (match? result-db expected-db)))) :selector-state :changed}}}})
(is (match? expected-db (:db (dispatch [event-id network-name]))))))
(testing "update-selected-networks > if all networks is already selected, update to incoming network" (testing "update-selected-networks > if all networks is already selected, update to incoming network"
(let [db {:wallet {:ui {:network-filter {:selector-state :default (let [network-name constants/arbitrum-network-name
:selected-networks
(set constants/default-network-names)}}}}
network-name constants/arbitrum-network-name
expected-db {:wallet {:ui {:network-filter {:selected-networks #{network-name} expected-db {:wallet {:ui {:network-filter {:selected-networks #{network-name}
:selector-state :changed}}}} :selector-state :changed}}}}]
props [network-name] (reset! rf-db/app-db
effects (events/update-selected-networks {:db db} props) {:wallet {:ui {:network-filter {:selector-state :default
result-db (:db effects)] :selected-networks
(is (match? result-db expected-db)))) (set constants/default-network-names)}}}})
(is (match? expected-db (:db (dispatch [event-id network-name]))))))
(testing "update-selected-networks > reset on removing last network" (testing "update-selected-networks > reset on removing last network"
(let [db {:wallet {:ui {:network-filter {:selected-networks (let [expected-fx [[:dispatch [:wallet/reset-selected-networks]]]]
(reset! rf-db/app-db
{:wallet {:ui {:network-filter {:selected-networks
#{constants/optimism-network-name} #{constants/optimism-network-name}
:selector-state :changed}}}} :selector-state :changed}}}})
expected-fx [[:dispatch [:wallet/reset-selected-networks]]] (is (match? expected-fx
props [constants/optimism-network-name] (:fx (dispatch [event-id constants/optimism-network-name])))))))
effects (events/update-selected-networks {:db db} props)
result-fx (:fx effects)]
(is (match? result-fx expected-fx)))))
(deftest get-wallet-token-for-all-accounts-test (h/deftest-event :wallet/get-wallet-token-for-all-accounts
(testing "get wallet token for all accounts" [event-id dispatch]
(let [address-1 "0x1" (let [address-1 "0x1"
address-2 "0x2" address-2 "0x2"]
cofx {:db {:wallet {:accounts {address-1 {:address address-1} (reset! rf-db/app-db {:wallet {:accounts {address-1 {:address address-1}
address-2 {:address address-2}}}}} address-2 {:address address-2}}}})
effects (events/get-wallet-token-for-all-accounts cofx) (is (match? [[:dispatch [:wallet/get-wallet-token-for-account address-1]]
result-fx (:fx effects) [:dispatch [:wallet/get-wallet-token-for-account address-2]]]
expected-fx [[:dispatch [:wallet/get-wallet-token-for-account address-1]] (:fx (dispatch [event-id]))))))
[:dispatch [:wallet/get-wallet-token-for-account address-2]]]]
(is (match? expected-fx result-fx)))))
(deftest get-wallet-token-for-account-test (h/deftest-event :wallet/get-wallet-token-for-account
(testing "get wallet token for account" [event-id dispatch]
(let [cofx {:db {}} (let [expected-effects {:db {:wallet {:ui {:tokens-loading {address true}}}}
effects (events/get-wallet-token-for-account cofx [address])
expected-effects {:db {:wallet {:ui {:tokens-loading {address true}}}}
:fx [[:json-rpc/call :fx [[:json-rpc/call
[{:method "wallet_getWalletToken" [{:method "wallet_getWalletToken"
:params [[address]] :params [[address]]
:on-success [:wallet/store-wallet-token address] :on-success [:wallet/store-wallet-token address]
:on-error [:wallet/get-wallet-token-for-account-failed :on-error [:wallet/get-wallet-token-for-account-failed
address]}]]]}] address]}]]]}]
(is (match? expected-effects effects))))) (is (match? expected-effects (dispatch [event-id address])))))
(deftest check-recent-history-for-all-accounts-test (h/deftest-event :wallet/check-recent-history-for-all-accounts
[event-id dispatch]
(testing "check recent history for all accounts" (testing "check recent history for all accounts"
(let [address-1 "0x1" (let [address-1 "0x1"
address-2 "0x2" address-2 "0x2"
cofx {:db {:wallet {:accounts {address-1 {:address address-1}
address-2 {:address address-2}}}}}
effects (events/check-recent-history-for-all-accounts cofx)
result-fx (:fx effects)
expected-fx [[:dispatch [:wallet/check-recent-history-for-account address-1]] expected-fx [[:dispatch [:wallet/check-recent-history-for-account address-1]]
[:dispatch [:wallet/check-recent-history-for-account address-2]]]] [:dispatch [:wallet/check-recent-history-for-account address-2]]]]
(is (match? expected-fx result-fx))))) (reset! rf-db/app-db
{:wallet {:accounts {address-1 {:address address-1}
address-2 {:address address-2}}}})
(is (match? expected-fx (:fx (dispatch [event-id])))))))
(deftest process-account-from-signal-test (h/deftest-event :wallet/process-account-from-signal
(testing "process account from signal" [event-id dispatch]
(let [cofx {:db {:wallet {:accounts {}}}} (let [expected-effects {:db {:wallet {:accounts {address account}}}
effects (events/process-account-from-signal cofx [raw-account])
expected-effects {:db {:wallet {:accounts {address account}}}
:fx [[:dispatch [:wallet/get-wallet-token-for-account address]] :fx [[:dispatch [:wallet/get-wallet-token-for-account address]]
[:dispatch [:dispatch
[:wallet/request-new-collectibles-for-account-from-signal address]] [:wallet/request-new-collectibles-for-account-from-signal address]]
[:dispatch [:wallet/check-recent-history-for-account address]]]}] [:dispatch [:wallet/check-recent-history-for-account address]]]}]
(is (match? expected-effects effects))))) (reset! rf-db/app-db {:wallet {:accounts {}}})
(is (match? expected-effects (dispatch [event-id raw-account])))))

View File

@ -4,7 +4,7 @@
[clojure.string :as string] [clojure.string :as string]
[clojure.walk :as walk])) [clojure.walk :as walk]))
(defn- subscription-name->test-name (defn- keyword->test-name
[sub-name] [sub-name]
(->> [(namespace sub-name) (->> [(namespace sub-name)
(name sub-name) (name sub-name)
@ -13,7 +13,7 @@
(map #(string/replace % #"\." "-")) (map #(string/replace % #"\." "-"))
(string/join "-"))) (string/join "-")))
(defmacro ^:private testing-subscription (defmacro ^:private testing-restorable-app-db
[description & body] [description & body]
`(cljs.test/testing ~description `(cljs.test/testing ~description
(restore-app-db (fn [] ~@body)))) (restore-app-db (fn [] ~@body))))
@ -46,17 +46,79 @@
;; Act and Assert ;; Act and Assert
(is (= <expected> (rf/sub [sub-name]))))) (is (= <expected> (rf/sub [sub-name])))))
```" ```"
[sub-name args & body] [sub-name bindings & body]
`(let [sub-name# ~sub-name] `(let [sub-name# ~sub-name]
(cljs.test/deftest ~(symbol (subscription-name->test-name sub-name)) (cljs.test/deftest ~(symbol (keyword->test-name sub-name))
(let [~args [sub-name#]] (let [~bindings [sub-name#]]
(restore-app-db (restore-app-db
(fn [] (fn []
;; Do not warn about subscriptions being used in non-reactive contexts. ;; Do not warn about subscriptions being used in non-reactive contexts.
(with-redefs [re-frame.interop/debug-enabled? false] (with-redefs [re-frame.interop/debug-enabled? false]
~@(clojure.walk/postwalk-replace ~@(clojure.walk/postwalk-replace
{'cljs.test/testing `testing-subscription {'cljs.test/testing `testing-restorable-app-db
'testing `testing-subscription} 'testing `testing-restorable-app-db}
body))))))))
(defmacro ^:private event-dispatcher
"Returns an s-exp that can build an event dispatcher given an event vector."
[]
`(fn [event-v#]
(let [event-id# (first event-v#)
all-interceptors# (re-frame.registrar/get-handler :event event-id#)
interceptors-without-fx# (remove #(= :do-fx (:id %)) all-interceptors#)]
(assert (seq all-interceptors#)
(str "Event does not exist '" event-id# "'"))
(:effects (re-frame.interceptor/execute event-v# interceptors-without-fx#)))))
(defmacro deftest-event
"Defines a test var for an event `event-id`.
This macro primarily exists to facilitate testing anonymous event handlers
directly, without the need to extract them to vars.
Similar to `deftest-sub`, this macro uses the re-frame machinery.
Consequently, the test body is no longer guaranteed to be pure, as all
interceptors will run (except for the standard `:do-fx`).
Within the test, we can directly mutate `re-frame.db/app-db` atom to set up
test data. Upon test completion, it is guaranteed that the app-db will be
restored to its original state.
Any usage of the `cljs.test/testing` macro within `body` will be modified to
ensure the app-db is restored.
The macro offers two bindings, namely `event-id` and `dispatch`. The
`dispatch` function is similar to `re-frame.core/dispatch`, but without
executing effects. It returns the map of effects we can assert on and it's
synchronous.
Example:
```clojure
(ns events-test
(:require [test-helpers.unit :as h]))
(h/deftest-event :wallet/dummy-event
[event-id dispatch]
(let [expected {:db {:a false}}]
;; Arrange
(swap! rf-db/app-db {:a true})
;; Act and Assert
(is (match? expected (dispatch [event-id arg1 arg2])))))
```
"
[event-id bindings & body]
`(let [event-id# ~event-id]
(cljs.test/deftest ~(symbol (keyword->test-name event-id))
(let [dispatcher# (event-dispatcher)
~bindings [event-id# dispatcher#]]
(restore-app-db
(fn []
(with-redefs [re-frame.interop/debug-enabled? false]
~@(clojure.walk/postwalk-replace
{'cljs.test/testing `testing-restorable-app-db
'testing `testing-restorable-app-db}
body)))))))) body))))))))
(defmacro use-log-fixture (defmacro use-log-fixture