With over five years of experience in microservices testing, I am now a software testing engineer at EPAM Anywhere. In this tutorial, I’d like to share my take on how to approach microservices testing based on my experience. Let’s dive in!
The main difference between a microservice architecture and a monolithic one is that in the former, each application process functions as a separate loosely-coupled service with its own logic and database. Updating, deploying, testing, and scaling takes place within a single module.
In a monolith architecture, any changes affect the entire application, and the time for debugging and checking them increases. This usually leads to rare updates and an increase in the number of changes released all at once, thereby increasing the risks. There is also hardly any insulation in the monolith. A problem or bug in a module can slow down or break the entire application.
The advantages of a microservice architecture include:
However, a few important limitations of microservices to keep in mind include some risk of communication failure between the services, a relative difficulty in managing them, possible network latency issues, and rather complex testing (as mentioned above).
Since a microservices architecture is a collection of small services, each is responsible for a certain functionality that together represent a finished application. Therefore, the approach to testing such an architecture is different from the usual one.
The main advantage (and difficulty) of testing is that microservices are located on different servers and are written in different programming languages, such as Java, .NET, etc. At the same time, the developers of a particular microservice might not know what other microservices are doing, which complicates the testing process. On the other hand, we can quickly update and test a single microservice without affecting others.
If you are going to test microservices, you should introduce the following types of testing:
The goal of unit testing is to check that each piece of code works correctly. Typically, unit tests are created by developers and automation testers.
These tests help to find problems in certain parts of the code, such as the function that generates a file name, for example. They are small, isolated, and can check any single part of your service down to a line of code.
A good rule of thumb is: if you write code, write a test. I have seen a lot of projects where unit tests were written according to the “new code, new test” principle. I think this is the right approach, because when you add something new to the program, it often breaks. Also, if you write tests right away, you won’t have to turn over all the code when it grows.
There is a stricter principle, however: new code without review tests is not accepted. Of course, this works if the deadlines are not burning, otherwise the programmer will refactor or cover it with tests later.
It would be nice to set up CI/CD so the new build cannot be deployed until the unit tests pass.
As surprising as it may sound, unit tests are needed not only to test business logic and find bugs, but also to identify problems in the design. Everyone knows that if you cannot cover some part of the code with unit tests, then you will have problems in the architecture. Unit tests allow you to notice problems at an early stage. For example, if you need to lock in a bunch of dependencies to write a single unit test, then there is a problem and you need to refactor.
Thus, unit tests help to not only find logical errors, but also to identify problems in the design. What happens if you don't write them? The architecture is likely getting worse and more confusing, increasing the likelihood of making a mistake with any changes.
Such testing gives confidence that individual parts of the code work, but does not say whether the code as a whole works. This issue is solved by integration testing.
Unit tests are good, but most modern microservices work with external I/O (databases, MQ, and other services). So how should these be tested? You most likely want to make sure that the running application will work, and this is where integration testing comes to the rescue.
Integration testing allows you to simulate user actions and quickly obtain confirmation that the software product successfully interacts with other systems. This approach guarantees several advantages at once:
During integration testing, the ability of different microservices to interact with each other correctly is determined. This is one of the most important tests of the whole architecture. With a positive outcome of testing, we can be sure that it is designed correctly, and all individual microservices work as a complete product in accordance with expectations.
The main recommendation is to use integration tests to check the service against business requirements. Reproduce the main business cases for the service in integration tests, but do not try to test the business logic through them. Then, while refactoring and/or moving to another DB, you will not need to do anything with the tests themselves. You will always be sure that the work of the service meets the requirements.
There are always several teams working on microservices: backend, frontend and testers. All of them must agree among themselves as to which request works with which parameters, and what each type of data will accept and return. This requires a contract between teams that will contain all methods and returns for all services.
For example, a backend developer wrote the code, annotated it, and made swagger documentation. But if swagger is not validated by the frontend, and QA has already tested it, we will simply waste our time. Therefore, a contract is created. For example, a service has 8 endpoints, and we know in what format it sends and receives data.
Contract testing is necessary in order to make sure that everything really works as it should. In fact, this is a black box testing. It doesn’t matter how the processes inside the service take place; if some scheme does not work according to the PACT, then there is a bug.
It worked for me as follows: from a very early stage, there is a technical task agreed upon by all stakeholders. Based on the TOR, the task is evaluated, and a scheme is created that everyone works in accordance with.
You might be wondering what a PACT file is. PACT files are not a type of encrypted binary, but are a regular JSON file that has a specific structure. These single out both the consumer and the provider (i.e. who interacts within the contract framework and what their roles are). Essentially, developers can better describe interactions by clarifying what the service (or provider) is expected to provide in detail. There is also a description field — this is just a project description to remind developers what the contract is about.
The most interesting aspect of this is the State Provider. What is it, you ask? This is the state in which the tested microservice should be at the time it is accessed for a specific testing iteration or request. Both SQL queries can be described by states, along with other mechanisms for bringing the service into some state, such as the creation of some data in our sampled database. States is a complex module that can contain all kinds of entities that bring our service into the desired proper state.
In summary, the main task of the CDC is to improve the understanding of the behavior of the API on the project.
In a monolith, method calls are made within a single application. In a microservice architecture, all interaction is carried out through the network. Accordingly, the speed of information exchange between applications drops regardless of the exchange protocol used. Therefore, load testing is an important step in testing a microservice.
To be precise in terminology, the general concept would be performance testing — the main purpose of which includes determining the reference behavior of the system. There are a number of industry benchmarks that must be met during performance testing.
Performance testing is divided into the following types that we use in our work:
The load test measures and monitors peak system performance, server throughput, server response time under various load levels (under the breaking threshold), adequacy of the H/W environment, and the number of user applications that can be applied without compromising performance output.
Performance testing requires research and planning. Prior to beginning the load testing phase, you will need to collect information from various sources, such as analytics tools for traffic data, session duration, and number of visitors during peak hours. It also helps to look at previous campaign data to get a better idea of how much load or concurrent users you are going to test on your website or application.
The primary objective is to create a plan that best suits the environment and mimics real-life scenarios as closely as possible.
On prior projects where performance testing was implemented, we used JMeter. This is a free tool, and it can be used to simulate large loads on a server, a group of servers, a network, or an object to check its power or analyze the overall performance under various load types. JMeter simulates a group of users sending requests to a target server, and returns statistical information for the target server or service via graphical charts.
After you’ve formulated a test plan, you can begin setting up a primary load test in JMeter with the following components: the testing plan, a thread group, and multiple samples. Scripts are created directly through the window interface by visual programming, but it is possible to expand the logic using Groovy, for example, or you can write your own plugins in Java.
In the microservice world, simply checking each service separately does not give confidence that the whole system works. This is where end-to-end (E2E) testing comes in.
E2E testing can be either automatic or manual. The operation of all system components together is checked for compliance with business requirements. If unit and integration testing is, for the most part, a test from a technical point of view, then E2E is a test of user expectations from the system.
In essence, E2E tests the business logic just like in the integration, however not in isolation but on a system-wide scale.
In end-to-end testing, we check the interaction of all services with the platform: registration, authorization, clicking on buttons, withdrawing and replenishing funds, etc. That is, we check the ability of the entire application to satisfy all the requests of the end user.
This is the final form of testing. Nearly all the same components are checked as in end-to-end testing, but by using only the UI. Functional testing can be carried out by manual testers or by using automated tests.
The primary task is minimal functional testing. Ready-made builds are tested, which can already be shown to customers or deployed in production.
Below I’ll describe briefly the approach we used on our project, but you are free to use any other suitable language (Python, .NET, etc.), tools (Selenium, Groovy, Jenkins, etc.) and approach (BDD, DDD, TDD, etc.).
For automation, we used Java, Cucumber, and a self-written library to describe the scripting logic. We used Cucumber for the convenience of writing the tests themselves.
We needed to run a huge number of scripts at the same time, so to solve this problem we used Selenide which was deployed on the same environment as Jenkins.
A pipeline was created in Jenkins, in which it was written how many containers needed to be raised to run the test.
For example, say you need to test 50-200 email templates, and it will take 15-20 minutes for one stream. By creating a pipeline in Jenkins, the script is split into many small containers that are then raised in Selenide.
We can say that Selenide is a virtual browser and Selenium is a virtual user. 10 containers were lifted at the same time, and all tests passed in a couple of minutes. All pipelines were also written in Groovy.
After that, we collected all this into reports depending on the project: UI in Cucumber reports, and API tests in Azure.
All scripts were written on the basis of test cases and user cases that were made by manual testers. Before starting development, the manual testers had a TOR that described the business logic of the application, along with layouts from designers.
That’s it for today. Happy testing!
Explore our remote tech jobs to find your next perfect role at EPAM Anywhere. We look forward to your application!