1. Blog
  2. Software Development
  3. NodeJS Snapshot Testing: An Essential Guide to Improving UI Consistency and Code Stability
Software Development

NodeJS Snapshot Testing: An Essential Guide to Improving UI Consistency and Code Stability

Learn how to use NodeJS Snapshot Testing to quickly and easily test your code. Get the most out of your development process with this powerful tool!

BairesDev Editorial Team

By BairesDev Editorial Team

BairesDev is an award-winning nearshore software outsourcing company. Our 4,000+ engineers and specialists are well-versed in 100s of technologies.

17 min read

Featured image

If you’re reading this, chances are, you know something about web development. And if you are in the web development world, you probably know what NodeJS is. But in case you don’t: NodeJS is a JavaScript runtime environment that allows us to run JavaScript code outside of the browser. It’s a great tool for building complex web applications due to its nonblocking, asynchronous nature. It’s also ideal for building APIs.

Testing is an important part of the development process. Here, we’ll look at one 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 UI, or buggy products. The best way to avoid these problems is to test.

Here, we’ll build a simple todo application and test it with snapshot testing.

Setting up the Database

Before starting, as we are going to write a real API for this tutorial, let’s set up our database. For the tutorial, we are going to use MongoDB Atlas. So, let’s go to cloud.mongodb.com and get our connection string. We’ll need it later. Our connection string will look similar to this one => mongodb+srv://<username>:<password>@cluster0.45hj6.mongodb.net/?retryWrites=true&w=majority.

Note that you will need to change the username and password to maintain the connection.

Env Variables

While maintaining our connection, we keep our username and password hidden. We’ll create a new file called .env and add our connection string to a variable named MONGO_URI. It would look something like => MONGO_URI=mongodb+srv://<username>:<password>@cluster0.45hj6.mongodb.net?retryWrites=true&w=majority.

We’re using environment variables in our application. Right now, we cannot do anything with it. But soon, we’ll install dotenv npm package and read this variable in our application.

package.json Configuration

For our own convenience, we’ll add scripts to the package.json file in our Node JS IDE. So, let’s open it and add the following:

  "scripts": {

    "test": "jest --detectOpenHandles --forceExit",

    "snap": "jest --updateSnapshot --detectOpenHandles --forceExit",

    "start": "node index.js",

    "dev": "nodemon index.js"

  },

Start and dev commands are for launching our application. We’ll install the Nodemon package for the dev command. We’ll install Jest, too. Here, the difference between test and snap commands is that the snap command updates the snapshot. We’ll see what that means while we’re writing our snapshot tests. We’re also using –forceExit flag for exiting the test suite after the tests are done.

Building the Todo application

To understand snapshot testing, we first need to build our application. It would be great if we had a CRUD application, and for that matter, we’re going to build a todo application that will have the properties of getting, adding, updating, and deleting a todo item.

We’ll begin by creating a new folder called “todo-app,” before entering that folder and running npm init -y. This creates a new package.json file. Though we’re building a simple todo app, we should always follow best practices and divide our application into different parts. So, we’ll create folders to divide our code via running mkdir controllers db mdels routes tests if we’re on Linux terminal.

Now, we need to install the dependencies we’ll be using. Let’s run the following command on the root of our application npm i express mongoose jest mongodb nodemon supertest dotenv –save. This will install the dependencies we need to build our application. We’ll use Express for firing up our server, MongoDB for the database, Mongoose for interacting with MongoDB, and Nodemon for watching our server without restarting. dotenv will help us with hiding sensitive data, and Jest and SuperTest will help us with testing our application.

Now that we’ve installed the required dependencies, let’s create a new file index.js by running touch index.js on the terminal and start coding.

Setting up the server

Look at the following code snippet for 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;

Even though there are comments on the code, let’s go over what’s going on.

First, while importing the dependencies, you’ll realize we’re importing connectDB from ./db/connect. That’s because we’re going to connect to our database in a different file. We’ll create that file shortly.

Second, we’re importing todoRoutes from ./routes/todoRoutes. This is also because we’re going to write our routes there.

After using the routes via app.use(“/todos”, todoRoutes);, we’re setting up the port and starting the server. We’re also exporting the app so we can use it in our tests.

Connecting to the Database

Since we want to separate our concerns, inside the db folder, we’ll 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;

Till will get mongoose, and we can connect to the database. In the index.js file, the last function was named 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 see, connectDB is imported here with the MONGO_URI we’ve saved to the .env file previously. Now that our server can connect to the database, it’s time to create a model.

Creating the Model

