← Testing Concepts

Mocking Best Practices: Integration Tests Only, Spies, and Types You Own

Published on 2026-08-13·v1.0

Objective

Go beyond "mock only at the system boundary" (the sibling observable-behavior-and-mock-fragility concept) to the operational rules Khorikov gives for using mocks well once you're at that boundary: mocks belong in integration tests, never in unit tests that exercise pure domain logic; a hand-written test spy often beats a generic mock's verify(); a verify() call needs to check how many times a method was called, not just that it happened; and you should only ever mock a type your own codebase defines.

Use Cases

  • Reviewing a unit test on a domain/business-logic class that needs a mock to even compile, and recognizing that as a design smell in the class under test rather than a reason to reach for Mockito.
  • Choosing between a generic mock's verify() and a hand-written spy when a mocked collaborator gets asserted against the same way across many tests.
  • Catching a verify(mock, atLeastOnce()) in review and recognizing it silently permits a duplicate call (double-charge, double-email) that a stricter assertion would catch.
  • Deciding whether to mock a third-party SDK class directly or introduce a thin adapter interface first, before a library upgrade forces the question.

Deep Dive

Mocks belong in integration tests, not unit tests

The precise rule: use mocks only when testing the layer that talks to unmanaged dependencies — never when testing the domain model. Mocking is for out-of-process collaborators (see managed-vs-unmanaged-dependencies and observable-behavior-and-mock-fragility for what qualifies); domain/business-logic classes shouldn't touch those directly at all. So a test on the domain model is, by definition, a unit test with no mock in it, and a test that exercises the orchestration layer talking to a real unmanaged dependency (or a mock standing in for one) is an integration test.

This connects directly to the functional-core/imperative-shell split covered in functional-architecture-and-testability: a class needing a mock to be unit-tested at all is usually a sign it's mixing a decision (business logic) with a side effect (I/O), not a sign the test needs a mock. The fix is almost never "add a mock" — it's separating the two responsibilities so the decision-making part has nothing left to mock.

A minimal contrast makes the rule concrete. A policy class that decides whether an invoice reminder is due needs no test double at all — it's pure domain logic:

