DEV Community

Cover image for The Easiest Way to Test Node.js Apps with MongoDB: Without Breaking Your Production Database
Gouranga Das Samrat
Gouranga Das Samrat

Posted on

The Easiest Way to Test Node.js Apps with MongoDB: Without Breaking Your Production Database

The Easiest Way to Test Node.js Apps with MongoDB: Without Breaking Your Production Database

I’ve been there, staring at my Node.js app, praying my tests don’t mess up the production MongoDB database. One wrong move, and poof, real user data could vanish.

I was in search of a way to test safely, without risking the live database. Today we are going to see how to use mongodb-memory-server to create isolated, in-memory MongoDB instances for testing. It’s fast, reliable, and keeps your production environment untouched.

Let’s get started.

What Makes mongodb-memory-server Special?

Going through an article, I found a tool that allowed me to spin up a temporary MongoDB instance in memory. No external database, no cleanup headaches. It’s perfect for writing tests that don’t bleed into the live data.

Setting Up the Project

Today we are going to build a simple Node.js app. The stack includes Express for the server, Mongoose for MongoDB interaction, and Jest for testing. The focus is on isolated, repeatable tests.

mkdir node-mongodb-testing
cd node-mongodb-testing
mkdir src
mkdir testing
npm init -y
npm i -D jest supertest mongodb-memory-server @types/express @types/mongoose @types/node
npm i -S express mongoose
Enter fullscreen mode Exit fullscreen mode

Using above commands we are setting up Express for the server, Mongoose for MongoDB models, and mongodb-memory-server for testing.

We are using Jest and Supertest handle the testing framework and HTTP assertions.

Let’s understand why we are using these tools? Express is straightforward, Mongoose simplifies MongoDB queries, and Jest makes testing feel like a conversation with your code.

Next, tweak your package.json to use ES modules and add test scripts:

{
  "type": "module",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand ./testing",
    "start": "node ./src/index.js"
  },
  "jest": {
    "testEnvironment": "node"
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the --runInBand flag we are using in the test script. It allows us to make sure that tests run one at a time. It is important to prevent conflicts because our tests use an in-memory database. Parallel execution would mess things up.

We are also using the testEnvironment flag for jest to use Node.js environment for the tests.

The test script also uses the --experimental-vm-modules flag. This flag enables ES module support. It keeps test code clean and readable. This is a workaround until Jest fully supports ES modules.

Building the Server

Create a src/index.js file for a basic Express server:

import express from "express";
export const app = express();
app.use(express.json());
app.put("/products", (req, res) => {
  console.log(req.body);
  res.status(204).send();
});
if (process.env.NODE_ENV !== "test") {
  app.listen(3000, () => {
    console.log("Server running on port 3000");
  });
  process.on("SIGINT", () => {
    console.log("Server shutting down");
    process.exit(0);
  });
}
Enter fullscreen mode Exit fullscreen mode

In our server code above we are using a NODE_ENV check to conditionally run the server as per Node.js environment. The API runs on port 3000. Tests run through Supertest. Jest sets the NODE_ENV to test during test execution.

Setting Up Database

Let’s move further with setting up the database with our Node.js application. We are going to use Mongoose to handle our database connection. Our app will use the connect method which will set up the database connection on startup. The connection closes when the app shuts down. Tests do something similar but connect to an in-memory database.

Create src/db.js file to connect to database:

import mongoose from "mongoose";
import { MongoMemoryServer } from "mongodb-memory-server";
const mongodb = await MongoMemoryServer.create();
export async function connectToDatabase() {
  if (process.env.NODE_ENV === "test") {
    const uri = mongodb.getUri();
    await mongoose.connect(uri);
    console.log("Connected to in-memory database");
    return;
  }
  try {
    await mongoose.connect("mongodb://localhost:27017/myapp");
    console.log("Connected to database");
  } catch (error) {
    console.error("Error connecting to database", error);
  }
}
export async function disconnectFromDatabase() {
  if (process.env.NODE_ENV === "test") {
    await mongoose.connection.dropDatabase();
    await mongoose.connection.close();
    await mongodb.stop();
  }
  await mongoose.disconnect();
  console.log("Disconnected from database");
}
export async function clearCollections() {
  const collections = mongoose.connection.collections;
  if (process.env.NODE_ENV !== "test") {
    throw new Error("clearCollections can only be used in test environment");
  }
  for (const key in collections) {
    const collection = collections[key];
    await collection.deleteMany();
  }
}
Enter fullscreen mode Exit fullscreen mode

In above example we are using a MongoMemoryServer instance to start a MongoDB server for testing. To separate API behavior from test behavior we are using the NODE_ENV environment variable here. The code adapts to run in either environment.

We have an interesting function here: clearCollections . It allows to wipe all documents from collections. This keeps tests isolated and independent.

We are usingdisconnectFromDatabase function on test completion to drop the database and close the connection.

Setting Up a Data Model

Let’s start with defining our schema. It includes three fields: name, price, and description. The data model handles data storage and retrieval for both the API and tests.

To set up the data model, create a src/product.js file with this code:

import mongoose from "mongoose";
const ProductSchema = new mongoose.Schema({
  name: { type: String, required: true },
  price: { type: Number, required: true },
  description: { type: String },
});
export const ProductModel = mongoose.model("Product", ProductSchema);
Enter fullscreen mode Exit fullscreen mode

Let’s update the server src/index.js file:

import express from "express";
import { connectToDatabase, disconnectFromDatabase } from "./db.js";
import { ProductModel } from "./product.js";
export const app = express();
app.use(express.json());
app.put("/products/:id", async (req, res) => {
  try {
    const product = new ProductModel(req.body);
    await product.validate();
    await ProductModel.updateOne(
      { _id: req.params.id },
      { $set: req.body },
      { upsert: true }
    );
    res.status(204).send(`Product ${req.params.id} updated`);
  } catch (err) {
    console.error(err);
    res.status(400).send(`Error updating product ${req.params.id}`);
  }
});
if (process.env.NODE_ENV !== "test") {
  await connectToDatabase();
  app.listen(3000, () => {
    console.log("Server is running on port 3000");
  });
  process.on("SIGINT", async () => {
    await disconnectFromDatabase();
    process.exit();
  });
}
Enter fullscreen mode Exit fullscreen mode

If the data doesn’t fit the schema, Mongoose validation raises an error. The try block catches it and returns a 400 status code. The upsert option creates a new document if none exists.

Setting up Testing With Jest

We are going to use Jest for executing tests. We can simply run the tests with the npm test command. Here we are using mongodb-memory-server, which means the database operates fully in memory.

To ensure test isolation, collections are cleared between tests. This prevents data from lingering, thanks to the clearCollections function called after each test.

Create a testing/product.test.js file with this code:

import supertest from "supertest";
import { jest } from "@jest/globals";
import { app } from "../src/index.js";
import {
  connectToDatabase,
  disconnectFromDatabase,
  clearCollections,
} from "../src/db.js";
// silence console.log and console.error
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
describe("Product API PUT", () => {
  const productId = "c3fe7eb8076e4de58d8d87c5";
  beforeAll(async () => {
    await connectToDatabase();
  });
  afterAll(async () => {
    await disconnectFromDatabase();
  });
  beforeEach(async () => {
    await clearCollections();
  });
  it("should update a product", async () => {
    const product = {
      name: "Test Product",
      price: 100,
    };
    await supertest(app)
      .put(`/products/${productId}`)
      .send(product)
      .expect(204);
  });
  it("should return 400 if product is invalid", async () => {
    const product = {
      name: "Test Product",
      price: "invalid",
    };
    await supertest(app)
      .put(`/products/${productId}`)
      .send(product)
      .expect(400);
  });
});
Enter fullscreen mode Exit fullscreen mode

In above example we are silencing the unnecessary noise. The database connection is established before tests start and closes after tests finish. To make sure tests run in isolation it clears the collections before each test.

The first test sends a valid product object to the endpoint and checks the response. The second test sends an invalid product object and verifies the response. Mongoose validation triggers an error for schema mismatches, which the test confirms.

Final Takeaway

We built a Node.js app with MongoDB and tested it using mongodb-memory-server. Each test runs in an isolated, in-memory database, keeping your production data safe.

Thank you. Let’s meet again with another cool guide on JavaScript.

Top comments (0)