In the late 2000s, an explosion happens in the software industries due to the rise of Mobile Internet (Wifi, Smartphones) and faster network. Almost all types of companies started to develop or use software like Banking, Insurance, Restaurants, etc.
In the 2010s, disruptive technologies arise which impact the Software Development landscape in a significant way: Cloud Computing, Containerization (Docker, Kubernetes), DevOps. There is a change in the Software Development model also. The Waterfall software development model is almost discarded and replaced by fast, iterative, incremental Software development methodology: Agile Software development. New Database technologies like NoSQL, NewSQL emerge and become mainstream.
To handle the complexity of modern software applications, to take advantage of the emerged technologies, and to fulfil the need for modern software development, a new Software Architecture Style arose in 2012 which is: Microservice Architecture.
Now, before I tell you more about microservices, let me introduce you to the architecture that prevailed before microservices Monolithic Architecture. Here, the application is similar to a big container where all its software components are assembled together and tightly packaged.
In contrast, Microservice Architecture uses divide and conquer to tackle the complexity of software systems, where a complex software system is divided into many microservices that communicates via external Interfaces.
Although there are many benefits with the microservices approach such as the ability to independently deploy, scale and maintain each component and parallelize development across multiple teams, the additional network partitions in this architecture, introduce new challenges regarding the testing strategies that should be followed to test such systems.
Untested code is broken code.Plone conference 2007, Naples. Speakers: Philipp von Weitershausen and Martin Aspeli
Hence, today we’re discussing testing methods for managing the additional testing complexity of multiple independently deployable components i.e. Microservices.
You can broadly break microservices testing down into two categories: pre-production testing and post-production. In this post, we’re considering the pre-production side, while the post-production will be covered by future posts.
Before digging into the testing methods, it’s important to be on a knowledge of the microservice structure.
Microservices are a collection of small autonomous services, modelled around a business domain. They often display a similar internal structure consisting of some or all of the displayed layers:
Microservice Layers Description
- Resources: act as mappers between the application protocol exposed by the service and messages to objects representing the domain.
- Domain: almost all of the service logic resides in a domain model representing the business domain.
Services coordinate across multiple domain activities.
Repositories mediate between the domain and data mapping layers using a collection-like interface for accessing domain objects.
- Gateway: encapsulates message passing with a remote service, marshaling requests and responses from and to domain objects. It will likely use a client that understands the underlying protocol to handle the request-response cycle.
- Data Mappers: A layer of Mappers that moves data between objects and a database while keeping them independent of each other and the mapper itself.
If you’re wondering about the difference between repositories and data mappers, you can refer to this answer.
How Does The Microservice Architecture Work?
Microservices handle requests by passing messages between each of the relevant modules to form a response. A particular request may require interaction with services, gateways or repositories and so the connections between modules are loosely defined.
What to Test in a Microservice Architecture?
Any testing strategy employed should aim to provide coverage to each layer of the service, between layers of the service, and the communications with the externals, while remaining lightweight.
Microservices Testing Methods
In this section, we’re going through different testing methods that cover different scopes in the system under testing starting from its simple units, ending at the whole system.
The scope: A unit test exercises the smallest piece of testable software in the application to determine whether it behaves as expected.
This is a testing method used for monolithic testing as well.
In which size should the tested unit be?
The size of the unit under test is not strictly defined. However, unit tests are typically written at the class level or around a small group of related classes.
The smaller the unit under test the easier it is to express the behavior using a unit test since the branching complexity of the unit is lower.
Unit Test Impact on The Design
Often, difficulty in writing a unit test can highlight when a module should be broken down into independent more coherent pieces and tested individually. Thus, alongside being a useful testing strategy, unit testing is also a powerful design tool, especially when combined with TDD.
Consider reading our previous post about Test-Driven Development (TDD).
Unit Test Types
We have two distinctions based on whether or not the unit under test is isolated from its collaborators or not:
These types are not competing and are frequently used in the same codebase to solve different testing problems.
A. Sociable Unit Testing: treats the unit under test as a black box tested entirely through its interface.
B. Solitary Unit Testing: the interactions and collaborations between an object and its dependencies are replaced by test doubles.
What are test doubles?
It is the technique of using an object to stand in place of a real object in a test.
Let’s explore the 4 most commonly used test doubles types:
When we use an object to stand in place of a real object but never make of the object, then the object is called a Dummy. Its usually done to fill the parameter list, so that the code compiles and the compiler stays happy.
These objects actually have a complete working implementation in them. But the implementation provided is some kind of shortcut which helps us in our task of testing, and this shortcut renders it incapable in production.
A great example of this is the in-memory database object which we can use just for our testing purposes, while we use the real database object in production.
These are objects which we hard code to provide canned responses whenever specific methods are called on those objects.
These are objects which we use for verification by checking whether particular methods were called on the object, with specific parameters. So these objects become the basis for the results of our tests.
Unit Testing in Microservices
Both types of unit testing play an important role inside a microservice.
What to test in a microservice using unit testing?
1. Domain logic:
Unit test type: Sociable.
Why using Sociable unit testing for testing domain logic?
Since these types of logic are highly state-based there is little value in trying to isolate the units.
Unit test type: Solitary.
Why using Solitary unit testing for testing Gateways?
Because the purpose of unit tests at this level is to verify any logic used to produce requests or map responses from external dependencies rather than to verify communication in an integrated way.
Resources & Service Layer (coordination logic):
Unit test type: Solitary.
Why using Solitary unit testing for testing Coordination logic?
Because coordination logic cares more about the messages passed between modules than any complex logic within those modules. Using test doubles allows the details of the messages passed to be verified and responses stubbed such that flow of communications within the module can be specified from the test.
When not to use unit testing?
When the ratio of (plumbing and coordination logic)/(complex domain logic) for a microservice is relatively large, unit testing may not pay off. It’s common to face such a situation when:
- Dealing with a small-size service.
- Dealing with some services that contain entirely plumbing and coordination logic such as adapters or aggregators.
The scope: an integration test verifies the communication paths and interactions between components to detect interface defects.
Integration tests collect modules together and test them as a subsystem in order to verify that they collaborate as intended to achieve some larger piece of behavior.
Integration Testing in Microservices
In microservice architectures, integration tests are typically used to verify interactions between layers of integration code and the external components to which they are integrating.
Examples of the kinds of external components against which such integration tests can be useful include other microservices, data stores, and caches.
What to test in a microservice using integration testing?
Purpose: allow any protocol level errors such as missing HTTP headers, incorrect SSL handling or request/response body mismatches to be flushed out.
Purpose: provide assurances that the schema assumed by the code matches that available in the data store.
Best practices with Integration Test:
Tests in this style have more than one reason to fail e.g. if the logic in the integration module regresses or if the external component becomes unavailable or breaks its contract.
From here, we have the following two suggested practices:
- Write only a handful of integration tests to provide fast feedback when needed and provide additional coverage with unit tests and contract tests (coming later) to comprehensively validate each side of the integration boundary.
- Separate integration tests in the CI build pipeline so that external outages don’t block development.
Gateways related practices
- Any special case error handling should be tested to ensure the service and protocol client employed respond as expected in exceptional circumstances.
- At times it is difficult to trigger abnormal behaviors such as timeouts or slow responses from the external component. It can be beneficial to use a stub version of the external component as a test harness which can be configured to fail in predetermined ways.
Persistence related practices
- Since most data stores exist across a network partition, they are also subject to timeouts and network failures. Integration tests should attempt to verify that the integration modules handle these failures gracefully.
The scope: A component test limits the scope of the exercised software to a portion of the system under test, manipulating the system through internal code interfaces and using test doubles to isolate the code under test from other components.
This is a testing method used for monolithic testing as well.
What to consider as a component in this test?
A component is any well-encapsulated, coherent and independently replaceable part of a larger system. This component is isolated using test doubles which avoids any complex behavior they may have, also helps to provide a controlled testing environment for the component, triggering any applicable error cases in a repeatable manner.
Component Testing in Microservices
In a microservice architecture, the components are the services themselves. By writing tests at this granularity, the contract of the API is driven through tests from the perspective of a consumer.
Isolation of the service is achieved by replacing external collaborators with test doubles and by using internal API endpoints to probe or configure the service.
Best practices with Component Test:
Tests communicate with the microservice through an internal interface allowing requests to be dispatched and responses to be retrieved. Often a custom shim is used to achieve this.
In this way, an in-process component test can get as close as possible to executing real HTTP requests against the service without incurring the additional overhead of real network interactions.
In order to isolate the microservice from external services, gateways can be configured to use test doubles instead of real protocol level clients. Using internal resources, these test doubles can be programmed to return predefined responses when certain requests are matched.
These test doubles can also be used to emulate unhappy paths through the component such as when external collaborators are offline or are responding slowly or with malformed responses. This allows error conditions to be tested in a controlled and repeatable manner.
Replacing an external datastore with an in-memory implementation can provide significant test performance improvements.
The scope: An integration contract test is a test at the boundary of an external service verifying that it meets the contract expected by a consuming service.
The contract concept
- Whenever some consumer couples to the interface of a component to make use of its behavior, a contract is formed between them.
- Each consumer of the component forms a different contract based on its requirements.
- If the component is subject to change over time, it is important that the contracts of each of the consumers continue to be satisfied.
Contract Testing in Microservices
When the components involved are microservices, the interface is the public API exposed by each service.
The maintainers of each consuming service write an independent test suite that verifies only those aspects of the producing service that are in use.
Contract test suite attributes:
- They do not test the behavior of the service deeply.
- They verify that the inputs and outputs of service calls contain required attributes.
- They verify that the response latency and throughput are within acceptable limits.
Contract Testing Benefits
- Contract tests provide confidence for consumers of external services.
- For the maintainers of those services. By receiving contract test suites from all consumers of a service, it is possible to make changes to that service safe in the knowledge that consumers won’t be impacted.
- Contract test suites are also valuable when a new service is being defined. Consumers can drive the API design by building a suite of tests that express what they need from the service. And here comes the concept of consumer-driven contract test.
Best practices with Contract Testing
- Ideally, the contract test suites written by each consuming team are packaged and runnable in the build pipelines for the producing services. In this way, the maintainers of the producing service know the impact of their changes on their consumers.
The scope: An end-to-end test verifies that a system as a whole meets business goals irrespective of the component architecture in use, testing the entire system, from end to end.
In order to perform end-to-end testing, the system is treated as a black box and the tests exercise as much of the fully deployed system as possible, manipulating it through public interfaces such as GUIs and service APIs.
End-to-End Testing in Microservices
As a microservice architecture includes more moving parts for the same behavior, end-to-end tests provide value by adding coverage of the gaps between the services. This gives additional confidence in the correctness of messages passing between the services but also ensures any extra network infrastructure such as firewalls, proxies or load-balancers is correctly configured.
Best practices with end-to-end testing:
- Some systems may have dependencies on one or more externally managed microservices. Usually, these external services are included within the end-to-end test boundary. However, in cases such as follows it can be beneficial to stub the external services, losing some end-to-end confidence but gaining stability in the test suite:
- If an external service is managed by a third party, it may not be possible to write end-to-end tests in a repeatable and side effect free manner.
- Some services may suffer from reliability problems that cause end-to-end tests to fail for reasons outside of the team’s control.
- To ensure all tests in an end-to-end suite are valuable, model them around personas of users of the system and the journeys those users make through the system.
- Make tests data-independent. Rely on pre-existing data as a common source of difficulty in end-to-end testing is data management.
Balancing The Tests
In general, the more coarse-grained a test is, the more brittle, time-consuming to execute and difficult to write and maintain it becomes.
The concept of the test pyramid is a simple way to think about the relative number of tests that should be written at each granularity. Moving up through the tiers of the pyramid, the scope of the tests increases and the number of tests that should be written decreases.
At the top of the pyramid sits exploratory testing, manually exploring the system in ways that haven’t been considered as part of the scripted tests.
In this post, we covered in detail the pre-production testing methods for microservices architectures and the best practices while performing them. We also introduced the test pyramid as a guide for balancing the different test-types numbers when building your own test strategy.
The following graph summarizes all the testing methods we discussed and their scope according to a service in the system:
Do you know that we use all this and other AI technologies in our app? Look at what you’re reading now applied in action. Try our Almeta News app. You can download it from google play: https://play.google.com/store/apps/details?id=io.almeta.almetanewsapp&hl=ar_AR”