Please read Part I of this article series to get more context on this article.
After writing the first integration test, we saw the advantages of writing such a test, and we wanted to write more. We needed a strategy to identify when to write the integration tests, which classes to mock, and for which classes should we continue writing the unit tests.
Here is the component diagram our typical REST endpoint (as described in part I).
We looked at the relationship between the component under test (CUT) and other components to determine which components should be mocked, which components should have unit tests in place and which components the integration tests are enough.
- Mock the components that are related to the CUT with the “interface dependency”. Since only the interface is visible to CUT, CUT can’t assume any implementation of these components and must write the test keeping just the contract in mind.
- The software components that are related to CUT with the “aggregation relationship” should have unit tests in place. These components’ lifecycle does not depend on the CUT and should be tested independently as well.
- Finally, the software components that are related to CUT with the “composition relationship” need not have any unit tests. Since their existence is dependent on the CUT, there is no need to write a separate test for these components.
- Needless to say, if the same team owns any “interface dependent” components then the team should perform a similar exercise for that component and come up with its test strategy independently.
Here is a graphic to summarize this section:
Following are some recommendation on designing the components/classes which will allow you to write the Integration tests relatively easily.
- Use dependency injection in your classes. The dependencies should be injected in the class rather than new-ing them in code.
- Always code to interface and not to actual implementation. This is especially true for the third-party dependencies in your class.
- With lambda in Java, it is more common that a RuntimeException is thrown from the methods. Document these exceptions either in the throws clause or in Javadoc. This helps in defining the method contract in greater details.
- Wrap the calls to non-unit testable classes or static calls.
Frequently Asked Questions
Q. If there are multiple layers of classes in the call stack, at what level should we start mocking the classes?
Ans: There is no technical limitation to warrant mocking of classes unless they expect a running app.
Typically, we mock the classes in the class hierarchy after two degrees of separation from the class under test. We do it only because constructing the class under test becomes complicated and the test becomes hard to maintain with a lot of dependency injection.
Q. Is it possible to end up with duplicate tests testing the same thing in the unit tests, integration tests and the end-to-end tests?
Ans: Yes, there may be multiple tests testing the same method multiple times. And I am fine with that. Different test types test the methods with a different purpose.
- Unit test is testing the method level contract.
- Integration test is testing how the components integrate.
- End-to-end is testing the functional behavior of the app.
Q. Is it the case that the Integration tests know too much about the implementation details? Are we creating a maintenance nightmare for ourselves?
Ans: If the tests are written using the component relationship diagram in mind then the Integration tests do not know more than it should. It is still a fair concern and that is why focusing on the test pyramic is important. Write more unit tests, fewer integration tests and minimum end-to-end tests.
Integration tests offer some obvious and some non-obvious advantages.
- Performance: Integration tests allow writing a large number of tests without increasing the test execution time. This means, a wider test coverage and running more tests in the CI/CD pipeline. If the end-to-end tests are converted to the integration tests then the performance improvement will be by the factor of 1000s (from seconds to milliseconds).
- Contract Testing: Integration tests allow testing the contract between software components. It can catch the incompatible contract level changes for the classes which are not mocked (for example, a new exception is thrown for an invalid input by the input validator class).
- Edge Cases: Integration tests allow us to automate the hard-to-reproduce edge cases easily as compared to the end-to-end tests. Sometimes, in end-to-end tests, it is either difficult or impossible to set up the scenario to reproduce the edge cases. With a combination of mocked and real classes, it becomes significantly easier to reproduce those edge cases.
Please drop a comment if you end up implementing Integration Tests. I am curious to know if this worked for your use case. I would like to know the challenges that you might have faced in your implementation.