Skip to content

Mockito: Architecture | How Mock, Stub and Spy Work Internally

The Traffic Control Room Analogy

Imagine every intersection in a city has a camera that records every car passing through. When a traffic manager wants to know whether a car went through a red light, they do not rerun the trip — they query the recording. When they want to reroute traffic, they intercept the signal before the car even reaches the intersection.

Mockito works the same way. Every method call on a mock or spy object is intercepted before reaching the real code. The interception infrastructure records what happened and decides what to return — consulting a rulebook (the stub registry) you wrote in advance.

Understanding this internal flow removes every mystery from Mockito: why spies behave differently from mocks, why when().thenReturn() is risky for spies, why verification works, and how infinite loops are avoided.


The Four Phases of a Test Method

In any well written Mockito test there are exactly four phases:

  1. Create mock or spy object — obtain a fake or partially real object.
  2. Stubbing — define what the fake should return when specific methods are called.
  3. Actual invocation — call the method under test, which internally calls the dependency.
  4. Verification — assert that the dependency was invoked correctly.

Each phase routes through the same internal pipeline. Let us trace that pipeline step by step.


Phase 1: Creating a Mock or Spy (MockMaker)

When you call Mockito.mock(Calculator.class) or Mockito.spy(new Calculator()), Mockito does not instantiate Calculator normally. The call goes to an interface called MockMaker, which has two main implementations:

SubclassMockMaker (older default)

Creates a subclass (proxy class) of Calculator:

Calculator  <──  CalculatorSubclassProxy
                 (generated at runtime)
                 + interceptor wired in

The proxy overrides all methods and delegates to an interceptor. The problem: final classes cannot be subclassed, so SubclassMockMaker cannot mock final classes.

InlineMockMaker (current default in Mockito 4+)

Instead of creating a new class, it modifies the bytecode of Calculator itself using a Java agent library called Byte Buddy. The modified bytecode looks conceptually like this:

java
// What Calculator.multiply looks like after inline instrumentation
public int multiply(int a, int b) {
    if (INTERCEPTION_ENABLED) {
        // Forward to interceptor
        return (int) mockMethodInterceptor.intercept(this, multiplyMethod, new Object[]{a, b}, realMethod);
    }
    // Run original code
    return a * b;
}

The INTERCEPTION_ENABLED flag is critical and we will return to it. The key advantage: final classes can be mocked because no subclass is needed.

java
// InlineMockMaker can mock final classes
final class PaymentGateway { ... }
PaymentGateway mock = Mockito.mock(PaymentGateway.class); // works with inline

The Interceptor and MockHandler

No matter which MockMaker is used, every method call on a mock or spy object eventually reaches:

Your code
  → mock.multiply(4, 2)
    → MockMethodInterceptor.intercept(...)
      → MockHandler.handle(...)
        → InvocationContainer

MockHandler is the brain of Mockito. It contains the actual decision logic. InvocationContainer is the storage layer — it records:

  • All stub definitions (when this method is called with these args, return this value)
  • All actual invocations (method name, arguments, invocation count)

This is why verification is only possible on mocks and spies: only those objects route through the interceptor and record invocations. Real objects have no such interception.


The INTERCEPTION_ENABLED Flag and Infinite Loop Prevention

For spies, when no stub is configured, Mockito must invoke the real method. Naively, this would cause an infinite loop:

spy.multiply(4, 2)
  → interceptor → handler → no stub → invoke real method
    → spy.multiply(4, 2) again  ← INFINITE LOOP

Mockito prevents this by temporarily disabling the interception flag before invoking the real method:

spy.multiply(4, 2)
  → interceptor → handler → no stub → DISABLE interception flag
    → real Calculator.multiply(4, 2) executes normally
      → returns 8
    → ENABLE interception flag again
  → returns 8 to caller

This flag has a second purpose: code paths that invoke the calculator from outside the test context (not through Mockito) do not set the flag, so they always run the real logic.


Phase 2: How Stubbing Works Internally

