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
- The five kinds
- A hand written stub
- A hand written fake
- A hand written spy
- The test
- Mocking libraries
- State verification against interaction verification
- What not to double
- Doubling a clock and randomness
- Design pressure is a signal
- Common mistakes
- Best practices
- Practice
- Conclusion
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
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
| Kind | Purpose |
|---|---|
| Dummy | Fills a parameter and is never used |
| Stub | Returns fixed answers so the test can proceed |
| Fake | A working but simplified implementation, such as an in memory store |
| Spy | A real object that also records how it was used |
| Mock | Programmed 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 argumentsThese 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 when | Prefer interaction when |
|---|---|
| The method returns a value | The whole point is a side effect |
| The result is observable | Sending, publishing or logging is the behaviour |
| You want refactoring freedom | The 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
- Write an in memory fake for a repository interface and use it in three tests.
- Add a spy that records notifications and assert that none is sent on the failure path.
- Explain the difference between a stub and a mock in one sentence each.
- Take a class that calls
LocalDate.now()internally and make it testable. - 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.