Integration Testing with Node JS and Jest
Let’s write tests without mocking
Hi guys! I brought another article on Test Driven Development. It is about Integration Testing with Node JS. This includes how to use Jest and also Test Containers library to write tests.
In contrast to Unit Testing, we actually write tests for real methods without mocking in Integration Testing. We does not have to mock. If you google the word, you would be ending up with a definition like this…
….. type of software testing in which the different units, modules or components of a software application are tested as a combined entity
Where we can actually do an integration testing in out projects?
- API layer — perform real API calls through code and assert
- DAO Layer — manipulate some test data with DAO methods and assert
- Service Layer — perform real service calls and assert
The list goes on…..
Hope you have followed my previous article on Unit testing. If not, you can read it from here: https://salithachathuranga94.medium.com/unit-testing-with-node-js-and-jest-5dba6e6ab5e
I will be using the same set of REST APIs for writing integration tests.
1️⃣ If we are performing real actions on our code, what will happen if we do data related manipulations? Will it be saved in the actual database we use in our project? If it is saved in really, then we pollute our database right? 😮
So, the ultimate solution is Test Containers… 😍
Test containers library is dealing with docker containers. Whatever the environment/server we run it, it will execute the code on docker containers. As an example, if we use MongoDB as out primary database, we can connect a Mongo test container for running tests. Then all data manipulations will be on that.
More info:
https://node.testcontainers.org/quickstart/
https://www.npmjs.com/package/testcontainers
2️⃣ How to perform API calls using code within the test case? And assert the results?
Solution would be SuperTest… 😍
More info: https://www.npmjs.com/package/supertest
Setup scripts and libraries
If you followed my previous article, you should have the test setup now. If you don’t know how to setup, follow here 👉 https://medium.com/p/setting-up-node-js-project-for-testing-with-jest-1dcc6d3ba5a8
So, here cov:integration script will pickup the integration test config and run Jest command. Then test:integration will generate the test results.
"scripts": {
"dev": "nodemon --exec node src/server.js",
"start": "node src/server.js",
"test": "npm-run-all --aggregate-output cov:clean -p cov:unit cov:integration -s cov:summary-preset cov:report",
"test:unit": "npm-run-all cov:clean cov:unit",
"test:integration": "npm-run-all cov:clean cov:integration",
"cov:clean": "rimraf .nyc_output && rimraf coverage",
"cov:unit": "cross-env NODE_ENV=test jest --forceExit --colors --coverage -c jest.config.unit.js",
"cov:integration": "cross-env NODE_ENV=test jest --runInBand --forceExit --colors --coverage -c jest.config.integration.js",
"cov:summary-preset": "mkdir .nyc_output && cp coverage/unit/coverage-final.json .nyc_output/unit.json && cp coverage/integration/coverage-final.json .nyc_output/integration.json",
"cov:report": "nyc report --reporter html --reporter lcov --report-dir ./coverage/summary"
}
Let’s start writing tests. Let me list the dependencies needed.
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"joi": "^17.7.0",
"lodash": "^4.17.21",
"mongodb": "^5.0.0"
},
"devDependencies": {
"@jest/globals": "^29.3.1",
"cross-env": "^7.0.3",
"jest": "^29.3.1",
"node-mocks-http": "^1.12.1",
"nodemon": "^2.0.20",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"supertest": "^6.3.3",
"testcontainers": "^9.1.1"
}
I’m going to write integration tests for API layer and DAO layer. For recap, I will put some code details on the APIs I have. Please follow the previous article to get it completely.
API — get all products
route => call controller
===========================
router.get('/products', getProducts); // controller method
controller => call service
===========================
const getProducts = async (req, res) => {
const result = await getProductsList(); // service method
res.status(200).json(result);
}
service => call dao
===========================
const getProductsList = async () => {
try {
return await getProductsFromDb(); // dao method
} catch (e) {
throw e;
}
}
dao => call database
============================
const getProductsFromDb = async () => {
const products = await db
.collection(MongoCollections.PRODUCTS)
.find({})
.project({ _id: 0 })
.toArray();
return { products };
};
API — create a product
route => call controller
===========================
router.post('/products', createProduct); // controller method
controller => call service
===========================
const createProduct = async (req, res, next) => {
const params = req.body;
if (_.isEmpty(params)) {
const err = Error('Request body is missing', StatusCodes.BAD_REQUEST);
res.status(400).json(err);
} else {
try {
const result = await saveProduct(params); // service method
res.status(201).json(result);
} catch (err) {
next(err);
}
}
}
service => call dao
===========================
const saveProduct = async (params) => {
try {
await validateSchema(params, productSchema);
return await saveProductToDb(params); // dao method
} catch (e) {
throw e;
}
}
dao => call database
============================
const saveProductToDb = async (data) => {
const result = await db.collection(MongoCollections.PRODUCTS).insertOne(data);
return { data, result };
};
Setup Test Containers 💥
After installing test containers library, we have to have a common single place to setup our mongo container for testing. It can be reused in all integration tests.
I have created a setup.js file inside test folder to support this.
Code for setup.js file.
const { GenericContainer } = require('testcontainers');
const { get } = require('lodash');
const createMongoContainer = async () => {
const env = {
container: null,
mongoUrl: null,
};
try {
env.container = await new GenericContainer('public.ecr.aws/docker/library/mongo:latest')
.withExposedPorts(27017)
.start();
env.mongoUrl = `mongodb://localhost:${env.container.getMappedPort(27017)}`;
} catch (err) {
console.error(`Error while setting up:. ${JSON.stringify(err)}`);
throw new Error(err);
}
return env;
}
const closeConnection = async (container) => {
setTimeout(async () => {
await container.stop();
}, 5000);
process.env.DB_URL = null;
};
module.exports = {
createMongoContainer,
closeConnection
}
I have used public aws mongo docker container to create test container. So, we can call createMongoContainer method in beforeAll hook in test files. Then we will have a dummy mongo to save and retrieve data.
Integration Tests — API Layer 💥
We can write logic to call our APIs using Supertest library. We can bind our express server into supertest and execute an application context on test setup.
beforeAll(async () => {
try {
const mockProductsArray = [
{ name: 'dummy 1', price: 10 },
{ name: 'dummy 2', price: 20 },
{ name: 'dummy 3', price: 30 }
];
const env = await createMongoContainer();
container = env.container;
process.env.DB_URL = env.mongoUrl;
await connect();
for (const product of mockProductsArray) {
await saveProductToDb(product);
}
} catch (err) {
console.log('Error: ', err);
throw err;
}
});
In our mongo connection code, we use environment variable value: process.env.DB_URL and process.env.DB_NAME to connect to database. So, for tests to connect to our test containers database, we have to override the DB URL env value.
Complete API Test —products.api.integration.test.js
const request = require('supertest');
const app = require('../../src/app');
const _ = require('lodash');
const { connect, saveProductToDb } = require('../../src/modules/products/dao');
const { createMongoContainer, closeConnection } = require('../environment/setup');
const { describe, it, expect, afterAll, beforeAll } = require('@jest/globals');
const { mockProductsArray, mockProductObject } = require("../mocks/products");
let container;
jest.setTimeout(1000000);
beforeAll(async () => {
try {
const env = await createMongoContainer();
container = env.container;
process.env.DB_URL = env.mongoUrl;
await connect();
for (const product of mockProductsArray) {
await saveProductToDb(product);
}
} catch (err) {
console.log('Error: ', err);
throw err;
}
});
describe('product API integration tests', () => {
it('GET /products - should fetch products', async () => {
return await request(app).get('/products')
.then(res => {
expect(res.body.products).toBeDefined();
expect(_.isArray(res.body.products)).toBeTruthy();
expect(res.body.products.length).toEqual(3);
expect(res.statusCode).toEqual(200)
});
});
it('POST /products - should create a product', async () => {
const res = await request(app).post('/products').send(mockProductObject)
expect(res.body.data).toBeDefined();
expect(res.body.data.name).toEqual(mockProductObject.name);
expect(res.statusCode).toEqual(201)
expect(res.body.result).toBeDefined();
expect(res.body.result.acknowledged).toBeTruthy();
});
});
afterAll(async () => {
await closeConnection(container);
});
Explanations:
You can see how I’m creating the test app context.
request(app).get(‘/products’)
This will initiate API layer context with test containers. I have inserted 3 dummy products into our test DB using beforeAll hook. So, then we know that we have 3 products ready before running test cases.
In first test, I’m calling the GET API and assert the results in different ways. We can check results are empty or not / results format / results length / response code and etc. I have done all these assertions.
In second test, I have called POST API to create a new product. There also we can assert all the above mentioned information to make sure our test is all good.
So, after running these tests, test container will be destroyed since I’m calling closeConnection method from setup.js inside afterAll hook, to cleanup connections.
Integration Tests — DAO Layer 💥
Now we are going to call actual DAO methods and see the results in out tests. Here also we have beforeAll setup to initiate container and insert some data. Then we will be able to manipulate this data suing DAO layer methods directly.
Complete DAO Test — products.dao.integration.test.js
const { beforeAll, afterAll, describe, it, expect} = require('@jest/globals');
const { createMongoContainer, closeConnection, insertData } = require('../environment/setup');
const { connect, saveProductToDb, getDatabase, getProductsFromDb } = require('../../src/modules/products/dao');
const { mockProductsArray, mockProductObject, mockProduct} = require('../mocks/products');
jest.setTimeout(1000000);
let container;
const mockProductsArray = [
{ name: 'dummy 1', price: 10 },
{ name: 'dummy 2', price: 20 },
{ name: 'dummy 3', price: 30 }
];
const mockProduct = { name: 'mock product', price: 100 };
beforeAll(async () => {
try {
const env = await createMongoContainer();
container = env.container;
process.env.DB_URL = env.mongoUrl;
await connect();
for (const product of mockProductsArray) {
await saveProductToDb(product);
}
} catch (err) {
console.log('Error: ', err);
throw err;
}
});
describe('products dao integration tests', () => {
it('should return products saved in DB', async () => {
const result = await getProductsFromDb();
expect(result).toBeDefined();
expect(result.products.length).toEqual(mockProductsArray.length);
});
it('should save product to DB', async () => {
const savedProduct = await saveProductToDb(mockProduct);
const productsList = await getProductsFromDb();
expect(savedProduct).toBeDefined();
expect(savedProduct.data.name).toEqual(mockProduct.name);
expect(savedProduct.result.acknowledged).toBeTruthy();
expect(productsList).toBeDefined();
expect(productsList.products.length).toEqual(mockProductsArray.length + 1);
});
});
afterAll(async () => {
await closeConnection(container);
});
In first test, I’m calling the method to retrieve all products in DB. We can check results are empty or not / results format / results length / response code and etc. as I told previously. If DAO method works fine, we should get the same number of data we have inserted.
In second test, I have called method to save a product to DB. After this insertion, we should one more data inside DB. We can assert the result format with our expectation. I have asserted that also.
After all, I have closed DB connections.
Run Tests 💥
Now you can use the scripts we created at the beginning. Among them, test:integration script will run all your integration tests.
npm run test:integration
You will get the below result on the terminal.
All done! ✅
So, this is one way how to write integration tests in NodeJS projects. There could be more ways. But I personally follow this way. However, article became lengthy. 😅 I hope I could explain as much as I can. You should feel awesome with Jest and Test Containers. 😜
I’m in love with Test Driven Development! So, why don’t you?😜
Bye guys! Will come back with another super stuff! ❤️