Difficult Mock Life
What Is Mocking?
Mocking is a popular and widely used approach in unit testing to handle dependencies. However, this method has both an upside and a downside, so it’s important to compare these pros and cons to determine if the costs will outweigh the benefits. Let’s take a closer look at mocking.
Mocking is a great way to isolate tests from external factors, like databases or web services. However, mocking also adds an extra layer of complexity to our codebase that must be carefully considered.
Mocking is also a useful way to replace dependencies with stand-ins in your unit test. The stand-ins are often called “mocks,” and they allow the unit test to run smoothly without invoking the real dependency.
Mocking can be achieved in various ways. The most popular approach is to create a mock object that implements the interface of the dependency, like so:
In the example above, the class mocks a user object and a database connection to satisfy a successful login event when invoked with certain parameters.
Mocks originally generated from “test doubles,” but mocking is more widely known because it became a generic term among developers. A mock stands for the real production code in a unit test and should be able to produce assertions about the manipulations made by the test subject during the test run.
When mocking dependencies, it’s crucial to consider how much time and effort it will take to set up the mocks in tests.
The Problem with Mocking
To put it simply, you basically create a living space for the bugs in your application when you mock dependencies – meaning you override the business logic with your mocked classes where coupling with the test subject may vary.
There are three issues to consider while using mocks in your unit tests:
- The complexity of your architectures can increase.
- Refactoring your code becomes more difficult.
- The likelihood that bugs will be hidden behind the curtain increases.
1. Reduced Simplicity
Mocking can reduce the simplicity of architecture design by coupling units together more tightly and by raising the difficulty level for making changes, which comes with a cost. When the system is harder to change, the design slowly starts to deteriorate – and refactoring becomes a hassle for developers.
Mocked resources add additional coupling between the test and the source code because mocks don’t just change the state or what is returned – they assert how an object behaves toward its collaborators. Additionally, since the internals of how the class interacts with its mocked resources are exposed, tests produce encapsulation. Thus, the test would fail if the interaction between a class and the mocked resources is changed when refactoring.
Moreover, an exception script has to be written in a mock-based unit test. To do that, you need to have a deep knowledge of the interaction between your application and its dependencies, which are generally cloud resources. It’s not enough to have knowledge of the sequence of calls – you also need to know about the data that is both inbound and outbound.
2. Difficult Refactoring
Most of the time, refactoring results in quick solutions when you look at your code from a divide-and-conquer point of view. You can see positive changes in many areas as the code quality increases.
But mocks inhibit refactoring because of the coupling they add between the test and the source code. If you are making an Interaction-Based Test, then coupling will increase between the test and the source code.
In a red-green-refactor cycle, it’s essential to see the green light before attempting refactoring. And it is what we expect to be in the “green” state after the refactoring, too. But if you mock your cloud resources for your unit tests, this expectation may fail because the mocks test both the internal and external behavior of the code.
This always happens to me whenever I decide to use mocks for small jobs. I have a UserSave class, which calls a database cloud resource and a notification service cloud resource. To make things quick and easy in my local environment, I mocked those dependencies as UserDatabase and UserNotification classes.
But then I realized that the design would be simplified if I refactored the application in the latter way, as shown in the image above. The functionality of my classes did not change, but the external behavior completely changed. So after this refactoring, many of the UserSave class unit tests have failed.
When you use mocks, your test code knows the internal requests and responses of the test subject classes. And that’s the reason why mocking prevents refactoring – because it tightly couples the production code with the test code.
3. Bugs Can Easily Hide
A good developer knows that mocking is a double-edged sword: It can be used to save time when testing the logic of your code, but there are some pitfalls that should not be ignored. The problem with mocking is that you are overriding the logic of the mocked class. The real logic gets hidden behind the scenes, which is where bugs just love to live.
Consider this: The mock may have attributes, methods, or arguments that the real object doesn’t have. Also, the return values can be different. Your unit tests run with mocked resources, and you decide the return values you will get.
On the other hand, real cloud resources may return different and/or unexpected values. The mock’s side effects and behavior may differ from those of the real objects. For example, when your real cloud resources raise an exception, the mocks might fail instead.
You can essentially create a mock “jungle” when mocking the interactions between the test subject application and the cloud resources. Mocking all of the classes makes you create mocks that return other mocks. If the data pathway is long and complex, then you have to mock all the way down from end to end.
This creates a perfectly ideal habitat for “cute” little bugs because:
- Mocks must be updated as frequently as the application code changes, therefore adding a maintenance burden to the application. If you don’t update the mocks, then you open the door for bugs.
- Integration-Based Testing makes refactoring difficult because the mocking frameworks introduce tighter couplings between test and source code. This may lead to bugs after refactoring.
- Most of the errors and bugs can be hosted as a result of Integration-Based Testing. Problems arise from coupling introduced by poor implementation in unit tests, such as complex constructors, mocking value objects, etc.
When to Mock
This isn’t to say that mocking is bad or harmful. But it’s best to mock dependencies only when you really have to, not just because you can.
It is a good practice to demarcate groups of objects with mocks using state-based tests internally.
Robert Cecil Martin, colloquially called "Uncle Bob," says in his blog: “Only use a mock (or test double) when testing things that cross the dependency inversion boundaries of the system.”
First of all, if I really need a mock, I write it myself rather than using a mocking framework. I believe the simplicity of the code is only minimally affected by doing this. When I need to write test doubles for large interfaces or third-party libraries, however, then I prefer to use the mocking frameworks.
And also, when I need to mock a resource, I first try to mock at the highest level possible in the class hierarchy diagram. To be more specific, I tend not to use a mock if a spy will get the job done. Similarly, I do not use a spy if a stub will work, and so on. The idea behind this approach is that the lower you go in the class hierarchy of mocks, the more knowledge duplication you create.
We all get frustrated when our unit testing goes beyond the bearable limits in regard to time. When mocking is not used at all, the execution of the test suite can be very slow – it can even take hours. Databases, different kinds of servers, and services run thousands or millions of requests over the network, a process that is considerably slower than computer instructions.
The tests are sensitive to faults in sections that are unrelated to your test subject. Such situations can be seen in many instances. For example, databases that contain extra or missing rows. Modifications can be made to the configuration files. Network timings can flicker due to an abrupt load on the hardware. Memory might be consumed by some other processes. The test suite may require special network connections that are down. The test suite may require a special execution platform, similar to the production system. And this is where we see the benefits of mocking because it can save you from such cases.
Efficiency Vs. Cost
Overall, the effectiveness and the efficiency benefit of mocking are lighter than the maintenance and development cost. My two cents would be to consider thriftily mocking cloud resources.
If you design your application architectures and find ways to test them that do not require mocking, then you should use mocks only for edge cases to test architecturally significant boundaries.
Also, mocking tools might fail in your testing somewhere in the process, so it’s better to depend on them as little as possible. Another note: Writing your own mock classes will help you maintain as much control as possible, while third-party tools will limit your ability to keep control.
Testing > Mocking
Testing your software helps you reduce your costs in multiple aspects. There are various ways to test your software. Using mocked versions of the cloud resources may help you reduce complexity and cost if it is done in the right way.
Overall takeaways on mocking are:
- You don’t have to mock everything.
- Always think about the cost of development and maintenance.
- You should only mock the class that is under your own control.
- Only mock tests’ relevant behaviors.
- Avoid mocking value objects.
- Avoid mocking complex setup or constructors.
- Write your own mocks.
As a final call out, remember that using mocks can bring a cost advantage for testing your applications but this is more like an illusion. Because you can not oversee the cost of potential defects in your application when you deploy it to the cloud environment.
Foresight comes into help at this point. It allows you to develop, test, and serve your application in the real cloud environment with the confidence of error detection. Foresight provides full visibility and deep insights into the health and performance of your tests and CI/CD pipelines. When mocks work in your local but cloud resources don't act the same as mocks, you can just plug Foresight and pinpoint the issues easily.