javascript

Testing MongoDB in Node with the MongoDB Memory Server

Camilo Reyes

Camilo Reyes on

Testing MongoDB in Node with the MongoDB Memory Server

In this post, we'll run through testing a Node-MongoDB app, step by step.

You can test MongoDB using mongodb-memory-server, an in-memory version of MongoDB that runs independently of a persistent database. A freshly spun-up mongod process starts at roughly 7 MB of memory, providing a lightweight, self-contained environment for running tests.

Let's get going!

About MongoDB Memory Server

mongodb-memory-server creates an in-memory MongoDB instance that can connect to any ODM, such as mongoose. This allows tests to run in a separate, non-persistent database, ensuring isolation from your main database.

We'll set up an app with a simple Express server and a single endpoint that accepts a PUT request to save a product to a database. Let's say that the product has a name, price, and description. The test will hit the endpoint with a product object and validate the request.

Our app will connect to a MongoDB instance running in Docker, and the test will connect to the test database running in memory. We'll run integration tests with supertest, and the test database collection will be created and destroyed for each test. Our emphasis will be on the integration tests, so the API itself will remain minimal.

To get started with the Docker image for MongoDB, run the following command:

Shell
docker run -d -p 27017:27017 --name mongodb mongo:latest

This allows you to connect to the MongoDB instance on localhost:27017. Under normal operation, the app connects to this instance, whereas the test suite runs against an isolated in-memory database.

Feel free to follow along with this post or clone the repo from GitHub.

Prerequisites

First off, create the project folder and run npm init -y to create a package.json file. Then install the dependencies:

Shell
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

The @types packages are typescript definitions. You can use a tool like the TypeScript Language Server Protocol to validate your code as you write it. Many editors support the TypeScript Language Server and it is a great way to get instant feedback.

The dependencies listed under the -D flag are for testing. The jest package is the test runner, supertest is the library that will make HTTP requests to the API, and mongodb-memory-server is the in-memory database that will be used for testing.

The remaining dependencies are for the API itself. express is the server, and mongoose is the ODM that will connect to the MongoDB instance. The idea is to keep the API minimal, and focus on testing.

Open the package.json file and add the following line:

JSON
"type": "module"

This tells Node to use ES modules, which are more modern than CommonJS modules.

Then, add the following scripts:

JSON
"scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand ./testing", "start": "node ./src/index.js" }

Also, configure the jest object in the package.json file:

JSON
"jest": { "testEnvironment": "node" }

The --runInBand flag runs tests sequentially. This is necessary because our tests will connect to the in-memory database, and running them in parallel will cause conflicts. The testEnvironment flag is set to node to run the tests in the Node environment.

The --experimental-vm-modules flag supports ES modules to keep the test code easier to digest. This is a temporary solution until Jest offers native support for ES modules.

Optionally, you can set up ESLint via the following command:

Shell
npm init @eslint/config@latest

For our project, we simply pick the default options.

Our API

Create a src/index.js file and add the following code:

JavaScript
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); }); }

This is a placeholder for the API. We will differentiate between the API and the tests using the NODE_ENV check. The API will run on port 3000, and the tests will run via supertest. Jest will set the NODE_ENV environment variable to test when running tests.

DB Connection

Our database connection will be handled by the mongoose library. The app will call the connect method to initialize the connection when it starts. The connection will be closed when the app shuts down.

Our tests will perform a similar operation, but instead of connecting to the MongoDB instance running in Docker, they will connect to the in-memory database.

To support both the MongoDB instance running in Docker and the in-memory database, create a src/db.js file and add the following code:

JavaScript
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(); } }

The mongodb object is an instance of MongoMemoryServer, which starts a fresh MongoDB server for testing. Note some of the logic is wrapped in an if statement that checks the NODE_ENV variable. This is how we differentiate between the API and the tests. The logic bifurcates and it is capable of running in both environments.

Between each test, the clearCollections function is called to remove all documents from the collections. This is a good practice to ensure the tests are isolated from each other. Note the exception that is thrown if the function is called in the wrong environment.

After the test suite is done, the disconnectFromDatabase function is called to close the connection. This will drop the database and allow the next test suite to run. This technique, coupled with the --runInBand flag, will ensure the tests run smoothly.

Product Schema

The product schema is created using the mongoose library. The schema will have three fields: name, price, and description. The schema creates a model that interacts with the database. This both stores and retrieves data and supports both the API and the tests.

To serve Mongoose data models, create a src/product.js file and add the following code:

JavaScript
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);

Mongoose's Schema object defines the document's structure. This adds a layer of validation to the data. Then, the model method creates a model that interacts with the database. The model creates, reads, updates, and deletes documents.

Go back to the src/index.js file and flesh out the rest of the API. This time, we can connect to the database and use the model to save the product. Replace the contents of the file with the following code:

JavaScript
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(); }); }

To encourage best programming practices, we set the _id field as the URL parameter. This automatically enforces document uniqueness in MongoDB.

The if block differentiates between the API and the tests and this technique is used throughout the codebase. The connectToDatabase function is called to connect to the database when the app starts. The disconnectFromDatabase function is called when the app shuts down, to close the connection.

If the data does not match the schema, the Mongoose validation throws an exception. This is caught in the try block, with a 400 status code returned. The upsert option creates a document if it does not exist.

Jest

Jest is the test runner we'll use to run our tests. The tests will be written in the testing folder and run with the npm test command. Because the tests use mongodb-memory-server, the database runs entirely in memory. To maintain test isolation, collections are explicitly cleared between tests, preventing data from persisting between tests. This is why the clearCollections function is called between each test.

Create a testing/product.test.js file and add the following code:

JavaScript
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); }); });

The console.log and console.error functions are mocked to silence tests, as they can otherwise be noisy. The beforeAll hook connects to the database before the tests run. The afterAll hook disconnects from the database after the tests run. The beforeEach hook clears the collections before each test runs. These hooks ensure that tests are isolated from each other and allow you to run multiple test suites without conflicts.

The first test will hit the endpoint with a valid product object and validate the response. The second test will hit the endpoint with an invalid product object and validate the response. Mongoose validation will throw an exception if the data does not match the schema, and this is verified in the test.

Wrapping Up

In this post, we've run through using the MongoDB Memory Server to test your Node app.

The mongod process started by mongodb-memory-server is lightweight, typically starting at around 7MB when freshly spun up. This setup provides a fast, isolated environment for testing database queries without persisting data.

Happy testing!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Camilo Reyes

Camilo Reyes

Our guest author Camilo is a Software Engineer from Houston, Texas. He’s passionate about JavaScript and clean code that runs without drama. When not coding, he loves to cook and work on random home projects.

All articles by Camilo Reyes

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps