Designing an integration test suite

January 12, 2022

integration test suite

In the previous posts, we designed a simplified project with frontend and backend services to later dive into the implementation details and find places for automatic testing. Last time, we focused on unit testing, and today, we will describe functionalities that can benefit from integration tests.

Integration tests should basically validate two or more modules that may be unit-tested independently. They are essential to ensure that two distinct system elements have a consistent output and input data structure and coherent execution logic. The funny thing is that the definition for unit, integration, and system tests is so vague that many tests fall into spaces in between.
For instance, unit tests should verify a single module, but the module itself may be quite small or very complex. That’s why we can discern bigger units and smaller ones. It depends on the module definition. In that case, we can create a limited integration test between two narrow parts of the same module, ensuring a proper cooperation between them, and at the same time, a vast unit test that verifies the full functionality of the whole thing. As a result, the integration is tested inside the unit. Sometimes, strictly categorizing tests is problematic because their category changes depending on the perspective.
On the other end of the spectrum, the boundary between integration and system tests is unclear too. We can design a test that verifies the API and the response data structure. When we see the system as a whole, then the API is only a part of it, and we may check the data from the frontend integration viewpoint. However, when we look at the API service as a separate thing that works independently and may be used by many clients, the same data-structure validation may be considered a reasonable system test. It happens because, from that view, the API is a separate system on its own.

This post will look at different parts of our mocked system, find interesting integrations to verify, and argue why covering them benefits the project. Preferably, you should understand the system design from previous articles.

Detail view

Let’s walk through the project view by view, which should be helpful because some integrations encompass frontend and backend parts.

In the previous post regarding unit tests, we discussed functions generating titles, headers, and meta-data. Now, we know that they work as expected, but what could go wrong is that the data from API may be inadequate. Designing a test with a mocked database, and using original backend logic that serializes the responses, and then passing that further into an actual frontend function rendering proper text may be helpful. We can also validate smaller scope integrations with a mocked API and check the cooperation between the response data structure, potential mapping layer, and the final rendering function.

Validating integration is especially beneficial when we find specific data flow with multiple data modifiers. When we are sure that elements of that flow work well due to unit testing, then our single integration inspection ensures that the whole process is complete. It is like a staple that binds separate pages together.

List View

Imagine we have a nice search form placed on the top of our book-list view, and when we use it, we see an actual list of books related to our query. At the beginning of that list, a header displays a certain number of books found during that search. What can we do to ensure that the value is accurate and updated correctly when the availability of specific books changes?
Well, consider a straightforward integration with a mocked API. Starting with the exact address like /s/charles-dickens/, we use the pathname parser to prepare the API query. Then, the query containing two keywords is used to request the mocked API, giving us a predetermined list as a result. The received data are filtered by component logic and passed down to the header’s function. In this way, we ensure data flow from the parser to the header. We can also change the API response between tests and make sure the number in the header updates accordingly.
A great thing in that sort of validation is that we can pick elements we want to test. Moreover, we can easily skip some of the steps simply because we specifically focus on those that may fall out of sync.

In the previous post, we described a unit test of a function that gets Book and generates a proper Keyword array. It is a vital part of a larger script. The implementation is critical from the administrative perspective and should be triggered whenever we add a new book into our catalog. It is an excellent opportunity to mock the database and the API and verify whether the flow works as desired. Starting with the book ID, we fetch data from the fake API, then generate keywords with the mentioned function, and save them to the fake database. With that test in place, we ensure the script validity and, as a result, guarantee that the search process is safe.

Another example is to test the internal behavior of the listBooks endpoint. With a mocked database, we can change the precision fields of certain Keyword entries and verify whether the search algorithm returns the books in the proper order. Having a simulated database or API is useful because we can validate the behavior, modify mocked data, and easily rerun the setup again. The changing environment is a popular concept among various types of tests.

The interesting idea is that those last two examples, which are, validating the keyword-generator script and verifying the listBooks endpoint, may be considered a unit test in some way. As we mentioned at the beginning of the article, the boundary is not very strict. When we look at the script or the endpoint as a whole, we can argue that from this perspective, checking the complete logic of a given module is only a higher-level unit testing. All in all, it is not so important how we categorize those tests because the vital part is to understand how to design them and what can we gain.

Cart view

The final view is the most complex one. Last time, we discussed two unit tests, validating the update of LocalStorage when a user adds or removes a book from the cart and verifying the overall price of selected titles. How can we affirm a data flow in between, and what do we gain as a result?
It seems like the full process may be complicated. For example, removing a book from the cart should inform a module that handles the change. The module should update the current state and save it to the LocalStore in case the user refreshes a page. The state should be minimal, so it keeps only the book IDs and their related quantities. Each time the state changes, it triggers a rerender of certain elements and provides them with data. Only then does the visual component fetch the data by IDs and pass actual prices further into the summary calculation logic.
That is only an exemplary description, and the actual workflow from the user interaction to the summary price update may be even more demanding. Designing a test where we trigger the state change and observe the result’s adjustment will help us ensure that the code in between works well.

Another helpful test is the backend-frontend integration. When we have a POST endpoint with input validation and responding with various error messages to the client, we can secure the logic on both sides of the HTTP. Let’s consider an integration test that has two steps. First, we trigger the API’s response and test whether it matches our predefined structure. Then, we pass the structure to the frontend error handling logic. That union should safeguard the user experience. We can verify data integration between services picking only vital functions on both ends.
Of course, we may go a step further and use end-to-end testing, but sometimes it is enough to establish a simpler and faster integration test.

Conclusion

We went through our imaginary system to find places where more advanced verification may be beneficial. Integration tests are often useful around areas where we designed unit tests. As mentioned in the post regarding granularity, the lack of unit validation does not prevent us from devising a practical integration assurance. Still, we should remember that having certainty at the level of a single module makes higher-level testing easier and better focused on the logic in between.

I hope this mock project with an imaginary implementation will help you design proper integration testing in your own application. See you next time when we’ll talk about system testing!