In MockK, when you write spyk(existingInstance), what relationship does the resulting spy have with the instance you passed in — and what breaks if you assume it is a wrapper that delegates to that object?
answer
- copy, not wrapper
- fields copied at spyk() time
- shallow — references shared
- spy !== original
- wire first, spy last
basics
~20 sspyk(obj) does not delegate. MockK builds a fresh instrumented instance of the same class and copies obj's fields into it, shallowly. Spy and original are then separate objects; state changes on one are invisible to the other.
solid answer
~50 s`spyk(obj)` is often described as "spying on that object", but mechanically MockK creates its **own** instrumented instance of the class and **copies the field values** of the object you handed in. There is no delegation afterwards. The practical consequences: - Mutations made through the spy land on the spy's fields. Asserting on the original instance afterwards sees the pre-copy values. - Mutations made on the original **after** the spy was created are never seen by the spy — so build/wire the object fully, then spy it last. - The copy is **shallow**: reference-typed fields point at the same nested objects, so a shared `MutableList` really is shared, while an `Int` counter is not. - The spy is a different identity (`spy !== original`), so registries or identity maps that hold the original will not match it. Rule: hand the spy to the code under test and assert on the spy, never on the original.
code
kotlin · 15 linesclass Cart(val items: MutableList<String> = mutableListOf()) {
var total: Int = 0
fun add(item: String) { items += item; total += 5 }
}
val real = Cart()
val spy = spyk(real)
spy.add("book")
// spy.total == 5, but the copy means:
assertEquals(0, real.total) // Int was copied by value
assertEquals(1, real.items.size) // list reference is shared (shallow copy)
verify { spy.add("book") } // the real call was still recordedgo deeper
Know the headline: spyk(obj) gives you a separate object with copied state, so assert on what spyk returned, not on the object you passed in.
Explain the copy-not-wrapper mechanic, the ordering rule (wire first, spy last), and that the copy is shallow so reference fields stay shared.
Diagnose from symptoms — assertions on the original failing, late-injected dependencies missing on the spy, identity mismatches — and say which of them the copy explains.
Frame it as a design constraint: needing spies on partially wired objects usually points at construction-time coupling worth fixing rather than working around with MockK.
## What a spy is, mechanically A spy is a test double that keeps the real implementation: any member you have not stubbed runs the class's actual code, and every call is recorded so you can verify it later. MockK creates one with `spyk`. To intercept calls, MockK needs an *instrumented* instance — an instance of a class whose bytecode MockK controls. It cannot instrument an arbitrary object you already constructed after the fact and hand it back to you as the same reference. So `spyk(obj)` does something subtly different from what the name suggests. ## What spyk(obj) actually does 1. MockK produces a new instance of `obj`'s class that it controls (created without running your constructor). 2. It then copies the field values out of the object you passed and writes them into the new instance, field by field. 3. It returns that new instance. From that point on, **there is no link at all** between the object you passed and the spy you got. Nothing is forwarded, nothing is delegated. The phrase to remember is "copy, not wrapper". ## The four consequences that bite people **1. Assertions on the original fail.** A test creates `val cart = Cart()`, spies it, drives the spy through `addItem(...)`, then asserts `cart.total == 10`. The spy's `total` moved; `cart.total` is still whatever it was at copy time. Always assert on the value `spyk` returned. **2. Late wiring is lost.** If a field is assigned after construction — a setter, a manual dependency injection step, a builder finishing up — and you created the spy before that assignment, the spy holds the old value. Create the spy *after* the object is fully initialized, and re-create it (rather than mutating the original) if setup changes. **3. The copy is shallow.** Field values are copied as-is. A `var count: Int` diverges immediately, because copying an `Int` copies the value. A `val items: MutableList<String>` does not diverge, because both objects hold the *same list reference*: mutating through the spy is visible through the original and vice versa. This mixed behaviour is exactly what makes the bug confusing — half the state seems shared and half does not. **4. Identity changes.** `spy === original` is false. Any code that looked the original up in an identity-based structure, or that was already given the original as a collaborator, keeps talking to the original — so the calls you expect the spy to record simply never reach it. Whatever consumes the collaborator must be constructed with, or told about, the spy. ## Why MockK is built this way MockK must be in the call path for *every* member of the class, including final ones (Kotlin classes and members are final by default). It achieves that by producing an instance whose class it has instrumented. A delegating wrapper would only be able to intercept calls that arrive from outside through the wrapper reference, and would need the class to be open to be substitutable. Copying state into an instrumented instance sidesteps both problems, at the price of the copy semantics above. ## Verifying against a spy Because the spy is a full MockK mock underneath, every real call through it is recorded: `verify { spy.addItem(any()) }` works exactly as it does for a plain mock, even though the method actually executed real code and produced real results. That combination — real behaviour plus a recorded interaction log — is the whole point of a spy. ## The checklist - Construct and wire the object completely; spy it last. - Pass the spy, not the original, to the system under test. - Assert and verify on the spy. - Remember the copy is shallow when state includes mutable collections or shared services. - If the class is expensive or awkward to construct, that is a signal to reconsider the spy, not a reason to spy the half-built object.
- You mutate the original object after creating the spy. Does the spy see the change?No. The field values were copied once, at the moment `spyk(obj)` ran; there is no live link afterwards. The spy keeps the values as of that instant. The fix is ordering: finish all construction and wiring first, then create the spy, and if setup has to change, create a new spy rather than editing the original.
- Given the copy semantics, when is spyk(obj) still the right tool?When you want the class's real behaviour for most methods but need to replace one or two — for example a real service whose single network call you stub out — and the object's state is fully set at construction time. It is a poor fit when state is injected late, when the object is registered elsewhere by identity, or when the real methods have side effects you did not want executed.
spyk(obj) is a photocopy of a form, not a mail-forwarding address: writing on the copy never shows up on the original — though if the form referenced an external folder, both copies point at that same folder.
saying these in an interview costs you the question
- Saying spyk(obj) delegates unstubbed calls to the instance you passed in
- Asserting on the original object after exercising the spy
- Assuming the state copy is deep, then being surprised that a shared MutableList mutates both ways
- Claiming the class must be open for spyk to work
- Handing the original to the system under test and then wondering why verify finds no calls