We’ll go inside the routes directory and build a new file called todoModel.js. We’ll populate that 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 todos will only have a name. The ID will be generated automatically by MongoDB. Here,  we’re exporting the schema with the name “Todo”. We’ll 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’ll go into the controllers folder and start a file named todoControllers.js. Controllers will require the model to function, 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 get the todos once they’re populated.

const Todo = require("../models/todomodel");




const getAllTodos = 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 getAllTodos async function, we want to get all the todos. For our remaining functions, we’ll use the same async/await try/catch syntax as it simplifies the code readability and makes it easier to debug.

Under the above code, we add 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 = {

  getAllTodos,

  createTodo,

  updateTodo,

  deleteTodo,

};

Creating, updating, and deleting the todo follows the same pattern as creating one, but we need to do something else: export these functions. We’re going to use them in our routes.

Creating the Routes

To create routes, we’ll go into the routes folder, create a file named todoRoutes.js, and insert the following code:

const express = require("express");

const router = express.Router();




const {

  getAllTodos,

  createTodo,

  updateTodo,

  deleteTodo,

} = require("../controllers/todoControllers");




router.route("/").get(getAllTodos).post(createTodo);




router.route("/:id").patch(updateTodo).delete(deleteTodo);




module.exports = router;

Now, we’re requiring express and router from express. Next, we’re importing the functions we’ve created and exported in the controllers. Then, we’re using the router to specify what route will call what function.

In our case, the base route will call the getAllTodos function in the case of a get request, and create Todo in the case of a post request. For patch and delete, we need to specify the id of the specific todo we want to update or delete. That’s why we’re using  /: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 makes sense.

If we run the server and test it via Postman or Insomnia, we’ll be able to make 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 intended.

Testing the Code

After meticulously crafting our application, the next crucial step is to ensure its reliability and effectiveness through rigorous testing.

Snapshot Test

Snapshot testing is different from standard testing. Also, its’s important to note that while it’s generally used with frontend technologies like ReactJs, it can be helpful in backend development, too. What is it, exactly? To understand snapshot testing, it’s helpful to first understand testing itself.

In software development, there are different testing methods, ranging from unit tests to end-to-end tests. There are also tools for writing tests using those methods like Jest, Mocha, Chai, Cypress and more.

Here, we’ll be using Jest. Normally, when we’re writing tests with Jest, there are certain things we’re looking for. We want to write a test that will check whether the code works as intended.

Think of the following example: since we’re building a CRUD application, we might want to check whether the patch method works as expected. Say there’s a todo “Buy candles” and, via a patch request, we want to change it into “Buy lighter”. In the test suite, we would write a test that will check if the todo was changed as intended or not. We would “expect” the todo in question to be “Buy lighter.” If it is, the test will pass. If it’s not, the test will fail.

Now, snapshot testing is different. Instead of expecting a certain behavior and initiating a pass/fail situation according to that, we take snapshots of our code in its state at a given moment and compare it to the previous snapshot. If there’s a difference, the test fails. If there’s no difference, the test passes.

This helps reduce possibility of unwanted changes. If there is a change, debugging now would be much easier.

Now, let’s code with a typical snapshot test case. We’ve already installed Jest and SuperTest, which is another tool that will help us test API requests.

Writing the Snapshot Test

First, we go the the tests folder we’ve created before and add the index.test.js file. Jest will automatically find this file. 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 required dependencies. Afterward, we define two methods: beforeEach and afterEach. These will be executed before and after each test. In the beforeEach method, we’re connecting to the database. In the afterEach method, we’re dropping the database and closing the connection. Now, we’ll write our first test under those lines:

describe("GET /todos", () => {

  it("should return all todos", async () => {

    const res = await request(app).get("/todos");

    // expect(res.statusCode).toEqual(200);

    // expect(res.body).toHaveProperty("todos");

    expect(res.body).toMatchSnapshot();

  });

});

Now, we’ll run npm run test on the terminal. This will correspond to jest –detectOpenHandles –forceExit as we’ve defined it in the package.json scripts. Note the committed lines are how we’d normally test the API response. But since we’re doing snapshot testing, we use a different approach with the toMatchSnapshot keyword.

After running the npm run test command, if you look at the tests folder, you’ll realize that there’s another folder named __snapshots__ inside it. That folder has a file named index.test.js.snap. If you open that file, you’ll see it contains:

// Jest Snapshot v1, https://goo.gl/fbAQLP




exports[`GET /todos should return all todos 1`] = `

{

  "todos": [],

}

`;

This means we’ve succesfully taken a snapshot of the current application state. Since we haven’t yet posted any todos, 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’s a difference, the test fails. If there’s no difference, the test passes. Let’s try it. In index.test.js, we’ll add the following test:

