Mocking & Dependency Isolation
Replacing real dependencies with test doubles â stubs, mocks, fakes, and spies; state-based vs interaction-based testing; and the discipline of mocking only what you don't own.
Test doubles stand in for real dependencies so a unit test stays fast, isolated, and deterministic. Stubs feed canned data, mocks verify interactions, fakes are lightweight working implementations, and spies record usage. The art isn't the tooling (Moq, NSubstitute, jest.mock) â it's judgment: mock the boundaries you don't control, and use real objects or fakes for your own logic to avoid tests that merely echo the implementation.
A unit test should exercise one unit, fast and deterministically. Real dependencies â databases, HTTP APIs, the system clock, payment gateways â are slow, flaky, or have side effects. Test doubles replace them so the test isolates the unit's own logic and runs in milliseconds.
Stubs return canned values to drive the test. Mocks are pre-programmed with expectations and verify interactions. Fakes are lightweight working implementations (an in-memory repository). Spies record how a real object was used so you can assert afterward. They're all 'test doubles'; the word 'mock' is often used loosely for all of them.
Two testing styles: state-based asserts on the result/state after acting (use stubs and fakes) â robust and refactor-friendly. Interaction-based asserts that the SUT called a collaborator correctly (use mocks/spies) â necessary when the behavior IS the call (e.g. 'sends an email'), but more coupled to implementation.
The guideline: mock across architectural boundaries you don't own or can't control (external APIs, time, randomness, I/O), and use real objects or fakes for your own collaborating code. Over-mocking produces tests that just re-state the implementation and pass even when the logic is wrong.
Key Concepts
Provides canned return values to drive the test. You assert on the SUT's output, not the stub.
Pre-programmed with expectations; the test verifies the SUT called it correctly (args, count).
A real but lightweight working implementation (e.g. in-memory DB) â usable, not production-grade.
Records how a (possibly real) object was used so you can assert invocations afterward.
Assert the resulting state (stubs/fakes) vs assert the calls made (mocks/spies).
Mock external boundaries (HTTP, time, APIs); use real objects/fakes for your own logic.
1// STUB (state-based): canned answer, assert the SUT's output.2var gateway = new Mock<IPaymentGateway>();3gateway.Setup(g => g.Charge(It.IsAny<decimal>())).Returns(new Receipt(true));4var sut = new OrderService(gateway.Object);5sut.Place(order);6order.Status.Should().Be(OrderStatus.Paid); // assert OUTPUT78// MOCK (interaction-based): assert the dependency was called correctly.9var mailer = new Mock<IMailer>();10new OrderService(gateway.Object, mailer.Object).Place(order);11mailer.Verify(m => m.Send(order.Email), Times.Once); // assert INTERACTION1213// jest equivalent:14const charge = jest.fn().mockResolvedValue({ ok: true }); // stub15expect(charge).toHaveBeenCalledWith(99.0); // mock-style verify1617// RULE OF THUMB: mock what you DON'T own or can't control18// (HTTP, payment APIs, the clock). Use real objects / fakes for your own logic.
Mocking is essential for isolating units, and the stub/mock/fake/spy distinction plus 'don't over-mock' is a frequent interview and code-review topic. Used well, doubles make fast deterministic tests; used carelessly, they create brittle tests coupled to implementation that pass even when the code is broken.
Common Pitfalls
1Testing Logic Without Charging Real Cards
OrderService must be tested, but it calls a real payment gateway and sends real emails. Running the tests would hit external services, cost money, be slow, and fail whenever the gateway sandbox is down.
External side effects make the unit untestable in isolation â tests are slow, non-deterministic, and have real-world consequences (charges, emails), so the team avoids writing them.
Inject IPaymentGateway and IMailer interfaces and substitute test doubles: stub the gateway to return a successful receipt (then assert the order is marked Paid), and use a mock to verify the confirmation email was sent exactly once. The logic is fully tested with no external calls.
Takeaway: Mock the boundaries you don't control (payments, email) so you can test your logic deterministically and for free. Inject dependencies as interfaces to create the seams that make this possible.
2Tests That Passed Even When the Code Was Wrong
A service had thorough-looking tests, but a real bug shipped anyway. Investigation showed the tests mocked nearly every collaborator and mostly asserted that mocks were called â they never exercised the real logic.
Over-mocking turned the tests into a mirror of the implementation. They verified 'method A called method B' rather than 'given input X, the output is Y', so they couldn't detect incorrect logic â only structural changes.
Reduce mocking to genuine external boundaries, use real objects/fakes for in-process collaborators, and shift toward state-based assertions on actual outputs. Where interaction matters (an email IS sent), keep a focused mock â but stop mocking your own pure logic.
Takeaway: Over-mocking creates tests that can't fail for the right reasons. Mock external boundaries, prefer real objects and state-based assertions for your own code, so tests verify behavior, not structure.
Join the discussion
Comments
Loading comments...
Cookie preferences
We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.