When using spyk in MockK, why can stubbing a method fail to take effect for internal self-calls, and what does recordPrivateCalls do?
answer
- Self-call uses real this -> stub bypassed
- Extract + inject instead of stubbing internals
- recordPrivateCalls = true to verify privates
- invoke("name").withArguments(...) is string-based, brittle
- Spying internals = testing implementation detail
basics
~20 sA spy wraps a real object. When one real method calls another method on itself, that inner call often runs the real one and skips your stub. recordPrivateCalls = true lets MockK see and verify private method calls.
solid answer
~50 sspyk creates a proxy around a real instance, but the real method bodies still reference their own this. When a real public method internally calls this.other(), that dispatch frequently goes straight to the real other() on the underlying object rather than through the spy proxy, so an every { spy.other() } stub is bypassed. This is the classic spy self-call limitation; if you must control an internal call, extract it into a separate injected collaborator instead. recordPrivateCalls = true (a spyk parameter) makes MockK instrument and record invocations of private functions so you can verify { spy invoke "privateFn" withArguments listOf(...) } or assert they happened; without it, private calls aren't tracked. You can also stub privates via every { spy.invoke("name").withArguments(...) }. These are advanced, brittle techniques — preferring real refactors over private-call mocking is the senior judgment.
code
kotlin · 13 linesclass Svc {
fun outer() = inner() + 1
fun inner() = 10
}
val spy = spyk<Svc>()
every { spy.inner() } returns 100
println(spy.inner()) // 100 (direct call via spy -> stubbed)
println(spy.outer()) // 11 (outer's this.inner() hits real 10)
// verifying a private call:
val s2 = spyk(Svc(), recordPrivateCalls = true)
s2.outer()
verify { s2 invoke "inner" withArguments listOf() } // tracked nowgo deeper
May not realize self-calls bypass stubs; can use a basic spy.
Knows internal self-calls can bypass stubs and avoids relying on them.
Explains the proxy/this dispatch reason, uses recordPrivateCalls knowingly, and prefers refactor-to-inject over private mocking.
Treats private/self-call mocking as a design smell, sets guidance to refactor for testability, and limits brittle string-based DSL to legacy code.
## Why self-calls bypass stubs `spyk(obj)` produces a **proxy** that intercepts calls made **through the spy reference**. But a real method's body holds its own `this`, pointing at the underlying real object. So: ```kotlin class Svc { fun outer() = inner() + 1 fun inner() = 10 } val spy = spyk<Svc>() every { spy.inner() } returns 100 spy.inner() // 100 (called through the spy -> stub applies) spy.outer() // 11 (outer's body calls this.inner() = real 10, NOT 100) ``` The outer call's `inner()` is dispatched on the real object, so the stub is **bypassed**. Lesson: **don't rely on stubbing a spy's internal self-calls.** If you need to control `inner`, make it an injected dependency: ```kotlin class Svc(private val helper: Helper) { fun outer() = helper.inner() + 1 } ``` Then mock `helper` normally. ## recordPrivateCalls Kotlin `private` methods aren't part of the type's public surface, so MockK doesn't track them by default. The spyk parameter `recordPrivateCalls = true` instruments private invocations so they can be **verified**: ```kotlin val spy = spyk(Svc(), recordPrivateCalls = true) spy.outer() verify { spy invoke "privateHelper" withArguments listOf("x") } ``` Without the flag, that `verify` wouldn't see the private call. ## Stubbing/calling privates MockK exposes a dynamic-call DSL for non-public members: ```kotlin every { spy.invoke("privateHelper").withArguments(listOf("x")) } returns 42 // or call it: val r = spy.javaClass // (reflection-style access) ``` The `invoke("name").withArguments(...)` form matches by **string name**, which is brittle — renames silently break it and it leans on reflection/instrumentation. ## Senior judgment - Spying on internals (self-calls, privates) tests **implementation detail**, raising coupling and fragility. - The robust fix is almost always a **refactor**: extract the internal behavior into a collaborator and inject it, then use an ordinary mock. Reserve `recordPrivateCalls` / `invoke("...")` for legacy code you can't change. - Combine with relaxation knobs only when necessary; these features layer instrumentation and increase test cost.
- What's the most robust fix when you need to control a spy's internal call?Refactor: extract the internal behavior into an injected collaborator and mock that collaborator normally, instead of relying on spy self-call interception.
- Why is the invoke("name").withArguments(...) DSL considered brittle?It matches private members by string name via instrumentation/reflection, so a rename doesn't fail to compile — it silently stops matching, breaking the test quietly.
Tapping someone's office phone (the spy) catches outside calls, but when they walk down the hall to talk in person (a self-call) you hear nothing — you wired the line, not the person.
saying these in an interview costs you the question
- Claiming spy stubs always intercept internal self-calls
- Not knowing recordPrivateCalls is needed to verify private invocations
- Recommending heavy private-method mocking instead of refactoring
- Believing private calls are tracked by default on a spy