Skip to content

Instantly share code, notes, and snippets.

@ignorabilis
Last active June 9, 2023 07:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ignorabilis/2c9ef36511c48246a90ceb6fdebe94c6 to your computer and use it in GitHub Desktop.
Save ignorabilis/2c9ef36511c48246a90ceb6fdebe94c6 to your computer and use it in GitHub Desktop.
Haskell inspired way of boxing/unboxing, useful for testing
(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