technical skills - testing without mocks - foundational patterns
Narrow Tests
Broad tests, such as end-to-end tests, tend to be slow and brittle. They’re complicated to read and write, often fail randomly, and take a long time to run. Therefore:
Instead of using broad tests, use narrow tests. Narrow tests check a specific function or behavior, not the system as a whole. Unit tests are a common type of narrow tests.
When testing infrastructure, use Narrow Integration Tests. When testing pure logic, use the Logic Patterns. When testing code that has infrastructure dependencies, use Nullables.
To ensure your code works as a whole, use State-Based Tests and Overlapping Sociable Tests.
State-Based Tests
Mocks and spies result in “interaction-based” tests that check how the code under test uses its dependencies. However, they can be hard to read, and they tend to “lock in” your dependencies, which makes structural refactorings difficult. Therefore:
Use state-based tests instead of interaction-based tests. A state-based test checks the output or state of the code under test, without any awareness of its implementation. For example, given the following production code:
A state-based test would pass in a date and check the result, like this:
In contrast, an interaction-based test would check how each dependency was used, like this:
State-based tests naturally result in Overlapping Sociable Tests. To use state-based tests on code with infrastructure dependencies, use the Nullability Patterns.
Overlapping Sociable Tests
Tests using mocks and other test doubles isolate the code under test by replacing its dependencies. This requires broad tests to confirm that the system works as a whole, but we don’t want to use broad tests. Therefore:
When testing the interactions between an object and its dependencies, use the code under test’s real dependencies. Don’t test the dependencies’ behavior, but do test that the code under test uses its dependencies correctly. This happens naturally when using State-Based Tests.
For example, the following test checks that describeMoonPhase
uses its Moon
and format
dependencies correctly. If they don’t work the way describeMoonPhase
thinks they do, the test will fail.
Write Narrow Tests that are focused on the behavior of the code under test, not the behavior of its dependencies. Each dependency should have its own thorough set of Narrow Tests. For example, don’t test all phases of the moon in your describeMoonPhase()
tests, but do test them in your Moon
tests. Similarly, don’t check the intricacies of date formatting in your describeMoonPhase
tests, but do test them in your format(date)
tests.
In addition to checking how your code uses its dependencies, sociable tests also protect you against future breaking changes. Each test overlaps with dependencies’ tests and dependents’ tests, creating a strong linked chain of tests. This gives you the coverage of broad tests without their speed and reliability problems.
For example, imagine the dependency chain LoginController
→ Auth0Client
→ HttpClient
:
- The
LoginController
tests checks thatLoginController
is correct, including how it usesAuth0Client
. (Auth0Client
in turn runsHttpClient
, but that isn’t explicitly checked by theLoginController
tests.) - The
Auth0Client
tests check thatAuth0Client
is correct, including how it usesHttpClient
. - The
HttpClient
tests check thatHttpClient
is correct, including using Narrow Integration Tests to check how it communicates with HTTP servers. - Together, they ensure the whole chain is checked. Even if
HttpClient
and its tests are changed intentionally, if that change breaksAuth0Client
, its tests would fail (and possibly theLoginController
tests, too). ChangingAuth0Client
’s behavior would similarly break theLoginController
tests.
In contrast, if the LoginController
tests stubbed or mocked out Auth0Client
, the chain would be broken. Changing Auth0Client
’s behavior would not break the LoginController
tests, because nothing would check how LoginController
used the real Auth0Client
.
To avoid manually constructing the entire dependency chain, use Parameterless Instantiation with Zero-Impact Instantiation. To isolate tests from changes in dependencies’ behavior, use Collaborator-Based Isolation. To prevent your tests from interacting with external systems and state, use Nullables. To catch breaking changes in external systems, use Paranoic Telemetry. For a safety net, use Smoke Tests.
Smoke Tests
Overlapping Sociable Tests are supposed to cover your entire system. But nobody’s perfect, and mistakes happen. Therefore:
Write one or two end-to-end tests that make sure your code starts up and runs a common workflow. For example, if you’re coding a web site, check that you can get an important page.
Don’t rely on smoke tests to catch errors. Your real test suite should consist of Narrow, Sociable tests. If the smoke tests catch something the rest of your tests don’t, fill the gap with more narrow tests.
Zero-Impact Instantiation
Overlapping Sociable Tests instantiate their dependencies, which in turn instantiate their dependencies, and so forth. If instantiating this web of dependencies takes too long or causes side effects, the tests could be slow, difficult to set up, or fail unpredictably. Therefore:
Don’t do significant work in constructors. Don’t connect to external systems, start services, or perform long calculations. For code that needs to connect to an external system or start a service, provide a connect()
or start()
method. For code that needs to perform a long calculation, consider lazy initialization. (But even complex calculations aren’t likely to be a problem, so profile before optimizing.)
Parameterless Instantiation
Overlapping Sociable Tests require your whole dependency tree to be instantiated, but multi-level dependency chains are difficult to set up in tests. Dependency injection (DI) frameworks work around the problem, but we don’t want to require such magic. Therefore:
Ensure all classes have a constructor or factory that doesn’t take any parameters. This factory (or constructor) should have sensible defaults that set up everything the object needs, including instantiating its dependencies. You can make these defaults overridable if desired. (If your language doesn’t support overridable defaults, use method overloading or an Options object, as shown in the Signature Shielding pattern.)
For some classes, particularly Value Objects, a parameterless factory isn’t a good idea in production, because people could forget to provide a necessary value. For example, an immutable Address
class should be constructed with its street, city, and so forth. Providing a default city could result in addresses that seemed to work, but actually had the wrong city.
In that case, provide a test-specific factory method with overridable defaults. Choose defaults that make the class work in as many situations as possible, and use a name such as createTestInstance()
to indicate that it’s only for tests. In your tests, pass in every parameter your test cares about, rather than relying on default values. That way, changes to the factory won’t break your tests.
This test-specific factory method is easiest to maintain if it’s located in the production code next to the real constructors. However, if you don’t want test-specific code in production, or if the logic gets complicated, you can use the Object Mother pattern to put it in a test-only helper module instead.
Signature Shielding
As you refactor your application, method signatures will change. If your code is well-designed, this won’t be a problem for production code, because most methods will only be used in a few places. But tests can have many duplicated method and constructor calls. When you change those methods or constructors, you’ll have a lot of busywork to update the tests. Therefore:
Provide helper functions to instantiate classes and call methods. Have these helper functions perform any setup your tests need rather than using your test framework’s before()
or setup()
functions.
Make the helper functions take optional parameters for customizing your setup and execution. If convenient in your programming language, return multiple optional values as well. This will allow you to expand your helper functions without breaking existing tests.
If you’re using a language without support for optional parameters, use method overloading or an “Options” object. If you’re using a language without support for multiple return values, you can return a simple data structure.