technical skills - testing without mocks - logic patterns
src: Logic patterns
Logic code is pure computation. To qualify, code can’t involve external systems or state. That means it can’t talk to a database, communicate across a network, touch the file system, read the date and time, look at environment variables, or use most random number generators. It can’t depend on any code that does these things, either.
Pure computation is easy to test. The following patterns make it even easier.
Easily-Visible Behavior
Logic computation can only be tested by State-Based Tests if the results of the computation are visible to tests. Therefore:
Prefer pure functions where possible. Pure functions’ return values are determined only by their input parameters.
When working with objects, prefer immutable objects, which are the object-oriented equivalent of pure functions. The state of immutable objects is determined when the object is constructed, and never changes afterwards.
For mutable objects, provide a way for changes in state to be observed, either with a getter method or an event.
In all cases, avoid writing code that explicitly depends on (or changes) the state of dependencies more than one level deep. That makes test setup difficult, and it’s a sign of poor design anyway. Instead, design dependencies so they completely encapsulate their next-level-down dependencies.
Testable Libraries
Third-party code doesn’t always have Easily-Visible Behavior. It also tends to introduce breaking API changes with new releases, or simply stop being maintained. Therefore:
Wrap third-party code in code you control. Ensure your application’s use of the third-party code is mediated through your wrapper. Write your wrapper’s API to match the needs of your application, not the third-party code, and add methods as needed to provide Easily-Visible Behavior. (This will typically involve writing getter methods to expose deeply-buried state.) When the third-party code introduces a breaking change, or needs to be replaced, modify the wrapper so no other code is affected.
Frameworks and libraries with sprawling APIs are more difficult to wrap, so prefer libraries that have a narrowly-defined purpose and a simple API.
Some third-party code is pervasive and stable, such as core language frameworks. Other code, such as UI frameworks, can be very costly to wrap. You may be better off not creating a wrapper for these cases.
If the third-party code interfaces with an external system or state, use an Infrastructure Wrapper instead.
Collaborator-Based Isolation
Overlapping Sociable Tests ensure your tests will fail if your code’s behavior changes, no matter how far down the dependency chain those changes may be. On the one hand, this is nice, because you’ll learn when you accidentally break something. On the other hand, this could make feature changes terribly expensive. We don’t want a change in the formatting of addresses to break hundreds of unrelated reports’ tests. Therefore:
When a dependency’s behavior isn’t relevant to the code under test, use the dependency to help define test expectations. For example, if you’re testing a report that includes an address in its header, don’t hardcode “123 Main St.” as your expectation. Instead, ask the address how it would render itself, and use that as part of your test expectation.
Be careful not to write tests that are a copy of the code under test. Collaborator-Based Isolation is for writing Narrow Tests that ignore irrelevant details. For example, in the following code, the test is checking the special case of a report with a single address, not the behavior of address rendering. Address rendering is expected to have its own Narrow Tests.
This provides the best of both worlds: Overlapping Sociable Tests ensure that your application is wired together correctly and Collaborator-Based Isolation allows you to change behavior without breaking a lot of tests. However, it also ties the tests more tightly to the production code’s implementation, so it should be used sparingly.