Unit tests are great at verifying business logic, but it’s not enough to check that logic in a vacuum. You have to validate how different parts of it integrate with each other and external systems: the database, the message bus, and so on." – Khorikov, 2020.
A unit test is a test that meets these criteria (from the previous chapter):
- Verifies a single unit of behaviour (typically a class),
- Does it quickly, and
- Does this in isolation from other dependencies and other tests.
An integration test is a test that fails to meet one or more of these criteria. In other words, if you determine that you need to test something outside of the scope of a unit test, it’s considered an integration test (typically because it’s integrating behaviours from multiple components). Performance tests, system tests, etc. are all kinds of integration tests.
There are a lot of different nuanced tests that are used in computer science, but we’ll focus on building generic integration tests.
Typically an integration test is one where you leave in selected dependencies so that you can test the combination of classes together. Integration tests are also suitable in cases where it is difficult to completely remove a dependency. This can happen with some critical, external dependencies like an external database.
This diagram demonstrates how unit tests primarily test the domain model, or business logic classes. Integration tests focus on the point where these business logic classes interact with external systems or dependencies.
Note that in this diagram, we’re also identifying code that we shouldn’t bother testing. Trivial code is low complexity, and typically has no dependencies or external impact so it doesn’t require extensive testing. Overcomplicated code likely has so many dependencies that it’s nearly impossible to test - and it should likely be refactored into something similer and more manageable before you attempt to add tests to it.
How many tests?
When discussing unit tests, we suggested that you should focus on core classes and their behaviours. This is reasonable for unit tests.
We can expand this to suggest that you should “check as many of the business scenario’s edge cases as possible with unit tests; use integration tests to cover one happy path, as well as any edge cases that can’t be covered by unit tests.”
A “happy path” in testing is a successful execution of some functionality. In other words, once your unit tests are done, you should write an integration tests that exercises the functionality that a customer would likely exercise if they were using your software with common features and a common workflow. Focus on that first and only add more integration tests once you have the main execution path identified and tested.
The primary purpose of integration tests is to exercise dependencies, so that should be your main goal. Your main integration test (the “happy path test”) should exercise all external dependencies (libraries, database etc). If it cannot satisfy this requirement, add more integration tests to satisfy this constraint.
Here’s some guidelines for creating integration tests.
- Make domain model boundaries explicit.
Try to always have an explicit, well-known place for the domain model in your code base. The domain model is the collection of domain knowledge about the problem your project is meant to solve. This can be a separate class, or even a package reserved for the model.
- Reduce the number of layers of abstraction
“All problems in computer science can be solved by another layer of indirection, except for the problem of too many layers of indirection.” – David J. Wheeler
Try to have as few layers of indirection as possible. In most backend systems, you can get away with just three: the domain model, application services layer (controllers), and infrastructure layer. Having excessive layers of abstraction can lead to bloated and confusing code.
- Eliminate circular dependencies
Try to keep dependencies flowing in a single direction. i.e. don’t have class A call into class B, and class B call back into class A with results. Circular dependencies like this create a huge cognitive load for the person reading the code, and make testing much more difficult.