On November 15, 2025, I had the opportunity to give a practical workshop at Nerdearla España on test doubles. It was an incredible experience to work directly with attendees on practical exercises and share knowledge about testing in a more interactive format.
Below I share the content we worked on in the workshop.
Test Desiderata
The term "Test Desiderata" refers to a set of principles or desirable characteristics for software tests. Although it's not a formal standard, the concept has become popular in the Software Craftsmanship world, with the aim of ensuring tests are effective, maintainable and reliable. We highlight three fundamental principles:
-
Determinism: Test doubles help eliminate randomness and dependence on external factors (such as databases, APIs or external services). This makes tests predictable and reliable, as they will always return the same expected values.
-
Isolated (Micro): The use of test doubles helps tests be smaller and more specific, avoiding external dependencies and ensuring each unit of code is tested in isolation.
-
Specific Behavioral Information: Tests should provide clear information about what failed and why.
Social vs Solitary Tests
-
A social test is one where the behavior of an artifact is tested using its real dependencies, with all the implications this can have (they may be more costly, they may fail due to one of those dependencies, etc). It's a higher-level test.
-
A solitary test is one where the behavior of an artifact is tested using test doubles of its dependencies, so it will have a more isolated, safe and less costly environment.
This doesn't mean all tests should be solitary. Sometimes you'll want to test with real dependencies to see the complete flow of business rules.
Types of Tests
To support us we'll use a diagram from the book "Clean Craftsmanship" by Robert C. Martin, which represents the types of doubles as a hierarchical structure. This was what finally made things click for me, what made all the puzzle pieces fit.
This is Meszaros' terminology, which first appeared in the book "xUnit Test Patterns: Refactoring Test Code" by Gerard Meszaros. I think it's important to know each type of double, because in test libraries they are generally blurry, and many times that's what leads to confusion.
LoginDialog - Base Exercise
To support and explain the different types of doubles, we'll look at a small example exercise. We have this Authenticator interface and the LoginDialog class. That interface represents the contract that the collaborator we pass through the constructor must follow. The goal is to test the behavior of the Login dialog and use the different doubles to run our tests.
interface Authenticator {
boolean authenticate(String username, String password);
}
public class LoginDialog {
private final Authenticator authenticator;
private boolean isOpen = false;
public LoginDialog(Authenticator authenticator) {
this.authenticator = authenticator;
}
public boolean submit(String username, String password) {
if(isOpen) {
close();
return authenticator.authenticate(username, password);
}
return false;
}
// More code...
}Dummies
Let's start with the simplest type of double, the dummy. A dummy is a type of test double used when you need to pass an object to a component under test but the behavior of the double isn't relevant to the test in question.
In this case we want to test that the modal closes when we click cancel, so a dummy fits perfectly. We create a dummy that implements the interface, so we can inject it into our Login — it can return anything, we don't care.
public class AuthenticatorDummy implements Authenticator {
@Override
public boolean authenticate(String username, String password) {
return false;
}
}
@Test
void when_closed_login_is_canceled() {
Authenticator authenticator = new AuthenticatorDummy();
LoginDialog dialog = new LoginDialog(authenticator);
dialog.show();
dialog.close();
assertFalse(dialog.isOpen());
}Whether the user is authenticated is not relevant to testing the modal's behavior.
Stubs
The next step is the stub. A stub is a type of test double that, unlike a dummy, provides predefined responses to calls made to it during the test — they have state or memory.
This allows us to specify the desired result without needing to interact with the real authentication system. For example, if we want to test a failed authentication:
public class AuthenticatorStub implements Authenticator {
private final boolean allowLogin;
public AuthenticatorStub(boolean allowLogin) {
this.allowLogin = allowLogin;
}
@Override
public boolean authenticate(String username, String password) {
return allowLogin;
}
}
@Test
void when_authorizer_rejects_login_fail() {
Authenticator authenticator = new AuthenticatorStub(false);
LoginDialog dialog = new LoginDialog(authenticator);
dialog.show();
boolean success = dialog.submit("bad username", "bad password");
assertFalse(success);
}We create a stub that we pass the desired result through the constructor, and return it in the authenticate method. This way in tests we can programmatically fail authentication, without depending on the real implementation.
Spies
Let's continue with the Spy. A Spy is used to verify if certain actions have been performed on the object, without interrupting the natural flow of the test. In addition to providing predefined responses like a Stub, it also records information about how it's used during tests, so we can ask it questions (data it was called with, how many times it was called, etc).
public class AuthenticatorSpy implements Authenticator {
private final boolean allowLogin;
private int calls = 0;
private String registeredUserName;
private String registeredPassword;
public AuthenticatorSpy(boolean allowLogin) {
this.allowLogin = allowLogin;
}
@Override
public boolean authenticate(String username, String password) {
calls++;
registeredUserName = username;
registeredPassword = password;
return allowLogin;
}
public int calls() { return calls; }
public String registeredUserName() { return registeredUserName; }
public String registeredPassword() { return registeredPassword; }
}
@Test
void login_dialog_correctly_invokes_authenticator() {
AuthenticatorSpy authenticatorSpy = new AuthenticatorSpy(true);
LoginDialog dialog = new LoginDialog(authenticatorSpy);
dialog.show();
boolean success = dialog.submit("user", "pw");
assertTrue(success);
assertEquals(1, authenticatorSpy.calls());
assertEquals("user", authenticatorSpy.registeredUserName());
assertEquals("pw", authenticatorSpy.registeredPassword());
}In this test, we ensure that LoginDialog correctly invokes Authenticator, checking that authenticate is only called once and with the arguments passed in submit.
Strict Mocks
We arrive at the last type on the left side of the diagram, Strict Mocks. A Strict Mock not only simulates the behavior of an object, but also verifies that expected calls are made to its methods with specific parameters, and fails the test if any unexpected call is made or the expected calls don't occur in the defined order.
public class AuthenticatorStrictMock implements Authenticator {
private boolean authenticateCalled = false;
private final String expectedUsername;
private final String expectedPassword;
private final boolean authenticationResult;
public AuthenticatorStrictMock(String expectedUsername, String expectedPassword,
boolean authenticationResult) {
this.expectedUsername = expectedUsername;
this.expectedPassword = expectedPassword;
this.authenticationResult = authenticationResult;
}
@Override
public boolean authenticate(String username, String password) {
if (!expectedUsername.equals(username) || !expectedPassword.equals(password)) {
throw new AssertionError("Authenticator was called with unexpected arguments");
}
if (authenticateCalled) {
throw new AssertionError("Authenticator authenticate method called more than once");
}
authenticateCalled = true;
return authenticationResult;
}
public void verify() {
if (!authenticateCalled) {
throw new AssertionError("Expected authenticate method was not called");
}
}
}The strict mock can help design new code, as it allows defining certain business logic and guides production code design. However, our tests can become fragile if business rules change.
Fake Objects
Finally we move to the right branch of the diagram — the Fake Object. It's an object that simulates the real behavior of the artifact by implementing some business rules in a rudimentary or simplified way, unlike stubs or mocks which generally only simulate responses to specific calls.
public class AuthenticatorFake implements Authenticator {
@Override
public boolean authenticate(String username, String password) {
return username.equals("user") && password.equals("good password");
}
}As we can see, this could perfectly simulate the behavior of the real Authenticator artifact, but with much simpler logic.
Fakes are especially useful in test environments where interacting with the real system would be impractical, costly or slow. The problem is that as the application grows, Fakes tend to grow with each new condition, potentially becoming so large and complex that they need their own tests.
Test Doubles with Libraries
All of this is great — we've seen the different types of test doubles, but generally in our day-to-day work we use test libraries that make development easier.
The example I want to show you is with Mockito, a Java library:
Authenticator authenticator = mock(Authenticator.class);
when(authenticator.authenticate("user", "password")).thenReturn(true);
// Verify calls
verify(authenticator, times(1)).authenticate("user", "password");As we can see, defining a double is as simple as using the mock method and passing it the class we want it to simulate. This allows us to define the response we want for certain input parameters through the when method. We can also verify how many times and with what parameters the authenticate method was called through the verify method.
Resources
You can find the workshop repository on GitHub with all practical exercises.
Additional recommended resources: