,

The Microservices Testing Pyramid

According to IDC, by 2024, nearly 60% of organizations’ new custom-developed applications will be built and managed using microservices and containers as foundations for stronger and higher-performing automation.

That is an ambitious shift from the established practice of monolithic application design which is still the default model of enterprise applications worldwide. 

This shift is understandable, though. Monolithic applications are simply not equipped to cope with the rigorous demands of scalability and flexibility in today’s digital economy. Microservices, on the other hand, are a natural fit for the digital enterprise. As a development model, microservices prioritize business needs over technological capabilities. Each microservices development team can choose the technologies that best deliver the required business functionality. Since each microservice is scoped to a single business function, it is easier and faster to develop, deploy, modify, and maintain these services. It facilitates continuous innovation and continuous deployment as each service module can be independently upgraded and deployed without upending the entire application. And development teams are organized into smaller, cross-functional teams that autonomously handle their share of services. 

However, the inherent complexity of microservices architectures makes them a less-than-ideal candidate for testing and QA.  After all, each service component comes with its unique combination of logic, in-house and third-party components, and a maze of connections. That adds up to quite a bit of complexity even for applications with just 100+ microservices. Hardly surprising then that maintenance and debugging are the least enjoyable part of working with a microservices architecture.

The challenges of testing have not slowed down microservices adoption. So, here’s a look at some of the most commonly applied testing models and strategies for testing microservices applications.

Image Source: TEAM International 

Image Source: Martin Fowler

Testing methods for microservices are broadly categorized across three levels — technology-facing tests at the bottom, exploratory tests in the middle, and acceptance at the top. The common software testing pyramid is the most commonly used tool to define the type, focus, and granularity of the different tests that are pertinent to microservices. The base of the pyramid illustrates the need for a large number of narrowly focused tests and progressively narrows down to fewer coarse-grained broadly scoped tests towards the top. As the scope of these tests broadens, they also become more brittle, difficult to write, and time-consuming to execute. 

Here’s a brief review of some of the most widely used approaches to testing microservices applications. 

Unit Testing

The “micro” in microservices refers to responsibility rather than size and is broadly governed by the Single Responsibility Principle according to which each class only has one responsibility i.e. reason to change. Focusing each microservice on a single narrow responsibility ensures that they are simple to understand and implement and augments reusability, flexibility, and testability. 

Unit tests or white-box tests typically focus on the performance of the smallest possible testable constituent of a software application. This granular approach perfectly aligns with the SRP philosophy of microservices design and should be the mainstay of microservices testing as they can deliver the highest software quality.  

Image source: Manning

Unit testing in microservices is further subdivided into solitary tests — where each class is tested in isolation with mocks or stubs replacing its dependencies — and sociable tests — where a class is tested along with its dependencies. While solitary tests can be applied to test the performance of a component when all dependencies are cut off, sociable tests are ideally suited for testing units with more complex business logic and transition states. Both these approaches therefore can be applied to the same code base to address different testing requirements or scenarios across different layers and internal modules of microservices architecture. For instance, solitary testing is more pertinent to both collaborators and gateways in the resources & services layer while sociable testing is more applicable to objects in the domain layer that are state-based and cannot be isolated. 

The key principles of a successful unit testing strategy in microservices include focusing on the smallest unit of code, ensuring optimum code coverage, and, most importantly, automated testing. In return, the benefits are earlier, easier, and cheaper identification and resolution of errors and eliminating the risk of system failure when new components or services are added. 

Integration Testing

Image source: Cigniti

Unit testing does go granular and covers the internal modules of each microservice. However, it does not address the interface and interactions between different system modules. And this is where integration testing comes in as it tests how multiple modules work together as a subsystem and verifies that all units are performing as per their defined specifications. Integration testing, therefore, is a critical step toward implementing more comprehensive system testing procedures.

There are four common approaches to integration testing namely big bang, incremental,  top-down, bottom-up, and hybrid/sandwich.  

In the big bang approach, all modules are integrated to essentially replicate the complete system. This model is typically used for compact, self-contained, less complex systems, none of which describes most modern microservices applications. Though this approach can be used in microservices to identify errors, there are no practical ways of isolating errors to specific modules or components. Incremental testing involves testing a minimum of two logically related modules in sequence until all the modules have been covered. 

In top-down and bottom-up integration, the testing process starts at the top or the bottom units, respectively, and gradually progresses to cover all units. 

Hybrid/sandwich integration testing consolidates the benefits and addresses certain limitations of the previous two approaches. In hybrid testing, the system under test is configured as three layers, the main target layer, the primary focus of the test, sandwiched between two other layers. 

Integration testing can also be classified as either narrow or broad given their scope. Narrow integration testing emphasizes isolation and could be as simple as a unit test without mocked dependencies. Broad integration testing tends more towards end-to-end testing further up the pyramid and focuses on validating the collaboration between multiple components. 

However, for the record, there exists a point of view that integration testing for microservices “isn’t just a waste of your time, it’s actually impossible.” The argument is that integration testing assumes the possibility that paths through a transaction can be known and, therefore, assembled for testing, which may be true for simple applications but not for microservices. Then there is the contention that in organizations that build more microservices, integration testing can create a range of issues, all of which can be addressed by contract testing that makes each service independently testable.

Component Testing

Component tests focus on isolating and testing each service in a microservice architecture. It provides a measure of microservice performance as a whole, including its interaction with the database and third-party components. Even though service isolation is a key stipulation, these tests do involve creating mock services that mirror the deployed service. Component testing creates a more controlled testing environment, and the isolation precept enables quicker test executions and a more comprehensive evaluation of service behavior. 

Testing such components in isolation provides many benefits: By limiting the scope to a single component, it is possible to thoroughly perform an acceptance test for the behavior encapsulated by that component whilst maintaining tests to execute more quickly. Isolating the component from its peers using test doubles/mocks avoids any complex behavior they may have. It also helps to provide a controlled testing environment for the component, triggering any applicable error cases in a repeatable manner. 

Image source: Chris Richardson

There are two types of component testing, in-process, and out-of-process. In-process testing utilizes test doubles and in-memory data stores and remains within the boundaries of a microservice. Though this approach is simpler and faster it is less realistic as it does not fully test deployable production service. 

Out-of-process component testing focuses on fully deployed artifacts, with test doubles replacing external collaborators, and validates the configuration of external services. Though more realistic than in-process, this approach is slower and more complex.

Component testing needs to cover as many functional test scenarios as possible so that all problems are identified and addressed before deployment.

Contract Testing

Contract testing refers to tests at the boundary of an external service that verifies if the implicit and explicit contracts of a microservices architecture are met. Even though it does not facilitate the comprehensive testing of service behavior, it is useful for testing service interactions in terms of latency, throughput, etc., and ensuring that no breaking changes will be introduced. And because they focus solely on service interactions, contract testing can test a huge number of interactions in a very short time and deliver better code coverage than end-to-end tests. 

Image source: TechBeacon

A microservices system has different types of contracts. For instance, an HTTP request and response is a contract when using APIs while the domain event itself is the contract in the case of an event-based system. Each contract is an agreement between a consumer — a client, like a web front end, that wants to receive some data from other services — and a provider — a service or server, like an API, that provides the data required by the consumer  — about the format used to transfer data between each other. Consumer-driven contract testing, therefore, ensures that the contracts that codify these service interactions between consumers and producers are fulfilled. 

There are two distinct phases to contract testing. First, the consumer publishes a contract, like a typical API schema, detailing their expectations and requirements for their interactions. Second, providers access and validate these contracts against their API schema. Getting both sides to approve the contract ensures that the API will not be used or altered in any way that breaches the obligations documented.

End-to-end testing

End-to-end testing is a top of the pyramid, and hence limited, strategy to verify that a microservices system meets all high-level requirements and is capable of delivering the business objectives it was designed for. These tests extend coverage to any gaps between services that the other tests may have missed and ensure that entire process flows are working correctly, including all service and DB integration.   

Image source: Chris Richardson

Service virtualization is a widely used technique that is appropriate for end-to-end testing microservices architectures. This technique helps simulate the behaviors of different services and components in distributed architectures such as microservices. It supports a test-driven development approach and allows DevOps teams to deploy virtual services and components so that the system can be tested even if all components are not yet available. For instance, using a virtual service to mock backend and third-party components not only ensures independent testing but also means that testing is not delayed by the unavailability of components. 

With service virtualization, development teams can create a virtual double of the entire system by mocking a diverse range of external API-based components. It also creates an isolated environment that supports independent testing.  Application integration happens early in the development cycle which means that errors are identified and addressed promptly to accelerate application delivery. 

End-to-end testing can present some challenges given the complex loosely connected characteristics of the microservices environment. For instance, they can be costly to maintain as writing and debugging these tests can consume a lot of development resources. The underlying complexity of the microservices system can also lead to non-deterministic failure points which in turn impacts productivity and time-to-market.

There are also several additional testing formats that apply to microservices.  For instance, UI testing is critical for applications that offer a web or API interface for end-users as it targets the behavior of these interfaces as users interact with the system. There’s exploratory testing at the very top of the testing pyramid that can be done throughout the testing life cycle. This approach offers better value over some of the other methods as it facilitates early identification of complex risks and errors and generates user-oriented insights that focus on the development process. Then there are persistence tests targeted at the persistence layer to test queries and the effect on test data and testing in production to detect problems that pre-production testing methods do not surface. 

Then there are off-the-pyramid non-functional tests such as load testing, stress testing, spike testing, load balancing testing, chaos testing, data replication testing, accessibility testing, security & vulnerability testing, etc.    

The tests incorporated as part of the testing strategy for microservices applications can vary from project to project and typically involve a mix of functional and non-functional testing methods. However, it is important for testing to be integrated early in the development process as it can be critical in determining the performance of complex microservice architectures. 

Conclusion

Microservices have rapidly emerged as a strategic imperative for the modern digital enterprise. Microservices applications, however, are extremely complex, presenting one of the first challenges as compared to conventional monolithic systems. The pace and scope of testing have to stay abreast of the frequency with which new system components are deployed. Every microservices project has to start with a robust and nuanced testing strategy that fits the needs of the project as well as the organizational resources available to create and execute these tests.