skip to content

Your team enabled parallel test execution inside a single JVM, and the tests using MockK's mockkObject started failing intermittently in ways nobody can reproduce locally. How would you reason about the cause, and what policy would you set for object mocking across the suite?

level: principalimportance: nice to knowfreq 19%

answer

  1. patch belongs to the class, not the test
  2. order- and thread-count-dependent = shared state
  3. unmockkAll in parallel teardown = landmine
  4. scoped mockkObject(Obj) { } narrows exposure
  5. growing patch count = missing seams, not a test problem

basics

~20 s

Object patching is process-global, so parallel tests share it: one stubs while another expects real behaviour, or one unmocks mid-flight. Policy: keep patched windows minimal and scoped, never unmockkAll in shared teardown under parallelism, isolate object-mocking tests from concurrent peers, and remove the need where a seam is affordable.

solid answer

~50 s

The diagnosis follows from the mechanism: `mockkObject` patches the singleton in place, so the change belongs to the loaded class in the JVM, not to a test. Run two tests concurrently that both reach that singleton and you get classic shared-mutable-state failures — A stubs while B expects real behaviour, B unmocks while A is mid-assertion, or a blanket `unmockkAll` in teardown removes a patch another running test still needs. The signature is failures that move with order and thread count and never reproduce alone. My policy has four parts. First, bound the window: block-scoped `mockkObject(Obj) { ... }`, never suite-level setup. Second, ban blanket `unmockkAll` in shared teardown once parallelism is on; undo exactly what you patched. Third, isolate: run tests that patch a shared singleton so they do not execute alongside others touching it. Fourth, shrink the population — where the singleton is ours and the path matters, give it an interface so a per-test `mockk` replaces a global patch.

code

kotlin · 10 lines
kotlin
// avoid under parallel execution
@BeforeEach fun setUp() { mockkObject(Clock) }
@AfterEach fun tearDown() { unmockkAll() }   // undoes other tests' patches too

// prefer: narrow window, exact undo
@Test
fun `expires the session`() = mockkObject(Clock) {
    every { Clock.now() } returns fixedInstant
    assertTrue(session.isExpired())
}

go deeper

for a junior

Recognise that object patching is global, so two tests running at once can interfere, and that narrow scoped patching is the safe habit.

for a middle

Name the concrete interleavings — stub seen by a stranger, premature restore, shared state mutation — and stop using blanket unmockkAll under parallelism.

for a senior

Diagnose from the failure fingerprint, apply isolation where refactoring is not affordable, and undo exactly what you patched.

for a principal

Set and sustain the policy, treat the count of object-patching tests as a design metric for ambient singletons, and be explicit that serial execution is a stabiliser rather than an end state.

## Reading the symptom Before theorising, characterise the failure. Object-mock leakage has a recognisable fingerprint: tests pass individually and fail in the suite; the set of failing tests changes when order or thread count changes; failures name assertions in tests that never mention the singleton; and re-running with parallelism disabled turns the suite green. If those hold, you are not looking at a race in production code — you are looking at shared state in the test process. ## Why parallelism is the trigger `mockkObject` does not substitute an instance; it patches the existing singleton so calls dispatch through MockK. There is no injection point on a Kotlin `object`, so there is nothing to scope the change to. The patch therefore belongs to the loaded class in the JVM and is visible to every thread and every concurrently executing test. Sequential suites hide this: as long as each test undoes its patch before the next begins, global state behaves like local state. Turn on same-JVM parallelism and the illusion ends. The concrete interleavings are worth naming, because each suggests a different remedy: 1. **Stub visible to a stranger.** Test A stubs `Clock.now()`; test B, running concurrently, asserts on real elapsed time and sees a frozen clock. 2. **Premature restore.** Test B finishes and calls `unmockkObject`, or worse `unmockkAll`, while test A is still exercising code that depends on the stub. A fails at an arbitrary point with real behaviour it never asked for. 3. **State bleed without stub bleed.** Object mocks are spies, so unstubbed members run real code. Two tests patching the same object also share whatever mutations that real code makes, and restoring behaviour does not roll those back. 4. **Ordering-sensitive verification.** Calls made by a concurrent test are recorded on the same patched singleton, so exhaustive or counted verifications see interactions that are not theirs. ## The policy **Bound the window.** Patch as late and unpatch as early as the assertion allows, using the block-scoped `mockkObject(Obj) { ... }` form so the window closes where it opened even on failure. Suite-level or class-level setup that patches for the duration of many tests is the worst case and should not survive review once parallelism is on. **Undo precisely.** `unmockkAll()` in a shared `@AfterEach` is a reasonable default in a sequential suite and a landmine in a parallel one, because it removes patches belonging to tests that are still running. Undo exactly the targets you patched. **Isolate what must stay global.** Some singletons genuinely cannot be refactored on the timescale you have — framework objects, third-party ones, deeply embedded legacy. For those, take the isolation lever the runner gives you: keep the tests that patch a given singleton from executing alongside anything else that touches it. Treat the singleton as a named resource that tests acquire exclusively, rather than hoping the schedule is kind. **Shrink the population.** Every object patch is a global mutation you are choosing to make. Where the singleton is ours and the code path is important enough to keep testing, giving it an interface and passing it in converts the problem into a per-test `mockk` with no shared state at all — and usually improves the production design, since the singleton stops being an ambient dependency. This is a gradual programme aimed at the singletons that keep appearing in flaky reports, not a rewrite. **Do not patch what you do not own.** Patching a framework or third-party singleton redefines behaviour for every path in the process that reaches it, including paths you have never read. The blast radius is proportional to how far the object sits from the code under test. ## Making the policy stick Policies about shared state decay unless they are visible. Useful reinforcements: a short convention note that object patching requires the scoped form and precise undo; a review habit of asking "who else reaches this singleton?"; and a periodic look at which singletons appear in flaky-test reports, which is a direct measurement of where seams are missing. If the count of object-patching tests is growing, that is a design signal — ambient singletons are spreading through the codebase — not a testing problem to be solved with more isolation configuration. ## The tradeoff to state out loud Disabling parallelism for the affected tests buys stability at the cost of wall-clock time, and it is the right immediate move when a pipeline is red. It is not the end state, because it preserves the coupling and the coupling keeps growing. The end state is that the number of tests needing a global patch is small, deliberate, and confined to singletons nobody can refactor.

  • What is the fastest way to confirm the failures are object-mock leakage rather than a real concurrency bug in production code?
    Re-run the suite with parallelism disabled and with a fixed test order. If it goes green, and the failing set changes when order or thread count changes, the variable is the schedule of tests, not the code under test. Confirm by narrowing to the singleton: run only the tests that reach it, in parallel, and see whether the failures follow.
  • Is running object-mocking tests serially an acceptable long-term answer?
    As an immediate stabiliser, yes — it trades wall-clock time for a green pipeline. As an end state, no, because it preserves the coupling and the population of such tests tends to grow with the number of ambient singletons. The durable fix is to shrink that population by giving the singletons that keep appearing in flaky reports an injectable seam.

saying these in an interview costs you the question

  • Blaming flakiness on the CI machine when failures track test order and thread count
  • Keeping unmockkAll in a shared teardown after enabling parallel execution
  • Patching singletons in class-level setup so the window spans many tests
  • Assuming a patch is confined to the thread or the test that created it
  • Treating serial execution as the permanent fix rather than a stabiliser

context