Every experienced developer has been there: a test suite that passes on your machine but fails in CI, a green build that hides a broken integration, or a test that's so tightly coupled to implementation that it breaks every time you refactor. These aren't testing failures, they're mocking failures.
Mocking exists to solve a fundamental tension in software testing: the need to test a unit of code in complete isolation while that code inevitably depends on the outside world, databases, APIs, queues, third-party services, the clock itself. Done well, mocking gives you surgical precision. Done poorly, it gives you false confidence and a maintenance nightmare.
This article is not about the basics. You already know what a mock is. What we're going after here is the craft, the judgment to know when mocking improves your tests and when it quietly destroys them, how to differentiate between the five types of test doubles, and how to write behavior-driven tests that survive aggressive refactoring.
If you're building production systems, payment processors, healthcare platforms, logistics engines, microservices that talk to each other, the strategies in this article will materially improve your test architecture.
Understanding Test Doubles: Beyond "Just Use a Mock"
Martin Fowler's taxonomy of test doubles is two decades old, but most developers still conflate them. This conflation leads to the wrong tool for the job. Let's fix that.
Dummy Objects
A dummy is the simplest test double. It's passed into a method to satisfy a parameter requirement but is never actually used in the execution path you're testing.
// Java / Mockito
// We need a Logger to construct PaymentProcessor, but we're testing charge()
// which doesn't log anything in the success path
Logger dummyLogger = mock(Logger.class);
PaymentProcessor processor = new PaymentProcessor(paymentGateway, dummyLogger);
processor.charge(order);
// dummyLogger is never called we don't verify or configure it
The key insight: if you find yourself configuring behavior on a dummy or verifying calls to it, you've misidentified it. It's now a mock or stub.
Stubs
A stub provides canned answers to method calls. It doesn't care how it's called only that when asked, it returns what you've configured. Stubs are for state-based scenarios where you need to control what a dependency returns.
# Python / unittest.mock
from unittest.mock import MagicMock
user_repo = MagicMock()
user_repo.find_by_id.return_value = User(id=42, email="[email protected]", tier="premium")
service = BillingService(user_repo)
invoice = service.generate_invoice(user_id=42)
assert invoice.discount_rate == 0.20 # premium tier discount
Notice: we're asserting on the state of invoice, not on whether find_by_id was called. That's the defining characteristic of stub usage, state verification, not interaction verification.
Fakes
Fakes are working implementations with simplified behavior. They're not configured per-test, they behave like the real thing but take shortcuts unsuitable for production.
// JavaScript an in-memory fake for a UserRepository
class InMemoryUserRepository {
constructor() {
this.users = new Map();
}
async findById(id) {
return this.users.get(id) ?? null;
}
async save(user) {
this.users.set(user.id, user);
return user;
}
}
// Tests use the fake, no database, no network, but real logic flows
const repo = new InMemoryUserRepository();
await repo.save({ id: 1, email: "[email protected]" });
const found = await repo.findById(1);
expect(found.email).toBe("[email protected]");
Fakes shine in integration-style tests where you want realistic behavior across multiple operations without external dependencies. They're higher effort to build but pay dividends when you have many tests hitting the same abstraction.
Spies
A spy wraps a real object and records how it was used. Unlike mocks, spies let the real implementation run, they just observe.
// Jest
const emailService = {
send: async (to, subject, body) => {
// real implementation that would send email
return { messageId: "real-id" };
}
};
const sendSpy = jest.spyOn(emailService, 'send');
await notificationService.notifyUser(user, "Your order shipped");
expect(sendSpy).toHaveBeenCalledWith(
"[email protected]",
expect.stringContaining("shipped"),
expect.any(String)
);
Spies are excellent for auditing: you want to verify that something happened, but you don't want to replace the real behavior. Use them when the real implementation is fast, side-effect-free (or the side effects are acceptable in tests), and you care about interaction patterns.
Mocks
Mocks are the full package, pre-programmed with expectations, configured with behavior, and verified after the fact. They fail loudly when those expectations aren't met.
// Mockito - strict mock with behavior expectations
@Test
void shouldSendConfirmationEmailAfterSuccessfulPayment() {
// Arrange
EmailService emailService = mock(EmailService.class);
PaymentGateway gateway = mock(PaymentGateway.class);
when(gateway.charge(any(ChargeRequest.class)))
.thenReturn(ChargeResult.success("txn-123"));
OrderService service = new OrderService(gateway, emailService);
// Act
service.placeOrder(order);
// Assert - interaction verification
verify(emailService).sendConfirmation(
eq("[email protected]"),
argThat(receipt -> receipt.transactionId().equals("txn-123"))
);
verifyNoMoreInteractions(emailService);
}
The mock here asserts behavior: not just that the order was placed successfully, but that the system communicated correctly with the email subsystem. This is interaction-based testing, the subject of our next section.
State Verification vs. Behavior Verification
This distinction is the conceptual core of advanced mocking. Getting it wrong leads to tests that test the wrong thing.
State Verification
After exercising the system under test, you examine the resulting state of the objects involved.
# State verification: did the order end up in the right status?
def test_cancel_order_updates_status():
order = Order(id=1, status="pending")
order_service = OrderService(InMemoryOrderRepository())
order_service.cancel(order)
retrieved = order_service.find(1)
assert retrieved.status == "cancelled" # ← state check
assert retrieved.cancelled_at is not None # ← state check
State verification is more robust. It survives refactoring because it doesn't care how the cancellation happens, only that it did happen correctly.
Behavior Verification
After exercising the system, you verify which methods were called on collaborators, with which arguments, and in what sequence.
# Behavior verification: did the system tell the right things to the right collaborators?
def test_cancel_order_notifies_fulfillment():
fulfillment = MagicMock()
order_service = OrderService(repo, fulfillment_client=fulfillment)
order_service.cancel(order)
fulfillment.cancel_shipment.assert_called_once_with(order.shipment_id)
Behavior verification becomes essential when the observable outcome is the interaction. If your method's job is to coordinate between subsystems, trigger a webhook, publish an event, call a third-party API, there may be no state to assert on. The interaction is the behavior.
The Tradeoff
Behavior verification couples your tests to implementation. If you refactor cancel_order to batch cancellations instead of calling cancel_shipment individually, your test breaks even though the system behavior is unchanged.
The practical rule: use state verification as your default. Escalate to behavior verification when the side effects are the requirement, when you're specifying that a notification must fire, an audit log must be written, a downstream service must be informed.
Advanced Mocking Strategies
Partial Mocks: The Compromise You Should Question
A partial mock, sometimes called a spy, mocks some methods while leaving others real. Most frameworks support it, and most experienced developers use it rarely.
// Mockito partial mock (spy)
UserService realService = new UserService(realRepo);
UserService partialMock = spy(realService);
// Override just the email-sending part
doNothing().when(partialMock).sendWelcomeEmail(any());
partialMock.registerUser(newUser);
verify(partialMock).sendWelcomeEmail(newUser);
When are partial mocks appropriate? When testing legacy code that hasn't been refactored for testability, you want to isolate one expensive or side-effectful collaborator without rewriting the entire class. Treat them as a refactoring stepping stone, not a destination.
Strict vs. Loose Mocks
Strict mocks (Mockito's STRICT_STUBS, Jest's default mock behavior with jest.config strictness) fail if you configure behavior that's never called, or call methods you didn't configure.
// Strict stubs - fail if configured interaction doesn't happen
@ExtendWith(MockitoExtension.class) // uses STRICT_STUBS by default
class PaymentServiceTest {
@Mock PaymentGateway gateway;
@Test
void shouldChargeCorrectAmount() {
when(gateway.charge(eq(new Money(100, "USD")))).thenReturn(SUCCESS);
// If gateway.charge() is never called, Mockito fails the test
// This catches "dead stubs" - configured interactions that became irrelevant after refactoring
service.processOrder(order);
verify(gateway).charge(new Money(100, "USD"));
}
}
Strict mocking is worth the friction on payment flows, security-critical paths, and integration boundaries. The noise it creates is signal - it tells you when your mock setup has drifted from the actual behavior.
Deep Stubbing: Handle With Care
Deep stubbing lets you chain method calls on mocks without configuring each intermediate object.
// Jest deep mock chaining
const stripe = {
customers: {
retrieve: jest.fn().mockResolvedValue({
subscriptions: {
data: [{ status: 'active', plan: { amount: 2999 } }]
}
})
}
};
// Now you can call stripe.customers.retrieve().subscriptions.data[0].plan.amount
The problem with deep stubbing: it's a strong signal that your code violates the Law of Demeter. If your production code chains a.getB().getC().getD(), consider whether a should expose a higher-level method instead. Deep stubs work - but they often indicate a design smell worth addressing.
Mocking Async Workflows
Modern systems are overwhelmingly async. Mocking async behavior requires explicit attention to Promise resolution, error paths, and sequencing.
// Jest - async mock with sequential responses
const repository = {
findUser: jest.fn()
.mockResolvedValueOnce({ id: 1, status: 'active' }) // first call
.mockResolvedValueOnce(null) // second call
.mockRejectedValueOnce(new DatabaseError('timeout')) // third call (error path)
};
// Test the retry logic
await expect(service.getUser(1)).resolves.toEqual({ id: 1, status: 'active' });
await expect(service.getUser(1)).resolves.toBeNull();
await expect(service.getUser(1)).rejects.toThrow('timeout');
For event-driven systems, mock the event emitter and test that handlers are registered and invoked correctly:
const eventBus = {
publish: jest.fn(),
subscribe: jest.fn()
};
orderService = new OrderService(eventBus);
await orderService.confirmOrder(orderId);
expect(eventBus.publish).toHaveBeenCalledWith('order.confirmed', {
orderId,
timestamp: expect.any(Number)
});
Mocking External Services and APIs
For payment gateways, shipping carriers, and communication platforms, you have two good options: HTTP-level mocking (intercept and respond at the network layer) or facade-level mocking (mock the wrapper you wrote around the third-party SDK).
# Python - mocking the Stripe client at the SDK level
from unittest.mock import patch, MagicMock
@patch('app.services.stripe.stripe.PaymentIntent.create')
def test_creates_payment_intent_with_correct_amount(mock_create):
mock_create.return_value = MagicMock(
id='pi_test_123',
status='requires_payment_method',
client_secret='pi_test_123_secret_abc'
)
result = payment_service.initiate_payment(amount=4999, currency='usd')
mock_create.assert_called_once_with(
amount=4999,
currency='usd',
automatic_payment_methods={'enabled': True}
)
assert result.intent_id == 'pi_test_123'
The critical practice: always wrap third-party clients behind an interface you own. This gives you a clean seam to mock without deep-stubbing through vendor SDKs. This is a foundational principle in and distributed system testing. It's one of the more powerful advanced patterns available to teams doing continuous delivery.
Dependency Isolation Strategies
Before you can mock a dependency, you need a seam, a place in the code where you can substitute the real thing for a test double. The three main strategies:
Constructor injection (cleanest, most testable):
public class NotificationService {
private final EmailClient emailClient;
private final SmsClient smsClient;
public NotificationService(EmailClient emailClient, SmsClient smsClient) {
this.emailClient = emailClient;
this.smsClient = smsClient;
}
}
Method injection (useful for per-call variation):
def process_payment(order, gateway=None):
gateway = gateway or StripeGateway()
return gateway.charge(order.total)
Interface-based abstraction (enforces the contract):
interface MessageQueue {
publish(topic: string, payload: unknown): Promise<void>;
subscribe(topic: string, handler: (msg: unknown) => void): void;
}
class OrderProcessor {
constructor(private queue: MessageQueue) {}
// Now you can mock MessageQueue freely in tests
}
Common Anti-Patterns That Quietly Destroy Your Test Suite
Over-Mocking: The Test That Tests Nothing
When you mock everything, repositories, services, validators, factories, you're no longer testing behavior. You're testing that your code calls its dependencies in a specific order. This is brittle and meaningless.
// ❌ Over-mocked: this test will pass even if the business logic is completely wrong
@Test
void overMockedAntiPattern() {
when(validator.validate(order)).thenReturn(true);
when(pricer.calculateTotal(order)).thenReturn(money);
when(inventoryChecker.isAvailable(order)).thenReturn(true);
when(paymentProcessor.charge(money)).thenReturn(receipt);
when(orderRepo.save(any())).thenReturn(savedOrder);
when(emailService.send(any())).thenReturn(true);
service.placeOrder(order);
verify(validator).validate(order);
verify(pricer).calculateTotal(order);
// ... etc
// What did we actually test? Just the call order. Not correctness.
}
The fix: mock only the external boundary - things outside your process (databases, HTTP calls, queues). Let the internal logic run for real.
Testing Implementation Details
The most common cause of brittle tests is asserting on how something is done rather than what it accomplishes.
// ❌ Brittle - breaks on every refactor
expect(userService.hashPassword).toHaveBeenCalledWith('rawpassword', { rounds: 12 });
// ✅ Resilient - tests the outcome
const savedUser = await userRepo.findByEmail('[email protected]');
expect(savedUser.password).not.toBe('rawpassword');
expect(await bcrypt.compare('rawpassword', savedUser.password)).toBe(true);
Implementation details are the "how." Your tests should own the "what" and the "whether." This is the single most impactful principle in building test suites that survive feature development. It's especially relevant in work where data integrity is non-negotiable.
Queue/Event System Testing
Event-driven systems need tests that verify both publication and consumption contracts:
// MockK - testing event publication
@Test
fun `order cancellation publishes correct domain event`() {
val eventBus = mockk<EventBus>(relaxed = true)
val service = OrderService(orderRepo, eventBus)
service.cancel(orderId, reason = "Customer request")
val slot = slot<OrderCancelledEvent>()
verify { eventBus.publish(capture(slot)) }
with(slot.captured) {
assertThat(this.orderId).isEqualTo(orderId)
assertThat(this.reason).isEqualTo("Customer request")
assertThat(this.cancelledAt).isNotNull()
}
}
For event consumption, test the handler directly with real event objects - don't mock the event itself:
// Test the handler logic, not the subscription mechanism
test('order.cancelled handler cancels related shipments', async () => {
const shipmentService = { cancelShipment: jest.fn().mockResolvedValue(undefined) };
const handler = new OrderCancelledHandler(shipmentService);
// Use a real event object - test the handler contract
await handler.handle(new OrderCancelledEvent({
orderId: 'ord-123',
shipmentId: 'ship-456',
cancelledAt: new Date()
}));
expect(shipmentService.cancelShipment).toHaveBeenCalledWith('ship-456');
});
Microservice Communication
For in production-grade systems.
The goal of all of this the test doubles, the behavior verification, the anti-pattern avoidance, is a test suite that gives you genuine confidence to ship. One that catches real bugs, not phantom ones. One that stays green when you refactor and turns red when you break something. That's what the craft is for.
SOCIAL SHARE CARD GENERATOR