Blazing Fast Testing in Node with MongoDB
Look ma, no mocks!
What is MongoDB?
MongoDB is a document-based NoSQL database, and is a popular choice for applications in the Node ecosystem. In a traditional relational database, such as MySQL, data is stored as rows and columns within tables. MongoDB, however, stores documents, such as BSON objects (binary JSON), which live inside a collection.
There are many benefits to this model, such as high performance, availability, and dynamic schemas. There are also challenges, such as slow joins across collections, and a lack of simple transactions. This post is about testing in Node with MongoDB, however, and not about why you should or should not use MongoDB, so I’ll leave it to the pundits on Hacker News to hash that out.
Automated testing
Automated testing involves breaking down a program into smaller pieces, and subjecting each piece to a series of tests, which are themselves programs. Tests are usually run periodically, often after every change to the source code. A few benefits include the following:
- Reduces production bug density 40% - 90%. Fewer bugs in production result in a dramatic decrease in maintenance time and costs.
- Ensures your code continues to work as the system grows and is modified–tests reduce future cost.
- Improves team velocity by establishing a fast-feedback loop. Passing tests provide multiple levels of confidence and will allow all members of the team to fearlessly develop, refactor, and merge code.
- Ensures that the code does what it says it does.
- Provides “Living Documentation” of how the system works.
While automated tests can add 15-35% in initial development time, in the long run, they provide an inexpensive way to test your entire system after every change, and before involving your QA team (if you are lucky enough to HAVE a QA team). I highly recommend a robust test suite for any production application.
Unit testing & mocks
Unit testing is a particular type of automated testing that focuses on small pieces of your program (the “System Under Test”), as opposed to other systems it may interact with, but which are not being explicitly tested.
To isolate the behavior of the SUT, mocks are used in place of the real dependencies to simulate (“mock”) the behavior of its dependencies. This is useful because the real objects are often impractical to incorporate into the test—they may require extensive setup or significantly increase the test’s run time. Network requests and database queries are often great candidates for mocks, but they come with a few tradeoffs.
What if you want to test code that is database-centric, such as a data model whose primary purpose is to interact with the database?
You could mock the database and make assertions on how you call it, but the level of confidence that provides is very low. Alternatively, you could use a real database in your tests, but that will be slow and require more infrastructure to manage, such as bringing up the database in a separate Docker container or cloud-hosting environment.
Neither feels like a great option.
mongodb-memory-server
Say hello to mongodb-memory-server. This wonderful package programmatically downloads and spins up a real MongoDB server and holds the data in memory directly from Node, which is much more convenient than using an external Docker container or cloud-hosted database. This allows your tests to use a real database, but without suffering the usual downsides of slow connection speeds and more infrastructure.
Because the MongoDB server it creates has such a low memory footprint (each instance takes about 7MB of memory), you can simultaneously spin up multiple, independent databases and run your tests in parallel. Though subsequent runs will be very fast, the first time you run this package, it will be slow because it downloads the MongoDB binaries to a local cache.
System Under Test (“SUT”)
The following is an example data model from an application that uses MongoDB. We’ll use it as our “SUT” for the remaining examples.
export default class Product { constructor(db) { this.db = db; this.collectionName = "products"; this.collection = db.collection(this.collectionName); } /** * Find a specific product document by ID * @param {ObjectID} productId */ findById(productId) { return this.collection.findOne({ _id: productId }); } /** * Find a list of product documents by IDs * @param {ObjectID[]} ids */ findByIds(ids) { return this.collection.find({ _id: { $in: ids } }).toArray(); } /** * Find a list of product documents with the provided brand * @param {string} brandName * @param {MongoSort} [sort] */ findByBrand(brandName, sort = {}) { return this.collection .find({ brand: brandName }) .sort(sort) .toArray(); } /** * Return a custom serialized product object * @param {ObjectID} productId */ async serialize(productId) { const product = await this.findById(productId); const relatedIds = product.relatedProducts.map(({ _id }) => _id); const relatedProducts = await this.findByIds(relatedIds); return { id: product._id.toString(), model: product.modelNum, sku: `${product.brand}-${product.modelNum}`, title: product.name, brandName: product.brand, price: product.salePrice, listPrice: product.msrp, discount: product.salePrice > product.msrp ? product.salePrice - product.msrp : null, relatedProducts: relatedProducts.map(relatedProduct => ({ id: relatedProduct._id.toString(), title: relatedProduct.name, brandName: relatedIds.brand })) }; } }
Before we write any code, let’s think about what we want to test.
The first two methods, findById
and findByIds
, are fairly straightforward. We want to verify that they return the document(s) associated with the provided ID(s).
The third method, findByBrand
, is a little more complicated. It searches the product collection for documents with the “brand” property matching the provided brand name. It also allows an optional sort object, so we should exercise the code with and without a sort.
Finally, the serialize
method returns a “serialized” version of a product document. It renames a few properties, adds a few dynamic properties, and makes additional database queries so that it can return nested related products. We should test that the returned object has the properties and values that we expect, as well as the related products.
Testing algorithm
Now that we have an idea of WHAT we want to test, let’s figure out HOW to test it.
Ideally, each test should run against a real database with real data, and run independently from any other test, even those that are running in parallel (thanks to the delightful Jest). We can accomplish that by following these steps:
-
Before each test suite (a grouping of related individual test cases):
a. Start up an independent MongoDB instance
b. Create a connection to it
-
Before each test case:
a. Instantiate a new Product model (SUT from above)
-
Within each test case:
a. Insert the desired documents and collections into the database
b. Call the method under test (for example,
findByBrand
) with the parameters needed for your desired outcome
c. Make assertions on the result
-
After each test case:
a. Remove any documents, collections, and indexes created in the test case
-
After each test suite:
a. Close the connection to the database
b. Stop the MongoDB instance
As you can see, a number of these steps are repeated many times, so we can create a shared library to help us.
Now we have a game plan! Let’s write some code.
Helper library
We’ll start with the helper library. We can import and use this library in all of our test suites.
import { MongoClient } from "mongodb"; import { MongoMemoryServer } from "mongodb-memory-server"; // Extend the default timeout so MongoDB binaries can download when first run jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; export default class TestDbHelper { constructor() { this.db = null; this.server = new MongoMemoryServer(); this.connection = null; } /** * Start the server and establish a connection */ async start() { const url = await this.server.getConnectionString(); this.connection = await MongoClient.connect( url, { useNewUrlParser: true } ); this.db = this.connection.db(await this.server.getDbName()); } /** * Close the connection and stop the server */ stop() { this.connection.close(); return this.server.stop(); } /** * Delete all collections and indexes */ async cleanup() { const collections = await this.db.listCollections().toArray(); return Promise.all( collections .map(({ name }) => name) .map(collection => this.db.collection(collection).drop()) ); } /** * Manually insert a document into the database and return the created document * @param {string} collectionName * @param {Object} document */ async createDoc(collectionName, document) { const { ops } = await this.db .collection(collectionName) .insertOne(document); return ops[0]; } }
Test suite
Sweet! Now that we have our helper library, let’s move on to our test suite. Remember that we already mentally mapped out what we want to test and the testing algorithm to be used. All that’s left to do is implement it in code.
We’re going to use Jest as our test runner, but if you use another test runner (such as Mocha or tape), the same concepts apply.
Before and after hooks
Let’s setup an outline, following the algorithm from above.
import TestDbHelper from "../testUtils/testDbHelper"; import Product from "./Product"; const dbHelper = new TestDbHelper(); beforeAll(async () => { await dbHelper.start(); }); afterAll(async () => { await dbHelper.stop(); }); let product; beforeEach(async () => { product = new Product(dbHelper.db); }); afterEach(async () => { await dbHelper.cleanup(); }); // we'll write our tests here...
We use Jest’s beforeAll
and afterAll
methods to start and stop the database server, respectively. We use Jest’s beforeEach
to instantiate a new Product model, and afterEach
to remove any documents, collections, and indexes created in the test case. This outline handles steps 1, 2, 4, and 5. Now, our individual tests cases only need to implement step 3.
Testing findById and findByIds
Recall from earlier that we want to verify that these methods return the document(s) associated with the provided ID(s). Since we have our before and after hooks already setup, we can focus on step 3:
- Insert the desired documents and collections into the database
- Call the method under test (
findById
in this case) with the parameters needed for the desired outcome - Make assertions on the result
// ...all previous code describe("findById", () => { test("should return the correct document by ID", async () => { // 1. Insert the desired documents and collections into the database const { product2 } = await createSampleProducts(); // 2. Call the method under test with the parameters needed for the desired outcome const result = await product.findById(product2._id); // 3. Make assertions on the result expect(result).toMatchObject(product2); }); test("should return null if a document with the provided ID could not be found", async () => { const result = await product.findById("123456789123"); expect(result).toBeNull(); }); }); describe("findByIds", () => { test("should return the correct documents by ID", async () => { const { product1, product3 } = await createSampleProducts(); const result = await product.findByIds([product1._id, product3._id]); expect(result).toMatchObject([product1, product3]); }); test("should return empty array if documents with the provided IDs could not be found", async () => { const result = await product.findByIds(["123456789123"]); expect(result).toEqual([]); }); }); /** * Insert set of sample products into the database */ async function createSampleProducts() { const product1 = await dbHelper.createDoc(product.collectionName, { name: "PLUS Sewing Quilting Machine", modelNum: "B880", brand: "Bernina", salePrice: 349.99, msrp: 329.99, relatedProducts: [] }); const product2 = await dbHelper.createDoc(product.collectionName, { name: "Mechanical Sewing Machine with Foot Pedal", modelNum: "10", brand: "Alphasew", salePrice: 79.99, relatedProducts: [] }); const product3 = await dbHelper.createDoc(product.collectionName, { name: "L460 Overlocker", modelNum: "L460", brand: "Bernina", salePrice: 189.99, relatedProducts: [] }); const product4 = await dbHelper.createDoc(product.collectionName, { name: "Sewing & Embroidery Machine", modelNum: "NQ3600D", brand: "Brother", salePrice: 219.99, msrp: 249.99, relatedProducts: [product1._id, product3._id] }); return { product1, product2, product3, product4 }; }
Voila!
Testing findByBrand
This method searches the product collection for documents with the “brand” property matching the provided brand name. It also allows an optional sort object, so we should exercise the code with and without a sort.
// ...all previous code describe("findByBrand", () => { test("should return matching documents with no sort", async () => { const { product1, product3 } = await createSampleProducts(); const result = await product.findByBrand("Bernina"); expect(result).toEqual([product1, product3]); }); test("should return matching documents with custom sort", async () => { const { product1, product3 } = await createSampleProducts(); const result = await product.findByBrand("Bernina", { salePrice: 1 }); expect(result).toEqual([product3, product1]); // sorted by sale price, ascending }); test("should return empty array if there are no matches", async () => { const { product1, product3 } = await createSampleProducts(); const result = await product.findByBrand("Unknown"); expect(result).toEqual([]); }); });
Testing serialize
This is the most complex method in our Product model, so we’ll need to write a few more tests to make sure we cover all the edge cases.
// ...all previous code describe("serialize", () => { test("should return correct shape", async () => { const { product4 } = await createSampleProducts(); const result = await product.serialize(product4._id); expect(result).toMatchObject({ id: String(product4._id), model: "NQ3600D", title: "Sewing & Embroidery Machine", brandName: "Brother", price: 219.99, listPrice: 249.99, // we'll test these in more detail in another test sku: expect.any(String), discount: expect.any(Number), discountPercent: expect.any(Number), relatedProducts: expect.any(Array) }); }); test("should return the correct SKU", async () => { const { product4 } = await createSampleProducts(); const { sku } = await product.serialize(product4._id); expect(sku).toBe("Brother-NQ3600D"); }); test("should return the correct discount if msrp is higher than sale price", async () => { const { product4 } = await createSampleProducts(); const { discount, discountPercent } = await product.serialize(product4._id); expect(discount).toBe(30); expect(discountPercent).toBe(13); }); test("should return a zero discount if msrp is lower than sale price", async () => { const { product1 } = await createSampleProducts(); const { discount, discountPercent } = await product.serialize(product1._id); expect(discount).toBe(0); expect(discountPercent).toBe(0); }); test("should return a zero discount if msrp is not set", async () => { const { product2 } = await createSampleProducts(); const { discount, discountPercent } = await product.serialize(product2._id); expect(discount).toBe(0); expect(discountPercent).toBe(0); }); test("should return the correct related products", async () => { const { product1, product3, product4 } = await createSampleProducts(); const { relatedProducts } = await product.serialize(product4._id); expect(relatedProducts).toEqual([ { id: String(product1._id), title: "PLUS Sewing Quilting Machine", brandName: "Bernina" }, { id: String(product3._id), title: "L460 Overlocker", brandName: "Bernina" } ]); }); test("should return an empty array if there are no related products", async () => { const { product1 } = await createSampleProducts(); const { relatedProducts } = await product.serialize(product1._id); expect(relatedProducts).toEqual([]); }); });
In conclusion
You’ve now created a robust test suite for the Product model that uses a real database to exercise the code in a way that more closely resembles your production environment (no database mocks). And still, it runs very quickly. On a recent client engagement, we used this technique to run over 700 tests in a little under eight seconds.
Isolating the system under test allows you to write unit tests that focus on small pieces of your program, as opposed to other systems not directly being tested. This enables you to carefully scrutinize the behavior of your SUT.
But in cases where the SUT is database-centric, such as a data model, we have to make a few concessions. We discussed several options available to us, but each comes with tradeoffs of increased runtime, complexity, or confidence.
We then examined mongodb-memory-server, a package that programmatically downloads and spins up a real MongoDB server that holds data in memory, directly from Node. This allows us to write our isolated unit tests with a real database, without the tradeoffs mentioned before.
And most importantly, we can modify and refactor our code with the confidence that our test suite has our backs!
All of the code described in this post can be found in the [companion repository on GitHub.] (https://github.com/FormidableLabs/mongodb-testing)