(ns darkleaf.di.how-to.reusing-components-between-tests-test
(:require
[clojure.test :as t]
[darkleaf.di.core :as di]
[darkleaf.di.utils :refer [catch-some]]))
An integration test starts a system, exercises it, and stops it.
Each di/start builds every component the test needs. When some
of them are expensive — a database connection, an HTTP server, a
parser with a heavy dictionary — every test pays that cost again.
di/->memoize
returns a stateful middleware
(The middleware argument)
that caches built components across di/start calls. Each test
starts only the keys it needs. The first test builds the
expensive components, the rest take them from the cache.
This recipe models a small web stack. The handler depends on a
database connection and a parser. The Object. instances stand
in for real connection state — the tests compare them by
identity to tell a cached component from a rebuilt one.
(defn db
{::di/kind :component}
[{url "DB_URL"}]
{:url url, :conn (Object.)})
(defn parser
{::di/kind :component}
[]
(Object.))
(defn handler
{::di/kind :component}
[{db `db, parser `parser}]
{:db db, :parser parser})
Two tests start the handler against the same memoized registry.
Here they are folded into one deftest so the recipe stays
self-contained.
(t/deftest reuse-test
(with-open [mem (di/->memoize {"DB_URL" "jdbc:test"})
first (di/start `handler mem)
second (di/start `handler mem)]
;; the second start took every component from the cache
(t/is (identical? @first @second))))
The memoized registry must be the first middleware in
di/start. Per-test overrides go after it:
(t/deftest mem-goes-first-test
(with-open [mem (di/->memoize)]
(let [ex (catch-some (di/start `handler {"DB_URL" "jdbc:test"} mem))]
(t/is (= ::di/wrong-memoized-registry-position
(-> ex ex-data :type))))))
An override rebuilds only the components that depend on it. The rest still comes from the cache. And overrides are compared by value: pass an equal override again and you get the cached components back.
(t/deftest override-test
(with-open [mem (di/->memoize {"DB_URL" "jdbc:test"})
first (di/start `handler mem)
second (di/start `handler mem {"DB_URL" "jdbc:other"})
third (di/start `handler mem {"DB_URL" "jdbc:other"})]
;; the new url produced a new db...
(t/is (not (identical? (:db @first) (:db @second))))
;; ...while the parser does not depend on it and was reused
(t/is (identical? (:parser @first) (:parser @second)))
;; an equal override hits the cache
(t/is (identical? @second @third))))
The memoized registry owns everything it builds. Stopping a
test's system does not stop cached components. They live until
you stop the memoized registry itself with di/stop.
To observe the lifecycle, this test folds di/log
(Logging system lifecycle) into the
cache — di/->memoize takes the same middleware values
di/start does.
(t/deftest ownership-test
(let [log (atom [])
logging (di/log :after-build! #(swap! log conj [:built (:key %)])
:after-demolish! #(swap! log conj [:stopped (:key %)]))
mem (di/->memoize {"DB_URL" "jdbc:test"} logging)]
(-> (di/start `db mem)
(di/stop))
;; the test's system was stopped, but the cached db was not
(t/is (= [[:built "DB_URL"]
[:built `db]]
@log))
(swap! log empty)
(di/stop mem)
;; stopping mem released everything it cached, in reverse order
(t/is (= [[:stopped `db]
[:stopped "DB_URL"]]
@log))))
A stub passed through an override deserves attention. Ownership
follows from who answers the key. A value in the override map is
answered by the map itself, outside the cache. But di/ref only
redirects the key to another one, and the referenced key is
answered by mem. Such a stub is cached and owned by mem like any
other component: tests that override db the same way share one
stub instance, and its stop hook runs at (di/stop mem), not
when the test's system stops.
(defn stub-db
{::di/kind :component
::di/stop (fn [db] (swap! (:log db) conj :stub-db-stopped))}
[{log ::log}]
{:log log, :conn (Object.)})
(t/deftest stub-ownership-test
(let [log (atom [])
mem (di/->memoize {::log log})]
(-> (di/start `handler mem {`db (di/ref `stub-db)})
(di/stop))
;; the test's system stopped, but the stub's stop hook did not run
(t/is (= [] @log))
(di/stop mem)
;; mem answered the `stub-db key, so mem owns the stub
(t/is (= [:stub-db-stopped] @log))))
Keeping with-open around each test's system is still a good
habit: a test may build something outside the cache, and
stopping a fully cached system is cheap.
The cache watches the vars it built components from. Redefine a
component — reload its namespace, re-evaluate its defn — and
the next start rebuilds it and everything that depends on it.
The rest of the cache stays. This is what makes the REPL loop
fast: edit a component, rerun its test, and only the affected
subgraph is rebuilt.
(t/deftest redefinition-test
(with-open [mem (di/->memoize)]
(defn router
{::di/kind :component}
[]
:v1)
(with-open [system (di/start `router mem)]
(t/is (= :v1 @system)))
;; edit and re-evaluate the component
(defn router
{::di/kind :component}
[]
:v2)
;; the next start picks it up, no restart of mem needed
(with-open [system (di/start `router mem)]
(t/is (= :v2 @system)))))
The tests above create a fresh cache each to stay independent.
In a project, one memoized registry serves the whole suite,
held in a var. Fold everything the tests share into it: the
application registries, stubs for the clock and randomness,
test credentials. Per-test variation stays outside, as
overrides on di/start.
The system namespace owns the cache alongside the application registry. For a CI run, the test-runner entry point wraps the real runner: the cache is created before the first test and released after the last one, even when the run throws. For the REPL there is a pair of start and stop functions that bind the same var.
(ns app.system
(:require
[clojure.edn :as edn]
[darkleaf.di.core :as di]))
;; the application registry, shared with prod and dev
(defn registry []
[...])
(defn test-registries []
[(registry)
{:app/clock (di/ref `fixed-clock)}
(-> "env_test.edn" slurp edn/read-string)])
(declare mem)
;; the entry point for `clojure -X:test`
(defn wrap-test-runner [{:keys [runner] :as args}]
(let [runner (requiring-resolve runner)]
(with-open [m (di/->memoize (test-registries))]
(def mem m)
(runner args))))
;; the REPL counterpart
(defn start-test [] (def mem (di/->memoize (test-registries))))
(defn stop-test [] (di/stop mem))
The :test alias in deps.edn points at the wrapper and
passes the real runner as an argument:
:test {:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}
:exec-fn app.system/wrap-test-runner
:exec-args {:runner cognitect.test-runner.api/test}}
Tests reach the cache through the var:
(ns app.login-test
(:require
[app.system :refer [mem]]
...))
(t/deftest login-test
(with-open [system (di/start `app.web/handler mem
{"SESSION_TTL" "60"})]
...))
The same registry then serves interactive work: start a
subsystem, call it by hand, stop it — the expensive components
remain running for the next start. The
di/->memoize
docstring shows the matching workflow for a dev system.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |