Learn how to use NodeJS Snapshot Testing to test your code quickly and easily. Make the most of your development process with this powerful tool!
If you're reading this, chances are you know something about web development. And if you are in the world of web development, you probably know what NodeJS is. But in case not: NodeJS is a JavaScript execution environment that allows us to execute JavaScript code outside the browser. It is a great tool for creating complex web applications due to its asynchronous and non-blocking nature. It is also ideal for building APIs.
Testing is an important part of the development process. Here we'll look at an important type: snapshot testing.
Testing in Software Development
For anyone involved in Node JS development services, writing tests in software development or following TDD (Test Driven Development) practices is critical. No one wants to ship faulty code, broken user interface, or buggy products. The best way to avoid these problems is to test.
Here we will build a simple task application and test it with snapshot tests.
Configuring the database
Before we start, since we're going to write an actual API for this tutorial, let's set up our database. For the tutorial, we will use MongoDB Atlas. So let's go to cloud.mongodb.com and get our connection string. We will need this later. Our connection string will look like this => mongodb+srv://
Please note that you will need to change your username and password to maintain the connection.
Environmental variables
While maintaining our connection, we keep our username and password hidden. We will create a new file called .env and add our connection string to a variable called MONGO_URI. It would be something like => MONGO_URI=mongodb+srv://
We are using environment variables in our application. Right now, we can't do anything with it. But soon we will install the dotenv npm package and read this variable in our application.
Package.json configuration
For our convenience, we will add scripts to the package.json file in our Node JS IDE. So let's open it up and add the following:
"scripts": { "test": "jest --detectOpenHandles --forceExit", "snap": "jest --updateSnapshot --detectOpenHandles --forceExit", "start": "node index.js", "dev": "nodemon index.js" },
The start and dev commands are used to start our application. We will install the Nodemon package for the dev command. We will install Jest as well. Here the difference between test and snap command is that snap command updates the snapshot. We'll see what this means as we write our snapshot tests. We are also using the –forceExit flag to exit the test suite after testing is complete.
Building the Todo app
To understand snapshot testing, we first need to build our application. It would be great if we had a CRUD application and in this case let's build a to-do application which will have the properties of getting, adding, updating and deleting a to-do item.
We'll start by creating a new folder called “todo-app”, before going into that folder and running npm init -y. This creates a new package.json file. Even though we are building a simple task app, we should always follow best practices and divide our app into different parts. Therefore, we will create folders to divide our code running route tests mkdir controllers db mdels if we are on the Linux terminal.
Now, we need to install the dependencies we will use. Let's run the following command in the root of our npm application i express mongoose jest mongodb nodemon supertest dotenv –save. This will install the dependencies needed to build our application. We will use Express to start our server, MongoDB for the database, Mongoose to interact with MongoDB, and Nodemon to monitor our server without restarting. dotenv will help us hide sensitive data, and Jest and SuperTest will help us test our application.
Now that we have installed the necessary dependencies, let's create a new index.js file by running touch index.js in the terminal and start coding.
Configuring the server
Note the following code snippet from the index.js file:
//importing dependencies const express = require("express"); const connectDB = require("./db/connect"); require("dotenv").config ; //importing routes const todoRoutes = require("./routes/todoRoutes"); //creating an express app const app = express; /* A middleware that parses the body of the request and makes it available in the req.body object. */ app.use(express.json); /* This is the root route. It is used to check if the server is running. */ app.get(" (req, res) => { res.status(200).json({ alive: "True" }); }); /* This is the route that handles all the todo routes. */ app.use("/todos", todoRoutes); const port = process.env.PORT 3000; const start = async => { try { await connectDB(process.env.MONGO_URI); app.listen(port, console.log(`Server is listening on port ${port}...`)); } catch (error) { console.log(error); } }; start; module.exports = app;
While there are comments on the code, let's go over what's going on.
First, when importing the dependencies, you will notice that we are importing connectDB from ./db/connect. This is because we are going to connect to our database in a different file. We will create this file soon.
Second, we are importing todoRoutes from ./routes/todoRoutes. This is also because we will write our routes there.
After using the routes via app.use(“/todos”, todoRoutes);, we are configuring the port and starting the server. We are also exporting the application so that we can use it in our tests.
Connecting to the database
As we want to separate our concerns, inside the db folder we will create a file called connect.js and write the following code:
const mongoose = require("mongoose"); mongoose.set("strictQuery", false); const connectDB = (url) => { return mongoose.connect(url, {}); }; module.exports = connectDB;
Until we get the mongoose and we can connect to the database. In the index.js file, the last function was called start:
const start = async => { try { await connectDB(process.env.MONGO_URI); app.listen(port, console.log(`Server is listening on port ${port}...`)); } catch (error) { console.log(error); } };
As you can see, connectDB is imported here with the MONGO_URI that we saved earlier in the .env file. Now that our server can connect to the database, it's time to create a model.
Creating the model
We'll go into the routes directory and build a new file called todoModel.js . We fill this file with the following code:
const mongoose = require("mongoose"); const todoSchema = mongoose.Schema({ name: { type: String, required: true }, }); module.exports = mongoose.model("Todo", todoSchema);
Our all will only have one name. The ID will be automatically generated by MongoDB. Here we are exporting the schema with the name “Todo”. We will use this name when we want to interact with the database.
Creating the controllers
Now that we have a model, we can create the controllers. We will go to the controllers folder and launch a file called todoControllers.js. The controllers will require the model to work, and since the model required Mongoose, we can use Mongoose commands in the controllers. Let's start by getting all the todos. Now, nothing exists in the database. We're just writing the logic that will receive them all once they're filled out.
const Todo = require("../models/todomodel"); const getAllAll = async (req, res) => { try { const todos = await Todo.find({}).exec ; res.status(200).json({ todos }); } catch (error) { res.status(500).json({ msg: error }); } };
First we import the model and then, with the asynchronous function getAllTodos, we want to get all the todos. For our remaining functions, we will use the same async/await try/catch syntax as it simplifies code readability and makes debugging easier.
Under the above code, we have added the following lines:
const createTodo = async (req, res) => { try { const todo = await Todo.create(req.body); res.status(201).json({ todo }); } catch (error) { res.status(500).json({ msg: error }); } }; const updateTodo = async (req, res) => { try { const todo = await Todo.findOneAndUpdate({ _id: req.params.id }, req.body, { new:true, }).exec ; res.status(200).json({ todo }); } catch (error) { res.status(500).json({ msg: error }); } }; const deleteTodo = async (req, res) => { try { const todo = await Todo.findOneAndDelete({ _id: req.params.id }).exec ; res.status(200).json({ todo }); } catch (error) { res.status(500).json({ msg: error }); } }; module.exports = { getAllAll, createTodo, updateTodo, deleteTodo, };
Create, update, and delete all follow the same pattern as creating one, but we need to do something else: export these functions. We will use them on our routes.
Creating the Routes
To create routes, we will go to the routes folder, create a file called todoRoutes.js and insert the following code:
const express = require("express"); const router = express.Router; const { getAllAll, createTodo, updateTodo, deleteTodo, } = require("../controllers/todoControllers"); router.route(" router.route("/:id").patch(updateTodo).delete(deleteTodo); module.exports = router;
Now, we are demanding express and express router. Next, we will import the functions we created and export into controllers. So we are using the router to specify which route will call which function.
In our case, the base route will call the getAllTodos function in case of a get request and create Todo in case of a post request. To fix and delete, we need to specify the ID of the specific task we want to update or delete. This is why we are using the /:id syntax. Now we can export the router and use it in our index.js file.
Now the lines const todoRoutes = require(“./routes/todoRoutes”); and app.use(“/todos”, todoRoutes); in the index.js file it makes sense.
If we run the server and test via Postman or Insomnia, we can do CRUD operations. But we want to do more: we want to test our code using snapshots so we can see if our code is working as expected.
Testing the Code
After meticulously crafting our app, the next crucial step is to ensure its reliability and effectiveness through rigorous testing.
Snapshot Test
Snapshot testing is different from standard testing. Additionally, it is important to note that while it is generally used with front-end technologies like ReactJs, it can also be useful in back-end development. What is this exactly? To understand snapshot testing, it's helpful to first understand the testing itself.
In software development, there are different testing methods, from unit testing to end-to-end testing. There are also tools for writing tests using methods like Jest, Mocha, Chai, Cypress and more.
Here, we will use Jest. Typically when we write tests with Jest, there are certain things we look for. We want to write a test that verifies that the code works as expected.
Consider the following example: as we are building a CRUD application, we may want to check if the patch method works as expected. Let's say there is a task “Buy candles” and through a patch request we want to change it to “Buy lighter”. In the test suite, we would write a test that would check whether the task was changed as intended or not. We would “expect” the task at hand to be “Shop Lighter”. If so, the test will pass. Otherwise, the test will fail.
Now snapshot testing is different. Instead of expecting a certain behavior and initiating a pass/fail situation accordingly, we take snapshots of our code in its state at a certain point in time and compare it to the previous snapshot. If there is a difference, the test fails. If there is no difference, the test passes.
This helps reduce the possibility of unwanted changes. If there is a change, debugging would now be much easier.
Now let's code with a typical snapshot test case. We have already installed Jest and SuperTest, another tool that will help us test API requests.
Writing the snapshot test
First, let's go to the tests folder we created before and add the index.test.js file. Jest will find this file automatically. Now, inside the file, we will start by writing the following lines of code:
const mongoose = require("mongoose"); const request = require("supertest"); const app = require("../index"); const connectDB = require("../db/connect"); require("dotenv").config ; /* Connecting to the database before each test. */ beforeEach(async => { await connectDB(process.env.MONGO_URI); }); /* Dropping the database and closing connection after each test. */ afterEach(async => { // await mongoose.connection.dropDatabase; await mongoose.connection.close; });
We start by importing the necessary dependencies. Later, we define two methods: beforeEach and afterEach. They will be run before and after each test. In the beforeEach method, we are connecting to the database. In the afterEach method, we drop the database and close the connection. Now, we will write our first test along these lines:
describe("GET /all", => { it("should return all todos", async => { const res = await request(app).get("/todos"); // expect(res.statusCode).toEqual(200); // expect(res.body).toHaveProperty("all"); expect(res.body).toMatchSnapshot ; }); });
Now, we will run npm run test in the terminal. This will correspond to jest –detectOpenHandles –forceExit as we defined in the package.json scripts. Note that the committed lines are how we would normally test the API response. But since we're testing snapshots, we use a different approach with the toMatchSnapshot keyword.
After running the npm run test command, if you look at the tests folder, you will notice that there is another folder called __snapshots__ inside it. This folder has a file called index.test.js.snap. If you open this file, you will see that it contains:
// Jest Snapshot v1, exports(`GET /todos should return all todos 1`) = ` { "all": , } `;
This means we have successfully taken a snapshot of the current state of the application. As we haven't posted them all yet, the todos array returns empty. Now, whenever we make a change and run the tests, it will compare the current state of the application with this snapshot. If there is a difference, the test fails. If there is no difference, the test passes. We will try. In index.test.js, we will add the following test:
describe("POST /all", => { it("should create a new todo", async => { const res = await request(app).post("/todos").send({ name: "Buy candles", }); expect(res.body).toMatchSnapshot ; }); });
This test will create a new task called “Buy Candles”. Now, take a snapshot of the current state of the application. Let's run the npm run test again and see what happens. The tests pass. But if you look at the index.test.js.snap file, you will see that it has changed:
// Jest Snapshot v1, exports(`GET /todos should return all todos 1`) = ` { "all": , } `; exports(`POST /todos should create a new todo 1`) = ` { "all": { "__v": 0, "_id": "646dba457c9da2bc152c498a", "name": "Buy candles", }, } `;
Let's redo the tests and see what happens. Now the tests fail. If you check the MongoDB atlas and look at your collection, you will see that there are two “Buy Candles” all, with different IDs. But in the snapshot file, we only had one. That's why it fails. It compares the state of the application where the snapshot was taken with the current one and shows the changes. If you look in your terminal you will see the test details.
We can update our snapshot. Let's change “Buy candles” to “Buy lighter” for convenience and run npm run snap this time. You may remember that this command corresponds to jest –updateSnapshot –detectOpenHandles –forceExit in package.json scripts. The tests pass. It will also update the snapshot file. If we go back to the index.test.js.snap file and look at what's inside, we should see this:
// Jest Snapshot v1, exports(`GET /todos should return all todos 1`) = ` { "all": ( { "__v": 0, "_id": "646dba457c9da2bc152c498a", "name": "Buy candles", }, { "__v": 0, "_id": "646dba747fda9c2f1fb94a7d", "name": "Buy candles", }, ), } `; exports(`POST /todos should create a new todo 1`) = ` { "all": { "__v": 0, "_id": "646dbcb461df5575a4a63bf1", "name": "Buy lighter", }, } `;
Let's look at another example. Snapshot testing is especially useful in cases where there may be unexpected changes. For example, there is a chance that one of the all will be deleted. Let's comment out the post request for convenience and add another test to our index.test.js file:
describe("DELETE /todos/:id", => { it("should delete a todo", async => { const res = await request(app).delete("/todos/646ce381a11397af903abec9"); expect(res.body).toMatchSnapshot ; }); });
Note the ID in delete(“/todos/646ce381a11397af903abec9”); is the ID of the first task in our collection. We supply by strict code. Now if we run npm run test it should fail and show us the differences. In the terminal, the tests should pass, and when we look at our snapshot file, we should see this:
// Jest Snapshot v1, exports(`DELETE /todos/:id should delete a todo 1`) = ` { "all": { "__v": 0, "_id": "646dba457c9da2bc152c498a", "name": "Buy candles", }, } `; exports(`GET /todos should return all todos 1`) = ` { "all": ( { "__v": 0, "_id": "646dba457c9da2bc152c498a", "name": "Buy candles", }, ... ...
The ID we deleted is still in the snapshot. We need to update it. If we run npm run snap and look at the snapshot file, we see that the task with the specified ID is gone. From here we can continue playing with our app and see if it has changed.
Benefits of Instant Testing
There are several less obvious benefits to performing snapshot testing on nodeJS applications. Some of them are:
- Regression Testing: Snapshot testing is great for ensuring that any changes you make don't break your application. You can run the tests and see if there are any unexpected changes to the application.
- API contract checking: Snapshot testing is useful to confirm that the contract between the frontend and backend is not broken. By taking snapshots of API responses, you can ensure that the front end is getting the data it expects.
- Documentation: By taking snapshots, you can communicate the state of your application and what type of data should be returned in which scenario to your teammates.
- Collaborative Development: Snapshot testing can be beneficial in communication between front-end and back-end developers. With snapshots, front-end developers can anticipate and handle any changes in the backend.
- Refactoring and code changes: In code refactoring, snapshots provide a safety net. You can ensure that your changes do not change anything unwanted.
Conclusion
Here, we learn how to perform snapshot testing on nodeJS applications, install and configure Jest, write tests, and take snapshots of the application's current state. We also review the benefits of instant testing. You should now have a clearer idea of what snapshot testing involves and why it aids the development process.
If you liked this article, you might like;
- Unlock the power of Node.JS microservices
- Change Node Version: A Step-by-Step Guide
- Node JS Cache: Increasing Performance and Efficiency
- Unlocking the Power of Websocket Nodejs
Common questions
What libraries are commonly used for snapshot testing in Node.js?
Jest is one of the most popular testing libraries for Node.js snapshot testing. It allows you to easily create and manage snapshots of your components, making it simple to identify any unexpected changes. Another library that can be used for snapshot testing is Ava, although it is not as widely used as Jest.
When should I use Snapshot Testing in my Node.js project?
Snapshot testing is best used when you want to ensure that changes to your code don't unexpectedly change UI components. It's especially useful for large, complex applications where it can be difficult to manually check the user interface after each change. However, snapshot testing should not be the only testing strategy as it does not guarantee the correctness of the business logic, only the consistency of the UI.
How is a reference snapshot file created?
A reference snapshot file is typically created the first time you run a snapshot test. The testing framework (like Jest) will automatically create a snapshot of the current state of the UI component or other output being tested and store it in a file. This test file will then be used as a reference snapshot for subsequent testing.
What should I do if my snapshot test fails due to a snapshot mismatch?
If a test fails due to a snapshot file mismatch, it means that the current state of the UI or other output being tested does not match the stored snapshot values. You must first investigate to determine whether the change was intentional or the result of a bug. If the change was intentional and the new state is correct, you can update the snapshot as described above. If the change was unintentional, you will need to debug the cause of the unexpected change.