with-redefs JVM-global Redefinition Boundary in Parallel Clojure Integration Tests
0 reputation · 16 Aug 2023, 02:59 UTC
0 reputation · 16 Aug 2023, 02:59 UTC
The goal is to run Clojure integration tests that replace production API clients with mocks while executing test namespaces in parallel. Assuming standard Clojure var semantics on the JVM, with-redefs rebinds a var's root value globally across threads, so one test thread's mock can be visible to another thread's assertion. Thread-local binding limits visibility to the current thread but may not protect code that captures the var or dispatches work to futures and agent pools.
This leaves an unresolved compatibility boundary between global var mocking and concurrent test runners such as Kaocha. The team must decide whether to serialize mocked tests, adopt thread-local rebinding, or pass clients as explicit parameters, each with different implications for existing test structure.
with-redefs race conditions?binding provide sufficient isolation when tests spawn futures or background threads that read the same var?1. Is serialising all credential‑mocked integration tests acceptable?
Serialisation is a quick fix that guarantees isolation because with-redefs changes the var root globally. Running the affected namespaces one after another removes the race condition, but you lose the parallel‑test speed that Kaocha provides. If the test suite is small or the mocked calls are slow, the trade‑off might be tolerable. For larger projects, the slowdown can become a maintenance burden.
2. Does binding give enough isolation when futures or agents are used?
Thread‑local binding works only for the current thread. If your test spawns a future, an agent, or uses core.async, the spawned thread inherits the original var root, not the bound value. Therefore, binding alone does not guarantee isolation for code that captures the var after the test has started a background task.
3. What is the lowest‑effort migration path to inject HTTP clients as function arguments?
Instead of rebinding global vars, refactor your API‑client calls to accept a client function or a protocol instance. Wrap the original client in a var that can be rebound only in the test setup, but capture its value before launching background work. A typical pattern is:
(def ^:dynamic *http-client* (fn [& args] (http-client/req args)))
(defn call-service [& args]
(let [client *http-client*] ; capture current value
(client args)))
In the test you then use with-redefs to replace *http-client* with a mock that returns a predetermined response. Because the mock is captured before any futures are started, background tasks see the mocked client even though the var root is global.
This approach requires only a small change to the test fixtures and the functions that use the client. You can keep the existing test bodies unchanged; only the call sites need to be updated to use the dynamic var or to accept the client as a parameter. If you prefer explicit dependency injection, pass the client as an argument to the functions under test and use a test fixture that supplies a mock.
clojure -M:test with Kaocha in parallel mode and watch for interleaved log output that indicates a shared mock is being used by multiple threads.(add-watch #'my-var :debug (fn [_ _ _ new] (println "Rebound to" new))) to see when different threads observe the change.future inside with-redefs and asserts that the future sees the mocked value. If the future prints the original value, the binding is insufficient.To refine the recommendation, it would help to know whether any of your integration tests spawn work on core.async channels, futures, or agents. If so, the capture‑before‑spawn pattern becomes essential.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.