Embedded Mocks and Hidden Contracts
Unit Testing: Basic Needs
A Unit test is testing an implementation, often a class or function. The test code needs a couple of elements such as:
- Planning: Know what will be tested and why.
- Setup: Isolated the code, cutting off external dependencies - done via mocking (but there are other techniques).
- Calling the target: Call the implementation, a class method, or a function to be tested.
- Verifications: Perform assertions to check behavior is according to expected.
Another way to see is that the testing code is doing dependency injection (DI). Another inference or concern we can reason about is, who manages and maintains the mocks?
- The engineers? Who wrote the implementation.
- Where does the mock lives? In the implementation project? In a different project, in a library?
- Should we reuse mocks or should we duplicate them?
Regardless if the implementation owns the mocks, the implementation can have empty objects, default objects, and all forms of facilities to make the test code easier and not worry less about mocking. Shipping mocks as part of the implementation code have issues. The drawback of this approach is in runtime or production the mocks will not be necessary, so there will be waste and additional complexity that is not required.
Such drawback can be bypassed if we have 2 binaries, 1 for the code, the other for the mocks, or simply if the implementation uses a better design that makes it easier to test but also reuse objects. Let's say the implementation is a microservice. The implementation uses DI and has interfaces, and concerns are well separated, in this case, we can implement interfaces for the testing and just inject different objects depending if we are in production or during the testing(which also could happen in prod).
Another way to see it is basically eating your own dog food. Code can be reused across tests and tests get a bit more clear, since they just need to do basic configuration and not perform crazy mocks anymore. Removing coupling, adding interfaces and testing is not a problem anymore.
Mocks as part of the Test Code
Let's go to a different approach now. Probably the default approach that our industry does most of the time. Testing project is where the mocks live. Such an approach makes the implementation easier, as it does not need to worry about mocks, however for testing becomes more complicated, what if methods are not easy to be tested?
The main issue we have here is, what if the implementation is a library, now all the teams will do the same testing. Mocking will be fragile because as the library changes the tests will break everywhere. If you have 100 projects doing mocks and testing your library, migrations will be very difficult. As Google wisely put it: Don't mock types you don't own.
The best thing about duplicating mocks code is that we keep them simple. I keep saying mock but we should be using Fakes and Dummies most of the time instead of mocks.
Pushing the Mocks to an internal shared library
When mocks are part of the library code, consumers use library mocks as part of their tests something bigger is happening. Sure you can think that you do not want to test the library but you want to test your own code, however, the library is used all over your code, so you can't test your code without testing the library unless you mock it, no problem the library does that for you.
Mocks can be Hidden Contracts
When you ship mocks as part of your internal shared library, you are coupling your consumers to your mocks, the price will be big during migrations. It is still possible to go with this approach but you need to be aware that you are basically maintaining another contract.
Takeaways
Mocks are expensive techniques and should be avoided, using simple forms of test double is preferred like dummies and fakes. But when we consider internal shared libraries, microservices, and testing, here is some advice to keep in mind:
- Test code should be well-written and easy to understand.
- Avoid coupling to the internal implementation, test behavior, and test the contract.
- Don't share mocks, don't reuse mocks.
- Mocks should live in test projects and not share in internal shared libraries.
- Everything you expose is part of your contract, beware of hidden contracts.
- Don't mock types you don't own (somebody else internal shared library), ask for testing interfaces.
- Make testing interfaces clean and as minimal as possible.
- Testing somebody else library makes migrations slow and painful.
- Mocks make refactoring painful, avoid coupling to the internal implementation.
- Do not test if methods are called (yes, forget spy).
- Good design matters, good testing matters, and good design makes testing easier.
- There is a limit to how much design should bend to make tests easier, don't compromise on information hiding and abstractions just for the sake of testing.
- Hidden contracts make maintenance more expensive and are sources of complexity.
- Reusing mocks is tricky and easily can backfire.
- Have interfaces, so consumers can implement dummies and fakes.
- Think about the tradeoffs: what to expose, how to test, where the code lives, who do it.
Cheers,
Diego Pacheco