Unit tests with mocks
To test a car’s dashboard, you don’t need a running engine: you connect the dashboard to a box that sends “speed: 80” and check that the needle points at 80. The dashboard is real; everything around it is simulated.
A unit test does that for one class. The class under test is real; its dependencies (the repository, the payment client) are replaced with mocks: objects you program to return specific answers, and can ask afterwards what they were called with. No database, no network, milliseconds per test.
Unit tests target business logic in services and domain objects, with collaborators at I/O boundaries replaced by test doubles. Prefer state-based assertions on the result; verify interactions only when the interaction is the behavior (a charge was requested, an event was published). Over-mocked tests (mocking value objects, mapping helpers, or the class’s own collaborators in the same module) couple tests to implementation and break on every refactor while catching nothing.
class DiscountServiceTest {
private val orders: OrderRepository = mock() // mockito-kotlin
private val service = DiscountService(orders) // the real class under test
@Test
fun `loyal customers get ten percent`() {
whenever(orders.countByCustomerId("ana")).thenReturn(12L) // stub what it needs
val percent = service.discountFor("ana")
assertThat(percent).isEqualTo(10)
}
@Test
fun `new customers get nothing`() {
whenever(orders.countByCustomerId("ben")).thenReturn(0L)
assertThat(service.discountFor("ben")).isZero()
}
}class DiscountServiceTest {
private final OrderRepository orders = mock(OrderRepository.class);
private final DiscountService service = new DiscountService(orders); // the real class under test
@Test
void loyalCustomersGetTenPercent() {
when(orders.countByCustomerId("ana")).thenReturn(12L); // stub what it needs
int percent = service.discountFor("ana");
assertThat(percent).isEqualTo(10); // AssertJ
}
@Test
void newCustomersGetNothing() {
when(orders.countByCustomerId("ben")).thenReturn(0L);
assertThat(service.discountFor("ben")).isZero();
}
}The service is created with new (no Spring at all), so the test starts instantly. The repository is a mock: when(...).thenReturn(...) decides what it answers. The test then calls the real discountFor and checks its answer. Kotlin developers often use MockK instead of Mockito (every { orders.countByCustomerId("ana") } returns 12); the idea is the same.
Tap the line with the bug.
@Test
fun findsCustomerByEmail() {
val ana = Customer("c-1", "ana@example.com")
whenever(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana))
val result = customers.findByEmail("ana@example.com")
assertThat(result).contains(ana)
}@Test
void findsCustomerByEmail() {
var ana = new Customer("c-1", "ana@example.com");
when(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana));
Optional<Customer> result = customers.findByEmail("ana@example.com");
assertThat(result).contains(ana);
}Hint: Which object does the test call: the code you wrote, or the mock?
The test stubs the mock and then calls the mock itself, so it only checks that Mockito returns what it was told to. It passes whatever the real code does; it would pass if the real code didn’t exist. These tests inflate coverage numbers and give false confidence.
Call the class under test, the one whose logic you want to check:
@Test
fun `login finds customer by email ignoring case`() {
val ana = Customer("c-1", "ana@example.com")
whenever(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana))
val result = loginService.start(" Ana@Example.com ") // the real service
assertThat(result.customerId).isEqualTo("c-1") // it normalized the email
}@Test
void loginFindsCustomerByEmailIgnoringCase() {
var ana = new Customer("c-1", "ana@example.com");
when(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana));
LoginResult result = loginService.start(" Ana@Example.com "); // the real service
assertThat(result.customerId()).isEqualTo("c-1"); // it normalized the email
}And for a repository method itself, a mock can’t tell you anything: test it against a real database (last part).
Practice: when to verify a call
- If the method returns something, assert on the result. That’s usually enough.
- If the method’s job is to cause something elsewhere (charge a card, send an email, publish an event), verify that call:
verify(payments).charge("c-1", 1999). - Check what didn’t happen when that matters:
verifyNoInteractions(payments)after a declined order. - Don’t verify every internal call (
verify(repo).findById(...)): the result already proves it, and the test breaks when the implementation changes for no good reason.
Choosing doubles
- Fakes over mocks for repositories and clients you use in many tests: an in-memory
FakeOrderRepositorywith real behavior (save then find) makes tests readable and refactor-proof. - Don’t mock what you don’t own at its raw API (Jackson, RestClient, JPA
EntityManager): wrap it in your own port and fake the port, or test the adapter for real (WireMock for HTTP, Testcontainers for databases). - Argument matchers and captors (
ArgumentCaptor, MockKslot) for asserting on complex outgoing objects; prefer asserting on fields over fullequals. - Strict stubs (Mockito’s default with
MockitoExtension) fail on unused stubs, which catches tests whose setup no longer matches the code.
- Unit tests: real class under test, doubles at I/O boundaries, state-based assertions; verify interactions only when they are the behavior.
- Prefer fakes for widely used ports; don’t mock third-party APIs directly; WireMock/Testcontainers for adapters.
- Mockito strict stubs (
MockitoExtension),ArgumentCaptor; Kotlin: mockito-kotlin or MockK (every/verify/slot).
Check this part
In a unit test of DiscountService, which object is real?
A test stubs customers.findByEmail and then calls customers.findByEmail. What does it check?
When should a test verify a call on a mock?