skip to content

What does MockK's recordPrivateCalls = true parameter change about a spy, and once it is enabled, how do you stub or verify a private function?

level: seniorimportance: should knowfreq 25%

answer

  1. private = not recorded by default
  2. recordPrivateCalls = true on spyk/mockk
  3. invoke "name" withArguments listOf(...)
  4. invokeNoArgs / getProperty / setProperty
  5. string names → renames break at runtime

basics

~20 s

By default MockK ignores private members: they execute but are neither recorded nor stubbable. With recordPrivateCalls = true they go through MockK, and you reach them by name with the dynamic-call DSL: every/verify { spy invoke "name" withArguments listOf(...) }.

solid answer

~50 s

A MockK double normally only intercepts non-private members. Private functions still run, but MockK neither records them nor lets you stub them — they are invisible to `verify`. Passing `recordPrivateCalls = true` to `spyk` (it exists on `mockk` too) makes MockK instrument private calls as well. You then address them through the dynamic-call DSL, because you cannot name a private member from the test source: ```kotlin val s = spyk(Service(), recordPrivateCalls = true) every { s invoke "computeTax" withArguments listOf(100) } returns 7 verify { s invoke "computeTax" withArguments listOf(100) } ``` Related forms: `s invokeNoArgs "reset"`, the shorthand `s["computeTax"](100)`, and `s getProperty "cache"` / `s setProperty "cache" value` for private properties. The cost is real: member names are **strings**, so a rename compiles cleanly and only fails at runtime, and the test is now coupled to internals. Use it for characterising legacy code, not as a default.

code

kotlin · 10 lines
kotlin
class ReportService(private val repo: Repo) {
    fun report(id: Long): String = render(repo.load(id))
    private fun render(data: Data): String = expensiveRemoteRender(data)
}

val service = spyk(ReportService(repo), recordPrivateCalls = true)
every { service invoke "render" withArguments listOf(any<Data>()) } returns "stub"

assertEquals("stub", service.report(1L))
verify { service invoke "render" withArguments listOf(any<Data>()) }

go deeper

for a junior

Know that private members are not visible to MockK unless the double was created with recordPrivateCalls = true.

for a middle

Name the parameter and the dynamic-call DSL (invoke / withArguments / invokeNoArgs / getProperty), and show a stub and a verify.

for a senior

Weigh it: use it to get legacy code under test, prefer stubbing over verifying private calls, and flag the string-name fragility.

for a principal

Set the policy — private-call recording is a temporary scaffold tied to a refactor, never the default testing idiom, and the suite should trend toward zero occurrences.

## The default: private means invisible When MockK builds a mock or a spy it intercepts the class's members so it can record calls and answer them from your stubs. By default it leaves **private** members out of that treatment. On a spy this means a private function still executes its real body — it is ordinary code — but MockK has no record of it. You cannot `verify` it, and you cannot replace it with `every`. That default is deliberate: private members are implementation detail, and keeping them out of the recording keeps the interaction log meaningful. ## Turning it on ```kotlin val service = spyk(ReportService(repo), recordPrivateCalls = true) ``` The same parameter exists on `mockk(...)`. With it enabled, private calls are instrumented like any other call: they land in the recorded-call log and they can be stubbed. ## Addressing a member you cannot name Kotlin will not let your test source reference a private function, so MockK provides a **dynamic-call DSL** that addresses members by string name: ```kotlin // stub every { service invoke "buildHeader" withArguments listOf("EU", 2026) } returns "hdr" // no-argument form every { service invokeNoArgs "resetCache" } just Runs // shorthand for the same thing every { service["buildHeader"]("EU", 2026) } returns "hdr" // verification uses exactly the same syntax verify { service invoke "buildHeader" withArguments listOf("EU", 2026) } ``` Private *properties* have their own forms: ```kotlin every { service getProperty "cache" } returns preloadedCache every { service setProperty "cache" value any() } just Runs ``` Argument matchers work inside `withArguments` just as they do in a normal recording block: `listOf(any<String>(), eq(2026))`. ## What this buys you Two genuine use cases: 1. **Characterising legacy code.** A large class does its real work in a private helper that hits the network. You cannot inject anything without a refactor, but you can spy the class, stub the private helper, and get the public method under test. 2. **Proving an internal path ran** when the public surface gives no observable signal — typically as a temporary scaffold while you carve out a seam. ## What it costs - **No compile-time safety.** `"buildHeader"` is a string. Rename the function in the IDE and the test still compiles; it fails later at runtime, and the failure message talks about a missing member rather than about your intent. - **Signature coupling.** `withArguments listOf(...)` pins the parameter list. Adding a parameter to a private helper — normally a free refactor — now breaks tests. - **Tests that assert on internals.** A verification of a private call says "the implementation does it this way", not "the behaviour is correct". It will fail on refactors that changed nothing a caller can observe. - **A noisier record.** Private calls now count as recorded interactions, which matters for exhaustive-verification style assertions. ## How to use it responsibly - Reach for it when the alternative is not testing the code at all. - Prefer stubbing a private call over *verifying* one: stubbing buys you isolation, verifying buys you brittleness. - Treat every `invoke "name"` in the suite as a TODO attached to a refactor — when the private helper becomes an injected collaborator, delete the dynamic call along with it. - Keep the parameter off by default; enabling it globally makes every private call part of the interaction record for no benefit. ## Diagnosing the common failures - *"My verify on a private function never matches."* Check that `recordPrivateCalls = true` is actually on the double — without it the call happened but was never recorded. - *"It matched yesterday and not today."* Look for a rename or a changed parameter list; the string name and `withArguments` list are both silently position- and name-sensitive. - *Overload ambiguity.* Two private functions with the same name are distinguished only by the arguments you supply, so be explicit with typed matchers.

  • Without recordPrivateCalls, what actually happens when a spied object calls its own private helper?
    The helper simply runs as normal Kotlin code — MockK is not in that call path at all. Nothing is recorded, so a verify on it can never match, and no stub you write for it takes effect. The call is completely invisible to the test double.
  • What is the main maintenance risk of verifying private calls this way?
    The member is addressed by a string, so renaming or re-signaturing the private function compiles fine and breaks only at test runtime, with a message about a missing or unmatched member rather than about the behaviour you meant to assert. It also couples the test to implementation detail, so refactors that preserve observable behaviour still fail.

saying these in an interview costs you the question

  • Assuming private calls are recorded by default and blaming MockK when verify does not match
  • Thinking you can write verify { spy.privateFn() } — private members are not referenceable from the test
  • Inventing syntax such as `spy private "name"`; the real DSL is invoke/invokeNoArgs/getProperty/setProperty
  • Treating verification of private helpers as a normal testing style rather than a legacy-code tactic
  • Believing the string name is checked at compile time

context