describe("POST /todos", () => {

  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 todo called “Buy candles.” Now, take a snapshot of the current state of the application. Let’s run npm run test again and see what happens. The tests passes. But if you look at the index.test.js.snap file, you’ll see it’s changed:

// Jest Snapshot v1, https://goo.gl/fbAQLP




exports[`GET /todos should return all todos 1`] = `

{

  "todos": [],

}

`;




exports[`POST /todos should create a new todo 1`] = `

{

  "todo": {

    "__v": 0,

    "_id": "646dba457c9da2bc152c498a",

    "name": "Buy candles",

  },

}

`;

Let’s rerun the tests and see what happens. Now, the tests fail. If you check MongoDB atlas and look at your collection, you’ll see there are two “Buy candles” todos, with different IDs. But in the snapshot file, we only had one. This is why it fails. It compares the state of the application that the snapshot was taken with the current one and shows the changes. If you look into your terminal, you’ll 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’ll 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 index.test.js.snap file and see what’s inside, we should see this:

// Jest Snapshot v1, https://goo.gl/fbAQLP




exports[`GET /todos should return all todos 1`] = `

{

  "todos": [

    {

   "__v": 0,

   "_id": "646dba457c9da2bc152c498a",

   "name": "Buy candles",

    },

    {

   "__v": 0,

   "_id": "646dba747fda9c2f1fb94a7d",

   "name": "Buy candles",

    },

  ],

}

`;




exports[`POST /todos should create a new todo 1`] = `

{

  "todo": {

    "__v": 0,

    "_id": "646dbcb461df5575a4a63bf1",

    "name": "Buy lighter",

  },

}

`;

Let’s look at another example. Snapshot testing is especially useful in cases where there might be unexpected changes. For example, there’s a chance that one of the todos might get 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 todo in our collection. We gave it by hardcode. 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, https://goo.gl/fbAQLP




exports[`DELETE /todos/:id should delete a todo 1`] = `

{

  "todo": {

    "__v": 0,

    "_id": "646dba457c9da2bc152c498a",

    "name": "Buy candles",

  },

}

`;

exports[`GET /todos should return all todos 1`] = `

{

  "todos": [

    {

   "__v": 0,

   "_id": "646dba457c9da2bc152c498a",

   "name": "Buy candles",

    },

    ...

    ...

The ID that we’ve deleted is still in the snapshot. We need to update it. If we run npm run snap and look at the snapshot file, we should see the todo with the specified ID has gone. From here, we can continue playing around with our application and see whenever it has changed.

Benefits of Snapshot Testing

There are several less obvious benefits of conducting snapshot testing on nodeJS applications. Some of them are:

  • Regression Testing: Snapshot testing is great at ensuring any change you’ve done doesn’t break your application. You can run the tests and see if there’s any unexpected change in the application.
  • API Contract Verification: Snapshot testing is helpful for confirming the contract between your frontend and backend isn’t broken. By taking snapshots of your API responses, you can ensure the frontend is getting the data it expects.
  • Documentation: By taking snapshots, you can communicate the state of your application and what kind of data should return in which scenario to your teammates.
  • Collaborative Development: Snapshot testing can be beneficial in communication between frontend and backend developers. With snapshots, frontend 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 your changes don’t alter anything unwanted.

Conclusion

Here, we’ve learned how to conduct snapshot testing on nodeJS applications, install and configure Jest, write tests, and take snapshots of the current state of the application. We’ve also reviewed the benefits of snapshot testing. Now, you should have a clearer picture of what a snapshot test entails and why it aids the development process.

If you enjoyed this article, you may enjoy;

FAQ

What libraries are commonly used for Snapshot Testing in Node.js?

Jest is one of the most popular testing libraries for snapshot testing in Node.js. 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 do not unexpectedly alter your UI components. It’s especially useful for large and complex applications where it can be hard to manually verify the UI after each change. However, snapshot testing should not be the only testing strategy as it doesn’t 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 the reference snapshot for subsequent tests.

What should I do if my snapshot test fails due to a snapshot mismatch?

If a test fails due to a snapshot files mismatch, that means the current state of the UI or other output being tested does not match the stored snapshot values. You should 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 not intentional, you will need to debug the cause of the unexpected change.

Tags:
BairesDev Editorial Team

By BairesDev Editorial Team

Founded in 2009, BairesDev is the leading nearshore technology solutions company, with 4,000+ professionals in more than 50 countries, representing the top 1% of tech talent. The company's goal is to create lasting value throughout the entire digital transformation journey.

Stay up to dateBusiness, technology, and innovation insights.Written by experts. Delivered weekly.

Related articles

Contact BairesDev
By continuing to use this site, you agree to our cookie policy and privacy policy.