Flexible and easy to care for: testing without mocks
The implementation of mocks is associated with compromises. The Nullable design pattern is an alternative.
(Image: Andrii Yalanskyi/Shutterstock.com)
- Martin Grandrath
Robust, automated tests are an integral part of agile software development. As requirements and framework conditions are constantly changing, developers must be able to continuously adapt their architecture. Their code must be able to grow and evolve. They must constantly extend, adapt, rearrange, merge or split existing features. To do this, they need the support of a fast, reliable and robust test suite that does not interfere with existing software functions.
Mock-based tests often cause additional maintenance effort during refactoring, i.e. changes to the code structure that simplify working with the code overall but do not change the behavior of the system. The way in which mocks are usually used in practice leads to a coupling of tests and implementation details. Changes to these details require adjustments to the tests, which is at the expense of development speed.
This article shows the compromises associated with mock-based tests and presents an alternative in the form of James Shore's Nullable design pattern.
Isolated, interaction-based tests
Mock objects, or mocks for short, are a sub-category of test doubles that serve as placeholders for production objects in unit tests. The term test double is based on the stunt double in films. Other types of test doubles are stubs, spies or fakes.
Mocks record how the software interacts with them during a test run: Which of their methods does the application call, in which order and with which arguments? The unit test then verifies whether the observed interactions match the expected ones. In this way, the interactions between the objects become an integral part of the implementation and the tests. This type of test is called interaction-based.
Videos by heise
At the same time, mocks isolate the object under test from its dependencies. This means that only the code of a single object is executed during the test, while all interaction partners are replaced by mocks. Tests that test objects in isolation are called solitary.
Even though solitary interaction-based tests have their advantages and have become the standard over time, they are not free of disadvantages. The fact that tests are linked to the interactions between objects makes refactoring more difficult. However, these are an indispensable tool for maintaining the quality of the code base in the long term.
Refactorings that change the interactions between objects can lead to false positives: Tests fail even though the program as a whole contains no errors. Only the object interactions deviate from the expectations of the tests. A suite of interaction-based tests makes the code base less flexible overall, as the tests fix the implementation details.
In addition, solitary tests may not detect errors if all objects work as expected in isolation, but undesirable behavior occurs in the interaction of the objects. To prevent this, additional integration tests that specifically test the interaction of several objects are required in addition to the unit tests.
Sociable, state-based tests are an alternative.
Real dependencies and visible behavior
In sociable tests, the object to be tested does not interact with test doubles, but with the real dependencies that also exist in production operation. Errors caused by the interaction between the objects are immediately apparent in the test. Separate integration tests are not required.
State-based tests verify the visible behavior of objects and ignore the underlying interactions. These tests therefore react much more robustly to refactorings, as they are only interested in the end result and not in the implementation details.
The elephant in the room
Using the real production objects in the tests instead of replacing them with mocks initially leads to a problem: the code to be tested must communicate with APIs, databases or the file system. These side effects would lead to non-deterministic tests, as they are dependent on the global state, including third-party systems. For example, a test could fail because a third-party API responds with different data than the test expects.
Another problem is the impact that API calls can have. It is not desirable that every execution of the shopping cart tests charges a credit card. In addition, it must be possible to test how a program behaves when a third-party API responds with different formats, with errors or not at all. And finally, the API connection slows down the tests.
Integration tests are necessary for the transition of the system to be implemented with the outside world, but the side effects are undesirable for the tests within the system.