Test Doubles and Mocking Concepts in Java

A test double stands in for a real dependency so a unit can be tested in isolation, quickly and predictably.

Why a double is needed

public class NotePublisher {

    private final NoteRepository repository;
    private final EmailSender emails;

    public NotePublisher(NoteRepository repository, EmailSender emails) {
        this.repository = repository;
        this.emails = emails;
    }

    public Note publish(long id) {
        Note note = repository.findById(id)
                .orElseThrow(() -> new NoSuchElementException("No note " + id));

        if (note.title().isBlank()) {
            throw new IllegalStateException("A published note needs a title");
        }

        Note published = repository.save(note.published());
        emails.send(note.authorEmail(), "Your note is live");
        return published;
    }
}

Testing publish against a real database and a real mail server would be slow, would need infrastructure, and would send actual email. A test double replaces each dependency with something controllable.

The five kinds

KindPurpose
DummyFills a parameter and is never used
StubReturns fixed answers so the test can proceed
FakeA working but simplified implementation, such as an in memory store
SpyA real object that also records how it was used
MockProgrammed with expectations, and verified afterwards

All five are commonly called "mocks" in conversation. The distinctions matter mainly when deciding whether a test should check a result or an interaction.

A hand written stub

class StubNoteRepository implements NoteRepository {

    private final Note note;

    StubNoteRepository(Note note) {
        this.note = note;
    }

    @Override public Optional<Note> findById(long id) { return Optional.ofNullable(note); }
    @Override public Note save(Note note) { return note; }
}

A hand written fake

class InMemoryNoteRepository implements NoteRepository {

    private final Map<Long, Note> storage = new LinkedHashMap<>();
    private long nextId = 1;

    @Override
    public Optional<Note> findById(long id) {
        return Optional.ofNullable(storage.get(id));
    }

    @Override
    public Note save(Note note) {
        long id = note.id() == 0 ? nextId++ : note.id();
        Note stored = note.withId(id);
        storage.put(id, stored);
        return stored;
    }
}
A fake is often the best double. It behaves like the real thing, is reusable across many tests, and does not encode assumptions about how the code under test calls it. Written once, it makes dozens of tests simple.

A hand written spy

class RecordingEmailSender implements EmailSender {

    private final List<String> sent = new ArrayList<>();

    @Override
    public void send(String to, String subject) {
        sent.add(to + ": " + subject);
    }

    List<String> sent() {
        return List.copyOf(sent);
    }
}

The test

class NotePublisherTest {

    private InMemoryNoteRepository repository;
    private RecordingEmailSender emails;
    private NotePublisher publisher;

    @BeforeEach
    void setUp() {
        repository = new InMemoryNoteRepository();
        emails = new RecordingEmailSender();
        publisher = new NotePublisher(repository, emails);
    }

    @Test
    void publishesADraftAndNotifiesTheAuthor() {
        Note draft = repository.save(
                new Note(0, "Java testing", "meera@example.com", Status.DRAFT));

        Note published = publisher.publish(draft.id());

        assertEquals(Status.PUBLISHED, published.status());
        assertEquals(1, emails.sent().size());
        assertTrue(emails.sent().get(0).startsWith("meera@example.com"));
    }

    @Test
    void refusesToPublishWithoutATitle() {
        Note draft = repository.save(
                new Note(0, "  ", "meera@example.com", Status.DRAFT));

        assertThrows(IllegalStateException.class, () -> publisher.publish(draft.id()));
        assertTrue(emails.sent().isEmpty());       // nothing was sent
    }
}

The second test asserts something that did not happen, which is often the more valuable assertion.

Mocking libraries

A mocking library generates doubles at runtime, usually with a dynamic proxy, so a fake need not be written by hand. The shape is broadly the same across libraries:

create a double for an interface
program it:   when findById(1) is called, return this note
run the code under test
verify:       send(...) was called exactly once with these arguments

These libraries are third party rather than part of Java, so keeping the mention brief here is deliberate. The concepts above are what transfer between them.

State verification against interaction verification

// State: assert on the result
assertEquals(Status.PUBLISHED, published.status());

// Interaction: assert on how a collaborator was used
assertEquals(1, emails.sent().size());
Prefer state whenPrefer interaction when
The method returns a valueThe whole point is a side effect
The result is observableSending, publishing or logging is the behaviour
You want refactoring freedomThe call itself is the requirement

Interaction tests are more brittle: they encode how the code works, so an internal change breaks them even when behaviour is unchanged. Use them where the interaction genuinely is the behaviour.

What not to double

  • Value objects and records. Just construct a real one.
  • The standard library. Use a real List, not a mocked one.
  • The class under test itself.
  • Everything, out of habit. If a real object is fast and deterministic, use it.
// Pointless
Note note = mockOf(Note.class);
when(note.title()).thenReturn("Java");

// Simply better
Note note = new Note(1, "Java", "meera@example.com", Status.DRAFT);

Doubling a clock and randomness

Clock fixed = Clock.fixed(Instant.parse("2026-08-21T09:00:00Z"), ZoneOffset.UTC);
ReportService service = new ReportService(repository, fixed);

// Randomness: inject the source rather than calling Math.random directly
Random predictable = new Random(42);        // a fixed seed
Shuffler shuffler = new Shuffler(predictable);

Time and randomness are dependencies like any other. Injecting them turns an unpredictable test into a deterministic one.

Design pressure is a signal

When a test needs six doubles, the class has six collaborators, and that is worth noticing. Difficult tests usually indicate a design problem rather than a testing problem: too many responsibilities, hidden dependencies, or state constructed internally.

Common mistakes

  • Mocking so much that the test only checks the mocks.
  • Asserting on every interaction, so any refactor breaks the suite.
  • Mocking value objects instead of constructing them.
  • Doubles that drift from the real implementation and hide a genuine failure.
  • Doubling a type you do not own, which encodes assumptions about someone else API.
  • Never testing against the real dependency at any level.

Best practices

  • Prefer a real object; then a fake; then a stub; then a mock.
  • Double the things that are slow, unpredictable or have side effects.
  • Assert on results where you can, and on interactions where the interaction is the point.
  • Keep one reusable in memory fake per repository interface.
  • Inject the clock and any randomness.
  • Back up unit tests with a small number of integration tests against the real thing.

Practice

  1. Write an in memory fake for a repository interface and use it in three tests.
  2. Add a spy that records notifications and assert that none is sent on the failure path.
  3. Explain the difference between a stub and a mock in one sentence each.
  4. Take a class that calls LocalDate.now() internally and make it testable.
  5. Find a test of your own that asserts on interactions and decide whether a result assertion would be better.

Conclusion

A test double replaces a slow or unpredictable dependency so a unit can be tested in isolation. Prefer real objects and in memory fakes, verify results rather than interactions where you can, and treat a hard to test class as a design signal.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Introduction to Java

Java is a statically typed, object oriented language that compiles to bytecode and runs on a virtual machine, which is what makes it portable.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.