java
public final class OverdueInvoicePolicy { public boolean needsReminder(Invoice invoice, LocalDate today) { long daysOverdue = ChronoUnit.DAYS.between(invoice.dueDate(), today); return daysOverdue > 0 && daysOverdue % 7 == 0; } } @Test void reminderIsDueOnTheSeventhDayOverdue() { Invoice invoice = new Invoice("INV-42", LocalDate.of(2026, 8, 1)); boolean dueToday = new OverdueInvoicePolicy() .needsReminder(invoice, LocalDate.of(2026, 8, 8)); // exactly 7 days overdue assertTrue(dueToday); // pure function in, value out — no mock anywhere }

The controller that actually sends the reminder does talk to an unmanaged dependency, so its test is an integration test and mocking that one collaborator is legitimate:

java
@Test void controllerSendsReminderWhenPolicySaysItsDue() { SmsGateway mockGateway = mock(SmsGateway.class); ReminderController sut = new ReminderController(new OverdueInvoicePolicy(), mockGateway); sut.sendReminderIfDue(invoice, LocalDate.of(2026, 8, 8)); verify(mockGateway, times(1)).send("+15551234567", "Invoice INV-42 is overdue"); }

Nothing here contradicts the "only one mock per test" folklore some teams repeat — Khorikov calls that a misconception. A unit of behavior can legitimately need several unmanaged dependencies (a message bus and a logger, say), and the number of mocks an integration test needs is simply however many unmanaged collaborators that operation touches — not a ceiling to enforce for its own sake.

Test spies: a purpose-built alternative to a generic mock

A spy is a test double that does the same job as a mock — it records what it was called with, so a test can assert on the interaction — but it's hand-written instead of generated by a mocking framework. Khorikov calls spies "handwritten mocks": functionally the same category of double, just built by hand for one specific collaborator instead of generically for any interface.

The payoff is readability. A generic mock's verify() calls are repeated, argument-by-argument, in every test that cares about the interaction; a spy can expose a purpose-built, fluent assertion method that reads like a sentence and gets reused everywhere that collaborator is checked:

java
public interface SmsGateway { void send(String phoneNumber, String message); } // Hand-written spy: test code, not production code public class SmsGatewaySpy implements SmsGateway { private final List<String> sentMessages = new ArrayList<>(); @Override public void send(String phoneNumber, String message) { sentMessages.add(phoneNumber + ":" + message); } public SmsGatewaySpy shouldHaveSentExactly(int count) { assertEquals(count, sentMessages.size()); return this; } public SmsGatewaySpy withMessageTo(String phoneNumber, String message) { assertTrue(sentMessages.contains(phoneNumber + ":" + message)); return this; } }
java
@Test void controllerSendsExactlyOneReminderSms() { SmsGatewaySpy smsSpy = new SmsGatewaySpy(); ReminderController sut = new ReminderController(new OverdueInvoicePolicy(), smsSpy); sut.sendReminderIfDue(invoice, LocalDate.of(2026, 8, 8)); smsSpy.shouldHaveSentExactly(1) .withMessageTo("+15551234567", "Invoice INV-42 is overdue"); }

shouldHaveSentExactly(1).withMessageTo(...) chains into something close to plain English, and both checks live in one place instead of being retyped in every test. Khorikov's own caveat applies here too: a spy is worth writing at collaborators the codebase asserts against repeatedly and precisely (the kind of type discussed under "only mock types you own," below); for a one-off interaction checked in a single test, a plain verify() is less ceremony and perfectly fine.

Verifying call count, not just call occurrence

Mockito's verify(mock).method(...) already defaults to exactly one matching call — it's shorthand for verify(mock, times(1)).method(...), and it fails if the call happened zero times or two or more times. That's stricter than the plain Verify() Khorikov's own (Moq/C#) examples use, where the unqualified form only checks "at least once" and needs an explicit Times.Once to become exact. The lesson still transfers, just at a different spot in the Java API: the mistake to watch for is a Mockito verification that's been loosened away from that strict default, typically with atLeastOnce() or atLeast(1). Those read almost identically to a bare verify() but reopen exactly the gap the book warns about — a duplicate call the test should have caught slips through silently:

java
// Under-specified: passes even if send() fires twice — a duplicate SMS goes undetected verify(mockGateway, atLeastOnce()).send("+15551234567", "Invoice INV-42 is overdue"); // Correctly specified: fails the moment the reminder is sent more than once verify(mockGateway, times(1)).send("+15551234567", "Invoice INV-42 is overdue");

The book's other half of this rule is checking for the absence of unexpected calls, not just the presence of the expected one — a test that only asserts the right message went out still misses a second, unrelated call slipping through unnoticed. Mockito's equivalent of the book's VerifyNoOtherCalls() is verifyNoMoreInteractions():

java
verify(mockGateway, times(1)).send("+15551234567", "Invoice INV-42 is overdue"); verifyNoMoreInteractions(mockGateway); // fails if send() (or anything else) was called again

SmsGatewaySpy.shouldHaveSentExactly(1) from the previous section already gives both guarantees in one call — it's checking the total recorded count, so it can't pass on either a missing or a duplicate send.

Only mock types you own

The last rule: never hand a mocking framework a third-party library's class or interface directly. Write a thin adapter of your own around it, and mock the adapter instead:

java
// Third-party SDK type — its shape isn't yours to control class StripeClient { ChargeResult charge(String customerId, long amountCents, String currencyCode) { /* ... */ } } // Your own type — the one your tests actually mock public interface PaymentGateway { void charge(CustomerId customerId, Money amount); } public class StripePaymentGateway implements PaymentGateway { private final StripeClient stripeClient; public StripePaymentGateway(StripeClient stripeClient) { this.stripeClient = stripeClient; } @Override public void charge(CustomerId customerId, Money amount) { stripeClient.charge(customerId.value(), amount.cents(), amount.currencyCode()); } }
java
@Test void checkoutChargesTheCustomerExactlyOnce() { PaymentGateway mockGateway = mock(PaymentGateway.class); CheckoutController sut = new CheckoutController(mockGateway); sut.checkout(order); verify(mockGateway, times(1)).charge(order.customerId(), order.total()); }

The specific failure mode this avoids: if StripeClient mocked directly, then a Stripe SDK upgrade that renames charge(...) to createCharge(...), reorders its parameters, or splits it into two calls breaks every test that mocked StripeClient — even though nothing about CheckoutController's own logic is wrong. With the adapter in place, that same upgrade only touches StripePaymentGateway's implementation (and the handful of integration tests that exercise it against the real SDK); every test mocking PaymentGateway keeps compiling and passing untouched, because PaymentGateway is a shape your own codebase controls. The adapter also lets you express the dependency in your own domain's terms (CustomerId, Money) instead of the library's raw primitives, the same role IBus/IMessageBus-style wrappers play in observable-behavior-and-mock-fragility.

Trade-offs

  • A unit test that needs a mock is a design smell, not a testing gap to patch — reaching for Mockito inside a domain-model test is usually treating a symptom; the actual fix is separating the decision from the side effect (see functional-architecture-and-testability), after which the domain test needs no double at all.

  • "One mock per test" is folklore, not a Khorikov rule — the number of mocks an integration test needs equals the number of unmanaged dependencies the operation under test actually talks to; artificially limiting mocks per test doesn't make the test better and can push you toward testing less than a full unit of behavior in one pass.

  • A spy costs upfront authoring time to save repeated verification boilerplate later — worth it for a collaborator asserted against precisely across many tests; for a single one-off interaction, a plain verify() call is cheaper and equally correct.

  • atLeastOnce()/atLeast(n) reads like a safe default but is strictly weaker than Mockito's own implicit default — verify(mock) alone already enforces exactly one call; swapping in atLeastOnce() for a "just to be safe" feel actually loosens the check and lets a duplicate call through:

    java
    verify(mockGateway, atLeastOnce()).send(phone, message); // duplicate sends pass silently verify(mockGateway, times(1)).send(phone, message); // duplicate sends fail the test
  • Checking for expected calls without verifyNoMoreInteractions() (or an equivalent count-based check) only proves half the contract — an unmanaged dependency needs both "the expected call happened" and "no unexpected call also happened"; a test that only asserts the former can still miss a second, accidental call to the same or a different method on the mock.

  • Mocking a third-party type directly saves writing an adapter, until the library changes — the adapter is extra code for a benefit that doesn't show up until the next major-version upgrade, at which point it's the difference between updating one class or chasing failures across the whole test suite.

Documentation Links