The goal of this article is to present a solution for implementing integration tests in a Nest.js application. Specifically, we aim to emphasise good practices for creating a testing environment and mocking external dependencies of the tested application. It’s also worth mentioning that the provided approach will use Testcontainers for databases instead of traditional mocking.
Unit testing focuses on testing individual components of the software, mocking all external dependencies and focusing on local logic. Integration testing goes one step further. While unit tests ensure that each component works correctly in isolation, integration tests validate how these components work together.
For example, even if all components pass their unit tests, they may still have issues interacting with one another. Integration tests verify entire functionalities, such as specific endpoints, along with their underlying business logic. It is important to note that while integration tests focus on local business logic, some external dependencies still need to be mocked. Specifically, external API calls should be mocked to ensure tests remain focused on the application's core functionality.
The most important goal of an integration test environment is to mimic a clean production environment as closely as possible. We will achieve this by using containerization of services.
Nest.js integrates two excellent testing libraries that we will use extensively in our project:
A general-purpose testing library that provides an interface for writing and executing test suites.
A library for testing web applications and API services. It simplifies placing API requests and verifying server responses.
In addition to these, for containerization, we will use:
A library that simplifies the creation and management of Docker containers for integration tests. It provides programmatic access to containers, allowing us to easily spin up and manage services like PostgreSQL and Redis during our tests. For this example, we will deploy two Testcontainers: one for PostgreSQL and one for Redis. These containers will be reset after each test case to ensure a clean testing environment.
Nest.js offers seamless integration with Jest and Supertest right out of the box.
The example project for this article simulates partial logic for accepting and processing online shop orders. Its main goal is to demonstrate an app that uses PostgreSQL and Redis databases while heavily relying on external services. In this case, we use an API from fakeApiStore.com to fetch data during processing.
The project architecture consists of:
- Nest.js application server
- PostgreSQL server
- Redis server
The Nest.js application processes a request to finalise a specific cart. Finalisation involves the following steps:
1. Fetching cart data from an external API
2. Calculating the total value of the order
- This involves fetching data for each individual product in the cart from an external API or retrieving it from Redis Cache.
3. Creating a Customer entity (if not already present in PostgreSQL)
4. Saving the processed Order in PostgreSQL
This structure allows us to highlight several critical considerations when setting up and implementing integration tests in your project.
According to the Testcontainers documentation (source: https://java.testcontainers.org/features/networking/), you cannot specify the port on which a container will be accessible on your machine. To address this limitation, we adopt a specific approach:
First, we run the containers without pre-configuring them. Then, we dynamically override local environment variables with the automatically assigned container settings.
While this approach configures environment variables dynamically, we must also consider when specific modules and files in the Nest.js app are initialised. For instance, in our app, the DataSource is imported into AppModule through the ConfigModule and is defined in a separate file:
To avoid modifying production code for tests, we override the ConfigModule during TestingModule initialisation. This technique is also useful for dynamically overriding specific environment variables or even whole classes:
Below is presented full code of class setting up test environment. It's worth mentioning that we utilise specific .env.integration file in order do be able to manipulate the environment regardless of default .env file.
Also testcontainers require some time to set up and it might exceed the default timeout of Jest. Its decremented to increase it manually by calling jest.setTimeout(30000) or changing your Jest configuration file.
We also need to mock external dependencies, specifically the external API. We achieve this by using a StoreManagerPort class, which selects the appropriate adapter based on the environment. During tests, StoreManagerPort is dynamically set to MockStoreManagerAdapter:
StoreManagerPort is an interface that ensures each adapter implements consistent methods:
In production, we use the FakeApiStoreManagerAdapter to call the external API. For tests, we use MockStoreManagerAdapter, which retrieves data from pre-configured JSON files.
This approach provides full control over data and facilitates simulating specific test cases effectively.
The code snippet below demonstrates example integration test case for the CartsController and the associated business logic. Each test case should follow a structured approach to ensure clarity and maintainability.
Each test case is designed to follow three main steps, ensuring consistency and clarity:
1. Prepare the Test Case Environment:
- Before executing the main logic, the test case sets up any necessary initial conditions, such as inserting mock data into the database.
2. Execute the Target API Request:
- The test sends an HTTP request to the /cart/finalize endpoint with specific parameters to simulate a real scenario.
3. Validate Results:
- After the API request, the test checks the database state and compares it with the expected outcomes to validate assumptions.
1. Clean Test Environment:
- After each test, the afterEach() block ensures a clean environment by clearing the database and resetting the Redis cache. This prevents cross-test interference.
2. Use of Mock Data:
- As the integration setup overrides external dependencies (like external APIs), there’s no need to mock API calls or data within individual test cases. This keeps the test code concise and focused. Mocked Data can be easily reused in multiple test cases.
3. Validation of Assumptions:
- The tests thoroughly verify database states (e.g., order and customer records) and API responses, including status codes and error messages, to ensure correctness.
In order to run test you can add specific script to package.json file in your project
This script will:
1. load environment variables from .env.integration file
2. run Jest based on predefined jest-integration.json config file
The jest-integration.json is looking like that:
After completing the entire test suite, it is essential to tear down the test environment to release resources and ensure no lingering side effects. The tear down process involves stopping the INestApplication and the Testcontainers instances for PostgreSQL and Redis.
Here is the implementation:
To ensure that newly introduced features do not break existing functionality, it is essential to validate the code automatically after each pull request or push. GitHub provides a robust solution for automating this process through GitHub Actions, which allows the creation of custom workflows triggered by specific events in your repository. This enables you to run all your unit tests and integration tests seamlessly.
By configuring GitHub Actions, you can have your tests executed automatically whenever changes are pushed to specific branches, such as main. GitHub handles the setup of the application in their virtual environment, installs the necessary dependencies, and executes the tests. These environments also support Docker and Testcontainers, making them ideal for running the integration tests.
To create a CI/CD pipeline, you can add a configuration file, .github/workflows/main.yml, to your repository's root directory. The file defines the workflow for running tests and managing the pipeline.
This configuration sets up a GitHub Actions workflow triggered manually or on every push to the main branch. The int-tests job, running on an ubuntu-latest environment, prepares the necessary tools and executes integration tests. It uses actions/checkout to get the code, actions/setup-node to set up Node.js (v18), and pnpm/action-setup for the Pnpm package manager. Dependencies are installed with pnpm install, and tests run using pnpm test:int.
The workflow stops immediately if any test or setup step fails, ensuring only valid changes are merged. GitHub's virtual environments support tools like Docker and Testcontainers, making integration testing straightforward.
By automating tests with GitHub Actions, you maintain code quality, prevent regressions, and ensure a stable, reliable application.
In this article, we've covered a production-tested approach for setting up integration tests in a Nest.js application. We demonstrated how to leverage Testcontainers to create a reliable and isolated test environment, mock external dependencies effectively, and utilise Jest and Supertest for robust testing. Additionally, we showed how to automate your CI/CD workflow using GitHub Actions, ensuring a stable and maintainable application deployment process.
Fully working example of the integration tests and CI/CD setup discussed in this article, are provided in repository: GitHub Repository
Feel free to explore the code, experiment with the setup, and adapt the provided practices to suit your specific project requirements. Happy testing! 🚀
Author:
Stanisław Kurzyp