Last active
June 9, 2023 07:10
-
-
Save ignorabilis/2c9ef36511c48246a90ceb6fdebe94c6 to your computer and use it in GitHub Desktop.
Haskell inspired way of boxing/unboxing, useful for testing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns playground.response) | |
(defn ok [value] | |
{:http-status 200 | |
:value value}) | |
(ns playground.box-unbox) | |
(defn --> | |
"Used to box a side effectful function with some of its values that can be | |
non trivial to obtain without starting a system, providing environment | |
variables through configuration, etc., like database connections, api tokens, | |
'clients', etc." | |
[fn-or-val & args] | |
(if-not (fn? fn-or-val) | |
fn-or-val | |
(with-meta | |
(apply partial fn-or-val args) | |
{:ignorabilis.box.unbox/boxed true}))) | |
(defn <-- | |
"Unboxes and executes the boxed function, passing to it any additional | |
arguments. Returns the result of that function. If given a value different | |
from a boxed side effectful function it just returns the value." | |
[fn-or-val & args] | |
(let [boxed-fn? (and (fn? fn-or-val) | |
(:ignorabilis.box.unbox/boxed | |
(meta fn-or-val)))] | |
(if boxed-fn? | |
(apply fn-or-val args) | |
fn-or-val))) | |
(ns playground.simple | |
(:require [playground.response :as response] | |
[playground.box-unbox :as bu])) | |
(def user | |
{:user-id 123 | |
:username "default" | |
:additional? true}) | |
(def additional | |
{:image "cool.png" | |
:nickname "The ONE"}) | |
(defn get-user-from-db [db username] | |
(prn (str "get in the database: " db " " username)) | |
user) | |
(defn get-user-data-from-third-party-api [token user-id] | |
(prn (str "http request using token: " token " " user-id)) | |
additional) | |
;; The function below looks like most Clojure API handlers; | |
;; along with many other functions that deal with a lot of side effects, | |
;; pulling and pushing data to databases and APIs | |
(defn client-api-response-original [request db token] | |
(let [username (:username request) | |
{:keys [additional? user-id] | |
:as user} (get-user-from-db db username) | |
additional (when additional? | |
(get-user-data-from-third-party-api token user-id)) | |
final-user (merge user additional)] | |
(response/ok | |
{:user final-user}))) | |
;; What if we have a simple tool that wraps those side effects and either | |
;; returns the result of their execution or if provided a value just returns | |
;; that value? In that case side effects could be ignored during testing, | |
;; thus allowing the >user and >additional arguments below to be simple values - | |
;; no mocking or with-redefs will be required when testing, so testable-response | |
;; could be tested by just doing (testable-response val1 val2 request) | |
;; or even straight in the REPL, without the need of db connections, tokens, etc. | |
(defn testable-response [>user >additional request] | |
(let [username (:username request) | |
{:keys [additional? user-id] | |
:as user} (bu/<-- >user username) | |
additional (when additional? | |
(bu/<-- >additional user-id)) | |
final-user (merge user additional)] | |
(response/ok | |
{:user final-user}))) | |
;; bu comes from box-unbox; bu/--> just wraps a function; then bu/<-- unwraps | |
;; it by executing it with any additional arguments; if a simple value is | |
;; provided to bu/<-- (or any unboxed function) it just returns it as it is | |
;; This function here contains minimal amount of logic - only function boxing - | |
;; which leaves very little room for errors | |
(defn client-api-response-boxing [request db token] | |
(let [>user (bu/--> get-user-from-db db) | |
>additional (bu/--> get-user-data-from-third-party-api token)] | |
(testable-response >user >additional request))) | |
;; One advantage is that if you change say get-user-from-db tests will | |
;; continue to pass; this means that inside of get-user-from-db you don't | |
;; want any type of logic, apart from connecting to the db and getting the | |
;; user; however if you have logic inside of the function passing tests might | |
;; surprise you; also if the actual function suddenly changes the shape of the | |
;; value returned that might be surprising - but checking if values have a | |
;; certain shape should be infinitely easier compared to mocking databases. | |
;; If we really wanted to test get-user-from-db we could do so in isolation | |
;; and make side-effectful tests that talk to a dev db (or a mock api, etc.) | |
;; these tests would be separate ones, could be triggered separately and won't | |
;; be mock/with-redefs on top of logic kind of a mess - it should lead to | |
;; simple, separate tests. | |
;; Finally integration tests still can be made, but these are testing the logic | |
;; as a black box, which is not always optimal/easy, are always expensive | |
;; because include real databases, APIs, or mock ones with whole services; | |
;; hard to write, slow to execute, etc. | |
;; Another note - when changing a function and introducing new side effects | |
;; often times unrelated tests (testing the API or the message queue or | |
;; whatever and not the function directly) could fail due to missing redefs | |
;; for example; which is silly, because now we need to make sure that all | |
;; tests have been updated with the proper redefs and that has nothing to | |
;; do with our original logic, we're now wasting time updating stuff that | |
;; should not have existed in the first place | |
;; An "intersting" issue with side effects are db invocations using | |
;; `with-db-transaction`; I'd say avoid using databases that are not | |
;; Datomic-like. For example a regular Postgres db executes SQL as part of | |
;; updates/inserts etc. This means the logic for those actions is not testable | |
;; by your application at all - you'll need a mock db and most probably some | |
;; meaningful data, which is quite the endeavor. On the contrary, Datomic-like | |
;; dbs force you to use transactions - so you know all the data that's going | |
;; into the db just before that `transact!` moment. That means all the logic | |
;; lives inside the application - and is thus testable, the (simple) | |
;; functional way | |
;; If avoiding `with-db-transaction` is not poissible because such a db is | |
;; already in place just treat the whole transaction as a side effect. Extract | |
;; any logic from it (as much as possible) in small pure functions and just | |
;; pray that the messy mutable stuff that invokes even messier sql underneath | |
;; continues to work - or just write non-functional tests for those functions | |
;; using a mock db and waste your life, ahem, time, doing it |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment