In the previous part, we focused on how to write tests that are simply pleasant to read. But readability is only half the battle. You can have the most beautifully written Given-When-Then section that… checks absolutely nothing.
Today we will talk about trust in our tests. Because the worst kind of test is one that gives you a sense of safety, even though the code underneath does something completely different from what the test suggests.
1. The trap of testing implementation (White Box)
I have noticed that in many projects Mockito is added to tests “automatically”. We generate a test class, mock all dependencies, and done. Few people ask themselves then: why am I actually using this mock?
Imagine a simple service for updating user data.
☹️ Sad code:
@Test
void shouldUpdateUserName() {
// given
var userId = 1L;
var user = new User(userId, "Jan");
// We have to "feed" the mock so the test can even start
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// when
userService.updateName(userId, "Jan Kowalski");
// then
// We only check a technical method call.
// Do we know whether the name was actually changed in the object before saving?
// This test will say "YES" even if the service sends old data to save().
verify(userRepository).save(any(User.class));
}
This test lies to you. It only checks whether the save method was called. If a developer mixes up fields and assigns
the new value to a completely different field in production code, or skips the assignment entirely, this test will still
be green. Instead of testing business behavior, which is changing the name, you test a technical library call.
Okay, but someone may notice that we can still verify the object state and try to use ArgumentCaptor for update logic.
☹️ Even sadder code:
@Test
void shouldUpdateUserName_CaptorVersion() {
// given
var userId = 1L;
var existingUser = new User(userId, "Jan");
var userCaptor = ArgumentCaptor.forClass(User.class);
when(userRepository.findById(userId)).thenReturn(Optional.of(existingUser));
// when
userService.updateName(userId, "Jan Kowalski");
// then
// This is where the trouble begins. We expose implementation details.
verify(userRepository).save(userCaptor.capture());
var savedUser = userCaptor.getValue();
assertThat(savedUser.getName()).isEqualTo("Jan Kowalski");
}
So, success? Not exactly. We have just entered White Box Testing mode. Tests become fragile (Fragile tests) because:
- Refactoring becomes painful: change
save()tosaveAll()and the test blows up, even though the business logic still works. - You test “how”, not “what”: you care whether a specific line of code was called, not what the result is for the user.
- Sonar lies: reports show line coverage, but you did not test those lines — you only executed them in an artificial environment.
Solution: In-Memory implementation
Instead of fighting Mockito, let’s treat the service as a black box. We need something that pretends to be a database but
works in memory. A ConcurrentHashMap under the repository is the simplest approach.
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> db = new ConcurrentHashMap<>();
@Override
public User save(User user) {
db.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(db.get(id));
}
public void clear() {
db.clear();
}
}
A state-based test could look like this. Now the test does not need any verify. We simply execute the action and check
whether the state in the “database” is correct.
class UserServiceTest {
private final InMemoryUserRepository userRepository = new InMemoryUserRepository();
private final UserService userService = new UserService(userRepository);
@BeforeEach
void setup() {
userRepository.clear();
}
@Test
void shouldUpdateUserName() {
// given
userRepository.save(new User(1L, "Jan"));
// when
userService.updateName(1L, "Jan Kowalski");
// then
var updatedUser = userRepository.findById(1L).orElseThrow();
assertThat(updatedUser.getName()).isEqualTo("Jan Kowalski");
}
}
What about DSL?
Remember the first part? We can use those patterns to prepare the initial state even more cleanly. Instead of manually
calling userRepository.save() in the given section, we will use our “ability”.
Happy code:
@Test
void shouldUpdateUserName() {
// given
thereIsAUser(anUser().withId(1L).withName("Jan").build());
// when
userService.updateName(1L, "Jan Kowalski");
// then
var updatedUser = userRepository.findById(1L).orElseThrow();
assertThat(updatedUser.getName()).isEqualTo("Jan Kowalski");
}
Is this still White Box? Someone might say: “Wait, but in the assertion you call the repository!”. No. The difference is fundamental:
- In Mockito (Interaction): You ask, “Did you call the save method?”. If a developer changes the way the save works, the test fails.
- In-Memory (State): You ask, “System, no matter how you did it, does this user have a new name?”.
In the Black Box approach, we treat the Service + InMemoryRepo pair as one black box. We do not care how many times the
service “talked” to the repository. We care about the final effect.
How it could look in the end
You may wonder: where does Ability get the repository from and is it definitely the same instance that the service
uses? This is the key point. For this to work, we need one source of truth.
The best way is to use interfaces with default methods.
public interface UserAbility {
UserRepository userRepository(); // Provider method
default void thereIsAUser(UserBuilder user) {
userRepository().save(user);
}
}
// builder in another package, for example com.ourdomain.testing.dsl.builders
public class UserBuilder {
private Long id = 1L; // Default ID
private String name = "Jan"; // Default name
// ... other fields
public static UserBuilder anUser() {
return new UserBuilder();
}
public UserBuilder withId(Long id) {
this.id = id;
return this;
}
public UserBuilder withName(String name) {
this.name = name;
return this;
}
public User build() {
return new User(id, name);
}
}
Let’s create a base class for unit tests to hide technical implementation details of our DSL.
public abstract class BaseUnitTest implements UserAbility {
// One shared instance for the service, assertions, and all Abilities
protected final InMemoryUserRepository userRepository = new InMemoryUserRepository();
@Override
public UserRepository userRepository() {
return userRepository;
}
@BeforeEach
void clearDatabase() {
userRepository.clear(); // Important for isolating tests: clean in-memory database state before each test
}
}
Thanks to this, your test class simply extends BaseUnitTest and can use our In-Memory implementation. Here is what the
final test class looks like. Notice how little technical noise is left. We focus only on business behavior.
class UserServiceTest extends BaseUnitTest {
// We inject the same repository that lives in BaseUnitTest
private final UserService userService = new UserService(userRepository);
@Test
void shouldUpdateUserName() {
// given
thereIsAUser(anUser().withId(1L).withName("Jan"));
// when
userService.updateName(1L, "Jan Kowalski");
// then
var updatedUser = userRepository.findById(1L).orElseThrow();
assertThat(updatedUser.getName()).isEqualTo("Jan Kowalski");
}
}
Summary
By combining In-Memory, Ability and a Base Class, our BaseUnitTest, we stop fighting the tools and start supporting the
process of delivering value. We managed to achieve three key goals:
- Isolation: Thanks to
@BeforeEachin the base class, each test starts with an empty database. This eliminates errors caused by data leaking between tests, which is a nightmare in large test suites. - One source of truth:
thereIsAUser(Given),userService.updateName(When), and the assertion (Then) all operate on the sameInMemoryUserRepositoryinstance. You do not have to configure anything manually — what you save in Given is physically available in When and verifiable in Then. - Easier debugging: You will feel the biggest difference when a test… fails. In the Mockito world, you often end up
with an enigmatic “Wanted but not invoked” message. Here, instead of debugging the depths of the framework, you simply
put a breakpoint in the
updateNamemethod and step into it.
What is next?
We now have unit tests that run fast and do not lie. In the next part, we will deal with integration tests.
You will learn:
- How not to fall into the trap of unnecessarily reloading the Spring context and why the
@DirtiesContextannotation is probably your enemy rather than your friend. - Why
@SpyBeanis an invitation to trouble and how to avoid it. - How to make
Testcontainerswork so that integration tests are almost as pleasant and stable as today’s unit tests.