One of our primary goals at Cashfree Payments is to build products with the best in class quality. We rely on a lot of test automation to achieve this. Integration testing as part of this automation is done to verify that multiple modules developed separately, work together as expected. In this blog, we explain how we are using Testcontainers to ensure that our integration tests are easy to write and maintain as well as portable and reliable.

About Testcontainers

The modules for which we write integration tests often depend on external resources. These include databases, caches, message queues, other cloud services, etc. which our tests need to take into account. The Testcontainers library has the ability to manage the lifecycle of such external resources in the form of docker containers. It is open source and has support for multiple programming languages including Java, Go, Python, and more.

Using this library, we can set up and run the required dependencies’ docker containers before our test execution starts. The module to be tested that has these dependencies connects to these docker containers (or testcontainers) and then our tests are run on top of this setup. After the test execution, we can gracefully tear down this setup. All of this can be accomplished with very few lines of code.

Test Scenario

Let’s take an example where the requirement is to test an asynchronous consumer of a message queue. Consumer reads payment events from an AWS SQS queue and for a given payment event, it updates the corresponding order’s status in MySQL DB and finally deletes the event from the queue. So, we can write an integration test for this consumer by spawning two testcontainers – MySQL and Localstack (for AWS SQS) and configuring the consumer code to point to these testcontainers.

Code

The test code below is written in Java programming language. The payment event consumer is part of a Spring Boot application and we have written a JUnit integration test for it with the help of the Testcontainers library.

We first create a base abstract test class that can be inherited by other integration tests. In this base class, we create static instances of the testcontainers (MySQL and Localstack) that can be initiated before the test execution starts. We do this so that they can be reused across all the integration tests and the cost of reinitiating them before each test or test class can be avoided.

The child class is responsible to execute the actual test once the testcontainers are up. Before the test is run, we set up an SQS queue and seed data in it by producing a message and also seed existing order data in MySQL DB.

This code creates an SQS client instance from the localstack container.

The test asserts that the order status is updated as per the payment event and the queue is empty after the event is consumed. This completes our integration test.

The testcontainers can be brought down in two ways:

  1. We leave this job to “Ryuk” – a special container that is started by the library before the test execution starts that takes the responsibility of fail-safe cleanup of testcontainers after the JVM shuts down. This is how it is happening in the above example and hence, no code is required for the same.
  2. We can disable Ryuk via system properties and can explicitly call the close() method on the testcontainers objects by adding a shutdown hook as shown below:

Continuous Integration

Our integration tests run as part of our CI pipeline which helps us identify issues and block unstable pull requests. This in turn reduces the turnaround time and fastens the feedback loop. Within a Kubernetes pod spawned by our CI pipeline, the containerised tests connect to a sidecar Docker runtime container to run the required Testcotnainers.

However, Testcontainers Cloud is now available and that removes the need to run the testcontainers depending on a sidecar Docker runtime container within any CI pipeline. The cloud solution can actually host the docker environment and run the testcontainers, we only need to point our tests to it. This helps keep the CI pipeline (or localhost testing) fast and lightweight.

But why do we need Testcontainers?

There are multiple reasons why:

1) Tests are more reliable. Integration tests often mock external dependencies with in-memory solutions which may not behave exactly like real dependencies. For example: Data types and native queries supported by databases like MySQL or Postgres used in production may differ from in-memory databases like H2. In such cases, Testcontainers are more reliable to test the exact behavior of any dependency.

2) It is portable. Tests written using Testcontainers can run in any docker-compliant environment: localhost machines or CI pipelines. Write once and run anywhere. Test code is completely decoupled and has no impact on the development environment. Portability increases further with the Testcontainers Cloud solution. And hence, it requires less maintenance without sacrificing testing quality.

3) Version upgrade of any external resource becomes very easy. Tests can be run against any dependency’s newer version easily and verified if the application can safely be migrated to it.

4) It is cheap. The cost of setting up a real “production-like” environment for integration tests can be high. For concurrent test runs, teams may need multiple such environments, increasing the cost and setup time further. With Testcontainers we can have test runs in different environments as close to reality as possible with minimal infra cost.

5) It requires less maintenance. Before the integration tests execution starts, generally, a clean set of resources (databases, caches, etc.) is required where data can be seeded based on the test scenario and after the execution data is cleaned up. This is easily achievable using Testcontainers as a new set of containers is spawned with every new run and we don’t have any shared external resources that require state management. Multiple CI pipelines can run in parallel independent of each other without causing any race conditions.

6) It is very easy to use. The Testcontainers library is simple and has a large set of predefined classes for different kinds of popular dependencies such as Redis, Kafka, Elasticsearch, different databases, cloud services, etc. It additionally provides a GenericContainer class that can help run any kind of dependency as long as it can be containerised. Hence, multiple test scenarios can be solved with ease.

7) It makes all dependencies visible. When all the integration tests covering all the scenarios are in place, they give clear visibility to the developers over what all dependencies their complex service has in the form of testcontainers. This increases knowledge within the team and helps them work on new features with more pace, confidence, and predictability. This also helps us in onboarding any new engineer to the system, since they don’t need to set up all dependencies on their own to run tests.

8) It has a minimal learning curve and it has support for multiple programming languages.

What is next for us?

With Testcontainers we have been able to conveniently increase test automation for our services and cover different test scenarios. We started with Java services and now plan to start using it for our Golang services. Over time, we also plan to start using the Testcontainers Cloud solution which would enable us to test fast, reduce our localhost resource requirements (CPU, memory), and work with lightweight, cheaper machines.

Do our experiments in tech sound intriguing to you? If so, get in touch with us for some interesting opportunities for engineers like you!

Jobs at Cashfree Payments

Discover more from Cashfree Payments Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading