BattlefyBlogHistoryOpen menu
Close menuHistory

How to write testable code with MongoDB

Ronald ChenOctober 12th 2021

At Battlefy, we want to maintain the integrity of esports for everything we build. This translate into our day to day as writing tests for our code. There are many ways to write so-called testable code, but some ways are more maintainable than others.

This post will describe how we write testable & maintainable Node.js code at Battlefy with MongoDB.

Play problem

Let’s make up some business rules to act as our play problem. This will make the code samples easier to understand.

We’ll be implementing tournament check-in for teams. The business rule is, only teams with all players in good standing are allowed to check-in.

One way a player fails to be in good standing is disqualification.

Direct implementation

Given the play problem, the most direct implementation of team check-in is to see if any players on the team has been disqualified and use that as a guard to prevent check-in.

Note that the top-level export is a factory function. This allows us to not worry about the details on how the MongoDB connection was initialized during server start-up. This simplifies our code greatly as our dependency to upon db (MongoDB database reference) is injected as a parameter.

This implementation is a bit contrived as if we were to implement this for real we would roll-up the disqualification of a player up to the team standing itself. We would also use MongoDB transactions, but that API only gets in the way of the example.

The code seems very straightforward, but what happens when we try to write a test for it?

Not so direct test

There are two major scenarios for the play problem, but we’ll only be implementing the scenario when a team has a disqualified player.

To set up this scenario we need to create a fake disqualification for a player and then assert that we are thrown an error when attempting to check-in.

Looking at the code we see that we need db.collection('playerDisqualifications').find(..).toArray() to return a Promise with a fake disqualification.

We write our tests with Jest at Battlefy and for this scenario it would look something like…

Yikes, what is going on? The reason why the test code is so long is due to MongoDB driver’s fluent API. This is the simplest implementation of mocking out db.collection('playerDisqualifications').find(..).toArray().

Holup you say, “Jest provides the ability to mock functions. You should just use Jest better.”

Jest isn’t great at mocking fluent APIs. I tried and the code is worse in my opinion. The problem you need a stable reference to the Jest mock functions in order to assert them later, which makes the test hard to follow.

Note there are some other problems. There is a random assertion on line 10. This violates the arrange-act-assert pattern. We want all the assertions at the end. I could write more code to move the assertion down, but this isn’t addressing the root cause.

Fixing the level of abstraction

The real reason why the test looks so bad is because our original direct implementation had two levels of abstraction mixed together.

  1. Implementing the MongoDB queries/commands
  2. Implementing business rules

The business rule depends on MongoDB, but not how to use the MongoDB driver. We need the ability get all the disqualified players, but don’t care exactly how to use the MongoDB driver’s fluent API to do so.

The fix is to introduce the Repository pattern. What this looks like in our play problem is to move all the MongoDB queries/commands into its own file, and inject the whole repository into our new implementation of checkIn.

Note the improved code readability! The reader no longer needs to reverse engineer what db.collection('playerDisqualifications').find(..).toArray() is trying to do. They can just read the method name.

Better code, easy test

Now when we write a test for checkIn, we do not need to concern ourselves with MongoDB driver’s fluent API. We can directly mock out the repository methods with Jest mock functions.

The test is now more readable as it uses vanilla Jest mocks/assertions. We also restored the arrange-act-assert pattern.

But what about the repository code? Shouldn’t we test that as well? Consider what it would mean to test the repository code.

If we wrote an unit test, we would end up mocking MongoDB driver’s fluent API again. But what is even the point of that? That does not test anything at all.

In order to really test the repository code we would need to write an integration test. The integration test would spin up real MongoDB server, run one of the repository methods, then assert the change in the database.

But in our case the our repository code doesn’t have any complexity that merits an integration test. Bugs in the repository code would be caught pretty quickly during QA and unlikely to be a source of bugs once fixed. However do keep an eye on the repository code. Once it gets complex enough, write an integration test.

Resilient under pressure

In our repository code, checkInTeam is implemented as an insert into the checkIns collection. Imagine for reasons, it has been decided check-ins are now to be recorded on the teams collection as the checkedInAt field.

The new repository code would look like…

How would we update the business logic and test? Well, there’s nothing to change in this case. We didn’t change the API of checkInTeam method, only its implementation. Our business logic didn’t depend on the implementation.

While we would still need to QA the change with a real database, this gives us incredible ability to refactor a ton of code. We are backed with the confidence of solid tests. Tests that ensure our business logic is correct and not irrelevant implementation details.

Beyond MongoDB

The Repository pattern is useful beyond MongoDB. The pattern is often used with SQL databases to keep the SQL separate from the business logic. This provides an opportunity to summarize the intent of the SQL with a good method name.

HTTP calls and external library calls (eg. AWS SDK calls) should also be abstracted away in the repository code. This makes it possible to migrate from one database/external service to another. Such migrations tend to be very mess, but at least you have a safe space in repository code to do so initially. Then eventually expose new repository methods that better expose how the new database/external service actually works.

Do you want to improve your testing skills? You’re in luck, Battlefy is hiring.

2022

Powered by
BATTLEFY