Mockito is a widely used library for testing Java applications — that much is undeniable. It's become so popular that the most common frameworks now include annotations and helpers to easily integrate it, avoiding the boilerplate code once required when the library first emerged.
The practice of “mocking” classes using this library is so deeply ingrained in development teams’ culture that, quite often, I find myself having to provide long explanations about my usual practice of not using mocks in my tests and not including Mockito in my projects (or at least not using it by default, only in very specific scenarios).
Throughout my career, the arguments I’ve received in favor of using this library in development tests tend to revolve around two main points:
- "You’re not doing unit testing unless you use Mockito" (i.e., mocks).
- "If you don’t use Mockito, you’re doing black-box testing, not white-box testing."
In this article, I aim to share my perspective on the use of mocks in general when it comes to writing development tests. I won’t delve into the distinctions between unit, integration, black-box, white-box, or end-to-end testing — those are technical considerations that are certainly useful when planning a proper testing strategy, but they’re not the core reason why development teams should include automated tests for their software.
Let’s talk fundamentals
After many years working on various projects, I've identified a set of guiding principles that shape how I approach any development process. I'm not trying to propose a definitive testing manifesto — these are simply the working principles I encourage within the teams I collaborate with. These fundamentals can be summarized in the following points:
- A developer is responsible for providing a reliable set of tests that demonstrate their code behaves as intended. It’s part of their job.
- The best way to spot functional and structural flaws in code is to actually use it.
- A developer should be the first person to use their code. Tests serve as evidence that they understand what their code does and how it’s used (this may sound obvious, but it’s not always the case).
- Test functionalities, not implementations.
- Having a system that ensures our code’s functionality is essential for continuous improvement (whether in terms of efficiency, structure, flexibility, or anything else).
This all may sound simple — and with most people I’ve shared these ideas with, there’s general agreement. We might disagree on where the line is between the developer and QA roles, or when it’s important to test specific implementation details, but overall, there’s alignment.
The real differences emerge when we get to implementation. Because, as they say, the devil is in the details.
Show me the code
To highlight some of the shortcomings in functional tests that rely on mocks, let’s look at a simple interface that defines a specific functionality:
public interface CarStorage {
void allocate(Car car);
Car fetch(UUID id);
}
This interface is deliberately undocumented to support the argument I intend to make. A typical test for an implementation of this interface might look like the following:
@ExtendWith(MockitoExtension.class)
class CarStorageImplTest {
@Mock
private StoreRepository storeRepository;
private CarStorage carStorage;
@BeforeEach
void setUp() {
carStorage = new CarStorageImpl(storeRepository);
}
@Test
void fetch_shouldReturnCar_whenCarExists() {
// Arrange
UUID carId = UUID.randomUUID();
Car expectedCar = new Car("Toyota", "Corolla");
when(storeRepository.find(carId)).thenReturn(expectedCar);
// Act
Car actualCar = carStorage.fetch(carId);
// Assert
assertEquals(expectedCar, actualCar);
verify(storeRepository, times(1)).find(carId);
}
}
So far, so good. This is what most development teams consider a unit test. However, it falls far short of meeting the fundamentals outlined earlier. Let’s put some points on the table.
To execute the fetch operation, an id is required (cardId in the example). Where does this id come from? If I just joined the project, this test offers no information about how the API is used, and we shouldn't assume that the developer knows either.
This test is not really using the code (Fundamental 2) nor does it prove that the person who wrote it understands how it works (Fundamental 3) because, in fact, it doesn't “use” the code at all. It simply manipulates the execution context of the implementation to ensure that assertions pass.
Now, suppose we need to change our component. It no longer accesses a database but integrates with another, potentially complex, data source that changes the internal structure of the implementation. What would we have to do then? Change the test and the mocks created.
Why do I need to change a test if the functionality it’s meant to validate hasn’t changed? Because the test is stapled to the implementation, not to the functionality — violating Fundamental 4.
How can I guarantee functionality when I'm changing both the implementation and the tests at the same time? You’d need a prayer book beside the keyboard and a lot of faith. In other words, we’re not meeting Fundamental 5 either.
In my view, this test system cannot be considered reliable (Fundamental 1) because it’s subject to all the above uncertainties. Or at least, it’s not reliable enough to be a useful tool for the development team in one of our most fundamental tasks during development: reducing complexity and volume of code through refactoring.
Let’s Try an Alternative
Let’s try writing a test in the following way:
class CarStorageTest {
@Inject
private CarStorage carStorage;
@BeforeEach
void setUp() {
removeAll();
}
@Test
@DisplayName("An allocated car can be retrieved through fetch operation.")
void fetch_shouldReturnCar_whenCarExists() {
// Arrange;
Car expectedCar = new Car("Toyota", "Corolla");
UUID carId = carStorage.allocate(expectedCar);
// Act
Car actualCar = carStorage.fetch(carId);
// Assert
assertEquals(expectedCar, actualCar);
}
}
In this type of test, the use of the API is much clearer. For the implementation of the test, we rely on the project’s dependency injection system (Spring, Quarkus CDI, or whichever one you're using).
Right away, we can spot a structural issue with the API. The allocate operation, as currently defined, doesn’t return the cardId (which violates Fundamental 2). While this may seem trivial in this example, you could encounter situations where the processing and persistence logic of certain data is incompatible with the retrieval and validation logic, and yet none of your mocked tests fail (as happened to me in a project).
On the other hand, it's easy to see the usage scenarios of the API regarding the fetch operation (Fundamentals 2 and 3). This is especially helpful when new members join the team. If someone needs to use your component, you can simply say: "Check the test cases, the usage scenarios are there." This saves long onboarding sessions and explanations — just show the code.
In the case of needing to redesign or refactor the implementation, this test remains unchanged. There’s nothing in it that's tied to the specific implementation, and it can be used to guarantee that the changes made do not break functionality (Fundamentals 4 and 5). This becomes particularly clear when technical details inside the app are modified, and we don’t have to rewrite a ton of tests — avoiding both frustration and risk of error.
What to Use When We Don't Use Mockito
Once you start implementing tests without using mocks, the question always arises: how do you build the implementation to be tested so that it works properly? The best answer is to rely on the resources already available in the project.
Design Software to Be Testable
Often, our own designs force unnecessary couplings between classes or modules.
Using design patterns (like strategy or template method), as well as functional programming resources like functions, suppliers, consumers, etc., can decouple business logic from data sources and consumers. This not only makes the logic more flexible and reusable, but also allows us to create implementations of these dependencies for testing — without needing mocks.
Using Stubs
There's no rule that says we must use a specific implementation for a repository, external system, or any other dependency. We can easily use our own stub implementation that emulates those dependencies. Once the stub is injected, the application code continues to function normally. I often create stubs that mimic repositories or external systems by persisting data in memory or files.
Any dependency injection system should allow us to inject our custom stub implementations for testing. This way, we don’t need to include any code in the test to manipulate parts of the application as we do with mocks.
Using Testcontainers
Another viable option is to use testcontainers. Today, hardware and technology have improved enough to support the use of Docker and testcontainers — spinning up containers for databases or MockServer.
Yes, this may slow down unit tests, but in my case, it's a worthwhile trade-off to decouple unit tests from the current implementation of the application under development.
Leverage the Event Bus
When your application architecture uses an event bus (such as Vert.x or Quarkus), the different units of computation (verticles in Vert.x) are already decoupled by design.
An event bus is a system where you can interact with application logic without knowing its internal structure. The only thing you need to know is the message format and the expected response. So, nothing stops us from building stub implementations connected to bus addresses, allowing the software under test to interact with them instead of the real code.
Is Mockito Actually Useful?
That really depends on whether the previously discussed fundamentals are treated as guiding principles or as dogma. If it’s the latter, the answer is no — and there’s no debate.
However, it’s always better to be pragmatic and take advantage of the tools we have. In some situations, it’s absolutely valid to use Mockito for its simplicity and accept that we’re bending some fundamentals. In particular, I use it in the following scenarios.
Exception Handling Tests
Implementing exception throwing from stubs can become quite complex when testing behavior in specific error conditions.
Adding features to configure exceptions in a stub and methods to control them is cumbersome. Sure, you’re not tying your code to a mock — but you are tying it to a stub, which offers no real benefit.
I typically use mocks for this kind of test. It’s simple, powerful, and effective.
Enforcing Optimized Implementations
Often, we include optimized services or adapters designed for specific business processes.
In those cases, we don’t want future refactors to bypass the optimized code. We want to enforce that any implementation continues using our optimized operations.
For this, it's helpful to use a Spy to verify that specific methods are being called — and how many times — to guarantee expected performance.
This is also useful when we want to limit the number of calls to the database adapter or web service during business process execution.
Conclusion
This post aims to be a proposal for a set of fundamentals to guide testing practices among developers. These principles are meant to ensure not only that the code is reliable but also that it becomes a tool for conveying knowledge on how to use the code properly — and for enabling the necessary refactors during development.
With this approach, the widespread use of Mockito in development tests should be avoided, since there are design alternatives and technologies that allow us to create tests that better align with these goals.
That said, Mockito remains very useful in specific contexts where writing custom support code for testing would be too complex or costly.
Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.
Tell us what you think.