Consider two stubbing styles and how they differ internally.

Style 1: when(...).thenReturn(...)

java
when(mockCalculator.multiply(4, 2)).thenReturn(100);

Java evaluates inner expressions first, so mockCalculator.multiply(4, 2) is resolved before when receives control. Tracing the flow:

  1. mockCalculator.multiply(4, 2) is called.
  2. Interceptor fires → MockHandler receives the call.
  3. MockHandler checks: is there a stub in InvocationContainer for multiply(4, 2)? No (we are in the middle of creating it).
  4. MockHandler checks: is this a verify call? No.
  5. MockHandler checks: is this a mock or spy?
    • If mock: return default value (0 for int).
    • If spy: invoke the real method (the danger with spies).
  6. The return value is discarded by when(...).
  7. when(...) starts the stub registration process.
  8. .thenReturn(100) completes it, storing multiply(4,2) → 100 in InvocationContainer.

For mock objects this is safe — step 5 just returns 0 which is discarded. For spy objects step 5 actually executes the real multiply — a side effect you almost never want.

Style 2: doReturn(...).when(...).method(...)

java
doReturn(100).when(spyCalculator).multiply(4, 2);
  1. doReturn(100) puts 100 into a temporary area immediately, signaling "we are about to stub something."
  2. .when(spyCalculator) identifies the spy object.
  3. .multiply(4, 2) is called.
  4. Interceptor fires → MockHandler checks: is there data in the temporary area? Yes.
  5. MockHandler transfers the stub from the temporary area into InvocationContainer.
  6. Real method is never invoked.

This is why doReturn is always safe for spies.

java
// Safe spy stubbing
Calculator realCalc = new Calculator();
Calculator spy = Mockito.spy(realCalc);

// SAFE: real multiply is never called during stubbing
doReturn(100).when(spy).multiply(4, 2);

// RISKY: real multiply IS called during stubbing (side effects possible)
// when(spy.multiply(4, 2)).thenReturn(100);

Phase 3: Actual Method Invocation

Once stubs are registered, when the method under test calls the dependency:

java
int result = mockCalculator.multiply(4, 2);

The flow is:

  1. Interceptor fires → MockHandler.
  2. Is there data in the temporary area? No.
  3. Is this a verify call? No.
  4. Is there a stub in InvocationContainer for multiply(4, 2)? Yes.
  5. Increment the invocation count for multiply(4, 2) in InvocationContainer.
  6. Return the stubbed value: 100.

The real multiply is never executed. The invocation count is now available for verification.

For a spy with no stub, the flow reaches step 4 with a "No" answer, then invokes the real method with the flag disabled (as described above), and records the invocation count anyway.


Phase 4: Verification

java
Mockito.verify(mockCalculator, times(1)).multiply(4, 2);
  1. Interceptor fires → MockHandler.
  2. MockHandler recognizes this as a verify call.
  3. It queries InvocationContainer: how many times was multiply(4, 2) called?
  4. It compares against times(1).
  5. Pass or fail accordingly.

InvocationContainer has all the history: method names, argument values, call counts, and call order.


Complete Architecture Diagram

Test Code

   ├─ mock() / spy() ──────→ MockMaker (InlineMockMaker default)
   │                              │
   │                         Byte Buddy instruments bytecode
   │                              │
   │                         Object returned with interceptor wired

   ├─ stubbing ───────────→ MockMethodInterceptor
   │                              │
   │                         MockHandler (the brain)
   │                              │
   │                    ┌─────────┴──────────┐
   │                    │                    │
   │              Temporary area       InvocationContainer
   │             (doReturn data)    (stubs + invocation history)

   ├─ act (real call) ────→ MockMethodInterceptor → MockHandler
   │                              │
   │                    Finds stub in InvocationContainer
   │                    Returns stubbed value (or default / real method)

   └─ verify ─────────────→ MockMethodInterceptor → MockHandler

                         Reads InvocationContainer
                         Validates count / args / order

Practical Code Examples

java
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class CalculatorArchitectureTest {

    @Test
    void mock_returnsDefaultsWithoutStub() {
        Calculator calc = mock(Calculator.class);

        // No stub configured — default for int is 0
        int result = calc.add(4, 2);
        assertEquals(0, result);
    }

    @Test
    void mock_withStub_returnsConfiguredValue() {
        Calculator calc = mock(Calculator.class);

        when(calc.add(4, 2)).thenReturn(10); // stub registered in InvocationContainer

        int result = calc.add(4, 2); // interceptor finds stub, returns 10
        assertEquals(10, result);

        verify(calc, times(1)).add(4, 2); // InvocationContainer confirms one call
    }

    @Test
    void spy_callsRealMethodWhenNoStubPresent() {
        Calculator real = new Calculator();
        Calculator spy = spy(real);

        // No stub for add — real method runs
        assertEquals(6, spy.add(4, 2));
    }

    @Test
    void spy_doReturn_doesNotInvokeRealMethodDuringStubbing() {
        Calculator real = new Calculator();
        Calculator spy = spy(real);

        // Safe: real multiply is never called here
        doReturn(999).when(spy).multiply(4, 2);

        assertEquals(999, spy.multiply(4, 2));
    }

    @Test
    void inlineMockMaker_canMockFinalClass() {
        // Works with InlineMockMaker (Mockito 4+ default)
        FinalService mock = mock(FinalService.class);
        when(mock.process()).thenReturn("mocked");
        assertEquals("mocked", mock.process());
    }
}

final class FinalService {
    public String process() { return "real"; }
}

class Calculator {
    public int add(int a, int b)      { return a + b; }
    public int multiply(int a, int b) { return a * b; }
}

Interview Questions & Pitfalls

Q1: What is MockMaker in Mockito and what are its two main implementations?

MockMaker is the interface responsible for creating mock and spy objects. SubclassMockMaker generates a proxy subclass and wires in an interceptor but cannot mock final classes. InlineMockMaker uses a Java agent (Byte Buddy) to modify the bytecode of the actual class and can mock final classes. InlineMockMaker is the default in Mockito 4 and later.

Q2: What is InvocationContainer and what does it store?

InvocationContainer is the data store at the heart of MockHandler. It maintains two kinds of records: stub definitions (which method with which arguments should return which value) and invocation history (which methods were actually called, with what arguments, and how many times). Verification queries InvocationContainer to check call counts and argument values.

Q3: Why can when(spy.method()).thenReturn(value) be dangerous for spies?

Java evaluates the inner expression spy.method() before passing the result to when. For a spy, if no stub exists yet for that method, Mockito's handler invokes the real method (partial mock default behavior). This means real code runs — including potential database calls, network calls, or exceptions — before the stub is even registered. doReturn(value).when(spy).method() avoids this because it preregisters the stub value before the method call is intercepted.

Q4: How does Mockito prevent infinite loops when a spy invokes the real method?

Mockito uses an interception flag in the instrumented bytecode. Before calling the real method on behalf of a spy, MockHandler disables the flag. When the real method runs and tries to reenter the same path, it sees the flag is off and simply executes the original logic. After the real method returns, MockHandler reenables the flag. This prevents the recursive interception loop.

Q5: Why is verification only possible on mock and spy objects, not on real objects?

Only mock and spy objects have their bytecode instrumented by MockMaker. Every invocation on them is routed through MockMethodInterceptor and recorded in InvocationContainer. Real objects bypass the interceptor entirely, so Mockito has no record of their invocations and cannot verify them.

Q6: What does Byte Buddy do in the context of Mockito's InlineMockMaker?

Byte Buddy is a bytecode manipulation library used as a Java agent by InlineMockMaker. It modifies the bytecode of the class being mocked at runtime to insert the interception flag check and the delegation to MockMethodInterceptor. This modification happens to the class itself rather than creating a new subclass, which is why final classes become mockable.