Unit Testing with Node JS and Jest
Let’s test!
Hi all…You may remember my last article one Node JS right? If you missed it, here we go! It is about setting up a express js project for testing with coverage report!
https://medium.com/p/setting-up-node-js-project-for-testing-with-jest-1dcc6d3ba5a8
I can tell you that, today’s article is a practical example of writing tests with Jest. Since we have unit and integration test running setup already — following the above article, we can start write testing… 😎
I will list the dependencies list first…I’m going to use mongo DB as the data storage. And the below dependencies are important for testing.
- node-mocks-http — mocking request and response
- testcontainers — creating docker based mongo setup
- cross-env — for unified node scripts setup
"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",
"testcontainers": "^9.1.1"
}
Then just replace the scripts with the below ones. I have updated them a bit with cross-env library.
"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:integration": "npm-run-all cov:clean cov:integration",
"test:unit": "npm-run-all cov:clean cov:unit",
"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"
}
Actually in TDD, we should write tests first and then go for development. Initially tests will be failed since we don’t have the functionalities implemented. Gradually we need to enrich the code with logics. But here, I will do in the other way because it’s very hard to write tests first and then code in a article. But it is doable in a video content. 😉
Create Node JS APIs 💥
We can update our dummy project with some APIs. For this, I will create some APIs with product items. Product will have just name and price. I will brief here about the API implementation relevant to each layer. Please note I am not going to include the implementations in detail because it will take the focus of the article out. Our focus is writing tests!
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 result using Postman
{
"products": [
{
"name": "test product 1",
"price": 24
},
{
"name": "test product 2",
"price": 10
}
]
}
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); // will be explained below
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 };
};
API result using Postman
{
"data": {
"acknowledged": true,
"insertedId": "63e0aef195e58f6ac6e17d2f"
}
}
Schema validation — validating request body
I used Joi library for this. Link: https://joi.dev/api/?v=17.7.0
It will throw errors if we variolate the expected request body comparing with predefined schema for a domain object.
validateSchema method
=======================
const validateSchema = async (params, schema) => {
return schema.validateAsync(params);
}
const productSchema = Joi.object({
name: Joi.string().required(),
price: Joi.number().required()
});
Middleware — handling errors
const errorHandler = () => {
return (err, req, res, next) => {
return res
.status(err.statusCode || StatusCodes.INTERNAL_SERVER_ERROR)
.json(err);
};
};
app.use(errorHandler());
Write Controller Unit Tests 💥
Let’s jump to write tests since we have our APIs. There are few things to keep in mind while writing tests.
- Unit tests should not be combined with multiple layers.
- If the testing unit is calling another unit, we should mock the dependent units rather than using it directly.
Ex: If controller is calling service, service should be mocked while writing tests for controller
- We must isolate testing unit and just focus on only its behavior.
🎬🎬🎬
Let’s start with controller layer…
Usually a controller takes 2 or 3 inputs right? They are request, response and next callback.
So if we are going to unit test controller, we should mock all the other things! In this case Jest provides several awesome ways to mock a method and return a dummy result! 😍
Unit Test — Get All Products
Our controller is calling to service layer. So, we have to mock service. How to do it?
Jest comes in handy in such scenarios…Look at the below code!
jest.mock('../../../../src/modules/products/service');
This is the very basic statement to inform jest, that we are going to mock this file.
Then we tell jest to spy on getProductsList method in service layer!
const productService = require('../../../../src/modules/products/service');
const mockFetchProduct = jest.spyOn(productService, 'getProductsList')
🔴 Mocking statement must be placed before spying! DO NOT change the order!!!!
Now we need to implement mocking result for our service method. For that I will use some dummy products array.
// dummy products list
const mockProductsArray = [
{ name: 'dummy 1', price: 10 },
{ name: 'dummy 2', price: 20 },
{ name: 'dummy 3', price: 30 }
];
// result is defined in the format we return in APIs
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
// mocking method implementation
mockFetchProduct.mockImplementation(mockProductList);
Now this service method will return an array of 3 products. This way, we isolated service method which is called by controller!
Next is mocking request and response. We can do it like this..
const httpMocks = require('node-mocks-http');
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
Let’s write complete test!
it('should get products list', async () => {
// mock
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
mockFetchProduct.mockImplementation(mockProductList);
await productController.getProducts(request, response);
expect(mockFetchProduct).toHaveBeenCalledTimes(1);
expect(response.statusCode).toEqual(200);
expect(response._isEndCalled()).toBeTruthy();
expect(response._getJSONData().products.length).toEqual(3);
});
We just call the controller method. Then we check several things in this test.
- Make sure service mock is called through controller— mockFetchProduct
- Make sure controller is returning 200 OK result
- Make sure we have the same number of products in the result as we expected
Run the test using your favorite IDE — mine is IntelliJ
All done right?? We have unit tested controller method successfully! 😎
Unit Test — Create Product
In this API call also we call to service layer…That is the normal way of writing layered architecture code. We already have the service mock.
We just need to again spy on create product and provide its dummy implementation like this.
// spy on method in service
const mockSaveProduct = jest.spyOn(productService, 'saveProduct')
// dummy product
const mockProductObject = { name: 'dummy 1', price: 10 };
// result is defined in the format we return in APIs
const mockProduct = jest.fn(async () => {
return { data: mockProductObject };
});
// mocking method implementation
mockSaveProduct.mockImplementation(mockProduct);
Okay!!! Now we have separated service logic from controller layer. We have to mock request and response again. But here, we need to provide a request body since this is a POST call.
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
request.body = {
name: 'dummy 1',
price: 10
};
Let’s write complete test!
it('should create a product', async () => {
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
request.body = {
name: 'dummy 1',
price: 10
};
const mockProduct = jest.fn(async () => {
return { data: mockProductObject };
});
mockSaveProduct.mockImplementation(mockProduct);
await productController.createProduct(request, response);
expect(mockSaveProduct).toHaveBeenCalledTimes(1);
expect(mockSaveProduct).toHaveBeenCalledWith(mockProductObject);
expect(response.statusCode).toEqual(201);
expect(response._isEndCalled()).toBeTruthy();
expect(response._getJSONData().data.name).toEqual('dummy 1');
});
We do these assertions…
- Make sure service mock is called through controller — mockSaveProduct
- Make sure controller is returning 201 OK result
- Make sure service method has been called with the exact payload passed from controller.
- Make sure we have the expected name in the result for the mocked product.
Complete Controller Test — controller.unit.test.js
const httpMocks = require('node-mocks-http');
const { describe, it, expect, afterAll } = require('@jest/globals');
const { mockProductObject, mockProductsArray } = require("../../../mocks/products");
jest.mock('../../../../src/modules/products/service');
const productService = require('../../../../src/modules/products/service');
const productController = require('../../../../src/modules/products/controller');
const mockSaveProduct = jest.spyOn(productService, 'saveProduct')
const mockFetchProduct = jest.spyOn(productService, 'getProductsList')
describe('product controller - unit tests', () => {
it('should get products list', async () => {
// mock
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
mockFetchProduct.mockImplementation(mockProductList);
await productController.getProducts(request, response);
expect(mockFetchProduct).toHaveBeenCalledTimes(1);
expect(response.statusCode).toEqual(200);
expect(response._isEndCalled()).toBeTruthy();
expect(response._getJSONData().products.length).toEqual(3);
});
it('should create a product', async () => {
const response = httpMocks.createResponse();
const request = httpMocks.createRequest();
request.body = {
name: 'dummy 1',
price: 10
};
const mockProduct = jest.fn(async () => {
return { data: mockProductObject };
});
mockSaveProduct.mockImplementation(mockProduct);
await productController.createProduct(request, response);
expect(mockSaveProduct).toHaveBeenCalledTimes(1);
expect(mockSaveProduct).toHaveBeenCalledWith(mockProductObject);
expect(response.statusCode).toEqual(201);
expect(response._isEndCalled()).toBeTruthy();
expect(response._getJSONData().data.name).toEqual('dummy 1');
});
});
afterAll(() => {
jest.clearAllMocks();
});
All clear right guys??? 😄 These tests will ensure that out controller API methods are working fine lonely!
Write Service Unit Tests 💥
Now we are going one step inside in the layers. We are going to unit test our service layer.
Our service is calling to dao layer. So, we have to mock dao. How to do it? The same procedure we followed above can be used. Start with mocking, extend it to spying and then mocking implementation.
jest.mock('../../../../src/modules/products/dao');
// import dao and start spying
const productDao = require('../../../../src/modules/products/dao');
const mockGetProductsFromDb = jest.spyOn(productDao, 'getProductsFromDb')
const mockCreateProductInDb = jest.spyOn(productDao, 'saveProductToDb')
// mock implementation for fetchproducts fetch from DB
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
mockGetProductsFromDb.mockImplementation(mockProductList);
// mock implementation for save products fetch to DB
const mockProduct = jest.fn(async () => {
return {
data: {
acknowledged: true,
insertedId: '63de5b5604e5b6c3284ce52c'
}
};
});
mockCreateProductInDb.mockImplementation(mockProduct);
In each test, we can use the relevant mocked methods…
Unit Test — Get All Products
We call the service method and get the response. We don’t have to check status here since this is not the web layer. We can assert the results.
it('should return products', async () => {
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
mockGetProductsFromDb.mockImplementation(mockProductList);
const response = await productService.getProductsList();
expect(mockGetProductsFromDb).toHaveBeenCalledTimes(1);
expect(response.products).toBeDefined();
expect(response.products.length).toEqual(3);
});
And then we do several checks.
- Make sure dao mock is called through service— mockGetProductsFromDb
- Make sure service is returning a Non NULL object
- Make sure we have the same number of products in the result as we expected
Unit Test — Create Product
it('should create a product', async () => {
const mockProduct = jest.fn(async () => {
return {
data: {
acknowledged: true,
insertedId: '63de5b5604e5b6c3284ce52c'
}
};
});
mockCreateProductInDb.mockImplementation(mockProduct);
const response = await productService.saveProduct(mockProductObject);
expect(mockCreateProductInDb).toHaveBeenCalledTimes(1);
expect(response.data).toBeDefined();
expect(response.data.acknowledged).toBeTruthy();
});
We do several checks.
- Make sure dao mock is called though service— mockCreateProductInDb
- Make sure service is returning a Non NULL object
- Make sure we have the correct key value in the response
Complete Service Test —service.unit.test.js
const { describe, it, expect, afterAll } = require('@jest/globals');
const { mockProductsArray, mockProductObject } = require("../../../mocks/products");
jest.mock('../../../../src/modules/products/dao');
const productDao = require('../../../../src/modules/products/dao');
const productService = require('../../../../src/modules/products/service');
const mockGetProductsFromDb = jest.spyOn(productDao, 'getProductsFromDb')
const mockCreateProductInDb = jest.spyOn(productDao, 'saveProductToDb')
describe('product service - unit tests', () => {
it('should return products', async () => {
const mockProductList = jest.fn(async () => {
return { products: mockProductsArray };
});
mockGetProductsFromDb.mockImplementation(mockProductList);
const response = await productService.getProductsList();
expect(mockGetProductsFromDb).toHaveBeenCalledTimes(1);
expect(response.products).toBeDefined();
expect(response.products.length).toEqual(3);
});
it('should create a product', async () => {
const mockProduct = jest.fn(async () => {
return {
data: {
acknowledged: true,
insertedId: '63de5b5604e5b6c3284ce52c'
}
};
});
mockCreateProductInDb.mockImplementation(mockProduct);
const response = await productService.saveProduct(mockProductObject);
expect(mockCreateProductInDb).toHaveBeenCalledTimes(1);
expect(response.data).toBeDefined();
expect(response.data.acknowledged).toBeTruthy();
});
});
afterAll(() => {
jest.clearAllMocks();
});
All clear right guys??? 😄 Now we wrote tests for service layer also.😍
Run the tests!
What’s remaining then? DAO layer…Let’s go!!!!!
Write DAO Unit Tests 💥
This is our data access layer which is directly talking to database! So, we have follow slightly different flow for unit testing this layer.
Key points to remember:
- We have to communicate with a database — should not be the real DB
- We have to perform real operations with that DB — since no other layers after DAO, we don’t need to mock.
How to setup a database then for tests ❓
Here we bring Test Containers into the ground😍. You can refer more details here: https://github.com/testcontainers/testcontainers-node
Testcontainers is a NodeJS library that supports tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
So, we will start a docker container for mongo DB while running the dao unit test! Before that we need to see how we created the Mongo Connection in our project…This method is called in server.js while starting up the server.
const connect = async () => {
const uri = process.env.DB_URL || `mongodb://localhost:27017`;
const dbName = process.env.DB_NAME || `test`;
const mongoClient = new MongoClient(uri);
return new Promise((resolve) => {
mongoClient.connect();
db = mongoClient.db(dbName);
resolve(db);
console.info(`Successfully connected to the mongodb cluster: ${uri}/${dbName}`);
});
};
See…I am taking an ENV variable called “DB_URL” to construct the database connection! So, we should replace this Env variable value with our test container URL while starting DAO test!
Sounds weird?? Please follow me…It is not that hard!!!
I will create a separate file to setup environment like this. Then we will be able to reuse the code wherever we want — specially in integration tests.
test/environment/setup.js
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
}
What we have done here???
- We start a docker container using public AWS ECR mongo image!
- Then we get the URL from that container — the outer PORT will be a random number
- Then we return the resulting container details with its URL
Then we can take mongo URL just by calling this method anywhere.
createMongoContainer().mongoUrl
Let’s get back to dao test…You know Jest provides numerous lifecycle method hooks to assist in setting up tests. So, I’m going to use one of them called — beforeAll
This will run as the very first thing while running the dao unit test file.
beforeAll(async () => {
try {
const env = await createMongoContainer();
container = env.container;
process.env.DB_URL = env.mongoUrl;
await connect();
await saveProductToDb({ name: 'dummy product 1' });
await saveProductToDb({ name: 'dummy product 2' });
await saveProductToDb({ name: 'dummy product 3' });
} catch (err) {
console.log('Error: ', err);
throw err;
}
});
See…I’m changing the environment mongo URL — env.DB_URL using the container retrieved.
Then I call mongo connect method…So, logic will be executed with this new URL and connection will be established with docker container. Not the actual DB anymore…. 😍
Then I insert 3 dummy products into this container database. We are good to go for writing dao tests now!
Unit Test — Get All Products
Data insertions is done before tests. Then we can write the test assuming that the data is already in database.
it('should fetch products from DB', async () => {
let productsFromDb = await getProductsFromDb();
expect(productsFromDb).toBeDefined();
expect(_.isArray(productsFromDb.products)).toBeTruthy();
expect(productsFromDb.products.length).toEqual(3);
});
We do several checks here.
- Make sure we get Non NULL result
- Make sure service is returning an array of products — using lodash
- Make sure we have the correct number of records in database
Unit Test — Create Product
We can provide a sample payload to dao method and test the behavior. It will return the response with acknowledged: true for successful act.
it('should save a product to db', async () => {
let payload = {
name: 'dummy product'
}
let savedProduct = await saveProductToDb(payload);
expect(savedProduct.data).toBeDefined();
expect(_.has(savedProduct.data, 'acknowledged')).toBeTruthy();
});
We do several checks here.
- Make sure we get Non NULL result
- Make sure we have the correct key value in the response
We can add one more assertion. After this test case run, we should have 4 products, if we successfully saved data in DB(previous 3 + new 1). Let’s test that too.
let productsFromDb = await getProductsFromDb();
expect(productsFromDb.products.length).toEqual(4);
This should pass! Run the tests and see the results…
All good right?? Let’s look at the complete code snippet.
Complete DAO Test — dao.unit.test.js
const { saveProductToDb, connect, getProductsFromDb } = require('../../../../src/modules/products/dao');
const { createMongoContainer, closeConnection } = require('../../../environment/setup');
const _ = require('lodash');
const { describe, it, expect, afterAll, beforeAll } = require('@jest/globals');
let container;
jest.setTimeout(1000000);
beforeAll(async () => {
try {
const env = await createMongoContainer();
container = env.container;
process.env.DB_URL = env.mongoUrl;
await connect();
await saveProductToDb({ name: 'dummy product 1' });
await saveProductToDb({ name: 'dummy product 2' });
await saveProductToDb({ name: 'dummy product 3' });
} catch (err) {
console.log('Error: ', err);
throw err;
}
});
describe('product dao - unit tests', () => {
it('should fetch products', async () => {
let productsFromDb = await getProductsFromDb();
expect(productsFromDb).toBeDefined();
expect(_.isArray(productsFromDb.products)).toBeTruthy();
expect(productsFromDb.products.length).toEqual(3);
});
it('should save a product to db', async () => {
let payload = {
name: 'dummy product'
}
let savedProduct = await saveProductToDb(payload);
expect(savedProduct.data).toBeDefined();
expect(_.has(savedProduct.data, 'acknowledged')).toBeTruthy();
let productsFromDb = await getProductsFromDb();
expect(productsFromDb.products.length).toEqual(4);
});
});
afterAll(async () => {
await closeConnection(container);
});
After all tests run, we must close the DB connection by stopping the docker container we started. I have used afterAll hook in Jest for this.
Now you can use the scripts we created at the beginning. Among them, test:unit script will run all your unit tests.
npm run test:unit
You will get the below result…
Let’s try the aggregate test script...
npm run test
The output should be generated into /coverage/summary folder. If you followed by previous article, you should have the test setup now. 👉 https://medium.com/p/setting-up-node-js-project-for-testing-with-jest-1dcc6d3ba5a8
If you go into summary folder and open index.html, you should see the test coverage report generated. 😍 See how descriptive it is…
All done! ✅
Huh! ❤️ I know article became lengthy. 😅 I had to cover a lot guys…That was the reason. I hope I tried my best to explain. You should feel awesome with Jest. 😜
Try to get used to Test Driven Development! It will make our lives easier in a long term.
I will meet you again with another Testing article! Mostly it will be based on integration tests.😉
Bye guys! ❤️