technical skills - testing without mocks - language patterns
Goals
This pattern language was created to satisfy these goals:
- No broad tests required. The test suite consists entirely of “narrow” tests that are focused on specific concepts. Although broad integration tests can be added as a safety net, their failure indicates a gap in the main test suite.
- Easy refactoring. Object interactions are considered implementation to be encapsulated, not behavior to be tested. Although the consequences of object interactions are tested, the specific method calls aren’t. This allows structural refactorings to be made without breaking tests.
- Readable tests. Tests follow a straightforward “arrange, act, assert” structure. They describe the externally-visible behavior of the unit under test, not its implementation. They can act as documentation for the unit under test.
- No magic. Tools that automatically remove busywork, such as dependency-injection frameworks and auto-mocking frameworks, are not required.
- Fast and deterministic. The test suite only executes “slow” code, such as network calls or file system requests, when that behavior is explicitly part of the unit under test. Such tests are organized so they produce the same results on every test run.
Experience has revealed these additional benefits:
- Faster than mocking frameworks. In a head-to-head comparison, tests using these patterns were 2–3 orders of magnitude faster than tests using a mocking framework. (Comparison code here.)
- Simple test setup. Test setup is straightforward and easy to encapsulate in helper methods.
- High reusability. The most complicated code needed for these patterns is also the most generic and reusable.
- In-memory infrastructure testing. High-level infrastructure wrappers, such as a client for a specific web service, can be tested without network calls or complicated setup. (Example test.)
- Edge case support. It’s easy to test complex edge cases, such as error conditions and timeouts. (Example tests.)
- Legacy code compatibility. The patterns are completely compatible with mocks and other test doubles, and can even be used together in the same test. Legacy code can be converted incrementally without impacting existing code.
Tradeoffs
Nothing’s perfect. These are the downsides of using this pattern language:
- Changes to production code. The patterns require you to modify your production code, particularly for infrastructure classes. Although the modifications are usable in production, and have production use cases, many of the changes will only be used by tests.
- Hand-written stub code. Some third-party infrastructure code has to be mimicked with hand-written stub code. It can’t be auto-generated and takes extra time to write. However, the results are highly reusable.
- Multiple test failures. Although tests are written to focus on specific concepts, the units under test execute code in their dependencies. (Jay Fields coined the term “sociable tests” for this behavior.) This can result in multiple tests failing when a bug is introduced.