Thread-local safety of with-redefs in parallel Clojure integration tests
0 reputation · 13 Jul 2023, 13:17 UTC
0 reputation · 13 Jul 2023, 13:17 UTC
with-redefs is a common strategy for swapping production-grade clients with mock implementations or local stubs. While this facilitates testing without requiring live credentials, the behavior of var rebinding in multi-threaded environments introduces uncertainty.When a test runner is configured to execute suites in parallel, the thread-local nature of rebinding via with-redefs can lead to inconsistent state. If the component under test spawns asynchronous tasks or utilizes a shared thread pool, the rebounded definition may not be visible to those sub-routines.
How does with-redefs behave when multiple threads attempt to rebind the same namespace simultaneously, and are there reliable patterns to ensure mocked dependencies are correctly propagated across asynchronous boundaries without resorting to global state changes?
32275 reputation · 13 Jul 2023, 14:49 UTC
with-redefs rebinds a Var using Clojure's binding mechanism, which stores the new value in a thread‑local slot. The rebinding is visible only on the thread that evaluates the with-redefs form and any thread that receives the current binding through explicit conveyance (e.g. bound-fn or with-bindings*). It does not automatically propagate to threads started by future, pmap, a Java ExecutorService, or core.async/go blocks.
with-redefs is implemented with binding, giving each thread its own Var value.bound-fn, with-bindings*, or wrapping work in the thread macro) copy the current dynamic bindings to the new thread.future, ExecutorService.submit, or core.async/go. Because those threads lack the conveyed binding, they call the original implementation, causing intermittent test failures.with-redefs provides a global mock leads to nondeterministic failures when parallelism or async is involved.future, pmap, ExecutorService, core.async/go).(with-redefs [client-fn (fn [] :mock)]
@(future ((bound-fn client-fn))))
(def ^:dynamic *http-client* real-client)
;; test
(with-redefs [*http-client* (atom :mock-client)]
(run-system))
;; production code
(deref *http-client*)
core.async/go blocks, move blocking parts to core.async/thread or wrap the go‑block body with bound-fn:(go (bound-fn (do-work)))
with-redefs altogether.Does the component under test start asynchronous tasks via future, ExecutorService, or core.async/go? Knowing the exact mechanism determines whether bound-fn conveyance or a mutable indirection is the simpler fix.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.