Testing microservices presents a special set of challenges because of their distributed and modular nature. Each component, or microservice, has to function correctly by itself and as part of a larger system, creating complexity that must be managed during testing. The best approach to effective testing is layered, where each layer is comprised of tests that cover the system at that level, ensuring a high level of test coverage.
Challenges of Testing Microservices
Microservices are inherently complex because they consist of multiple services that work together to make up a larger system, increasing overhead and introducing inter-process communication. A problem with any one of these services or its external dependencies can create a domino effect. Microservices should be made to be consumed by several consumers, so requests and responses have to be handled correctly for multiple clients and use cases.
The additional complexities that a microservice architecture introduces also affects the difficulty of testing of them. While many traditional testing methods are still useful for testing microservices, they are made more difficult by the distributed and independent nature of a microservice architecture. Because each component of the architecture is isolated from the rest, they have to be tested individually and as a complete system. Independent teams might work on separate services that rely on each other, and their testing has to cover their own module and integration with external services.
Each microservice should be focused on performing a set of related functions, but it is common for multiple services to require access to the same data. In order to conform to the encapsulation paradigm of microservices, they should only modify databases directly related to their designated operations and should access other data through API calls to the relevant service. This adds dependencies on other services for data in order to function correctly, so testing a simple flow in one microservice could require functioning or mocked responses from several other services to be set up as part of the test.
The layered interactions often present in microservices can make end-to-end flows difficult to follow. Tracing a process as it travels through and touches multiple services and databases requires tracking it across all parts of the system. This means monitoring multiple log files, databases, and servers, which increases the complexity of tracking down issues or verifying processes.
There are usually many dependencies between microservices, since they rely on each other to perform functions specific to their focus and these have to be managed as part of testing. If one service changes, it can affect all of the services that depend on it. They may need to be updated to pick up or handle any changes that were made.
Managing the Complexity
Using a unit testing framework such as JUnit to test individual operations within the system as it is developed can verify that independent components are working correctly for different use cases and sets of data. These unit tests can be run whenever the code is compiled to ensure that any changes that were made did not break test cases and all low-level operations still work as expected. Testing components in isolation allows for the verification of correct behavior before the introduction of more complexity through external service integration.
Using a layered, bottom-up approach to testing improves stability of the microservices as they are developed and ensures that testing covers all parts of the system. By validating that each layer of the system works starting with the smallest component up through the entire system, faults can be identified and fixed at each layer before they affect the larger system. Contract-driven testing can be used to ensure that responses are consistent with the expectations of clients that consume them. These approaches to testing are covered in more detail below under Types of Microservice Testing.
Designing the services to only directly access databases that are specific to their designated functionality can prevent them from modifying data that is outside their scope. It is common for services to require access to data outside of the data they are directly responsible for. In these scenarios, you should set up endpoints in the services responsible for handling that data to return the required data and access it through service calls from other services. These endpoints should be mocked during unit testing to isolate failures to a single service and then fully integrated and tested during integration testing.
Using a build and dependency management system like Maven, Ant, or Gradle centralizes and simplifies the configuration of external dependencies for each service, making them easier to manage. When testing changes to a service that affects its consumers, the consumer dependencies should be updated and tested to ensure the changes did not break them. Consumers of a microservice have expectations regarding the structure of the output, data changes, and performance, creating a contract that needs to be satisfied by the producer. Verifying that these expectations are met and continue to be met as the service changes is where consumer-driven contract testing is valuable. Understanding how a service will be consumed and developing it in a way so that existing contracts are not broken guarantees stability of the service over time and requires minimal or no changes from its consumers. Following Postel’s Law when designing input and output for the services by not requiring non-essential inputs and having consumers ignore unneeded outputs allows both the producer and consumer to be more robust as those inputs and outputs change over time.
Types of Microservice Testing
The purpose of unit testing is to verify specific behaviors without relying on other components. Testing the core functionality of a module and isolating its behavior from external services and systems ensures the code works correctly at the lowest level.
Contract testing should treat each service as a black box. Each service should be called independently and the responses verified. Any dependencies of the service should be stubs that allow the service to function but don’t interact with any other services. This avoids complex behavior that could be caused by external calls and focuses the test on a single service.
Consumer-contract testing views each service call as a “contract” where a certain result or output is expected for specific inputs. Any consumer of the service should expect the same results from a service over time, even as the service changes. Responses should be extensible so that more functionality can be added later on, but any additions shouldn’t break the existing functionality of the service. Designing the service in this way allows it to be resilient over time as consumers are not required to change their code to account for changes that were made to the producers.
It is common for microservices to be developed independently by different teams. Using consumer-driven contract testing to validate that the contracts with consumers are satisfied means that developers can be confident they will continue to be able to consume the service without requiring collaboration between the teams. Teams can continue to develop and test independently while remaining dependent on microservices developed by other teams.
Mocking frameworks like Mockito for Java can be used to fake responses from external services so that services can be tested in isolation without relying on external services working correctly at this level. It can also be used to validate that parameters matching expected data types or values are being passed to the external services, so when they are integrated they receive the correct request. Pacto is another useful tool for consumer-driven contract testing that allows you to test consumers independently by decoupling them from the services they depended on. This means contract satisfaction can be verified without relying on a complete implementation of microservice dependencies
After the services have been tested individually, the interactions between them should be verified. This is a critical part of testing a microservice architecture because it relies on inter-service communication to function. Service calls should be made with integration to external services, including success and error cases. This layer of testing validates that the system is working together correctly and any dependencies between services behave as expected.
End-to-end testing verifies entire process flows work correctly, including all service and database integration. Operations that touch many services should be most thoroughly tested to ensure all parts of the system work together as a whole to satisfy requirements.
Functional testing can be automated using a framework like JBehave, which takes a user story and verifies that the system behaves as expected in the given scenario.
User interface testing, or functional testing, is the highest level of testing and includes front-end integration and tests the system as an end-user would use it. The testing at this level should be no different from a normal user interacting with the system. All interfaces, services, databases, and third-party services should work together correctly to produce the expected results and satisfy user stories.
What’s your method of testing microservices? Please feel free to ask any questions in the comments section below, and connect with us on LinkedIn and Twitter @CrederaOpen.