loading...

How to Unit Test Firestore with Jest

wescopeland profile image Wes Copeland ใƒป5 min read

As a Firebase user since 2014, I've found the testing experience to be extremely frustrating with both RTDB and Firestore. I am not the only one who has had this experience. While things have certainly gotten better with local Firebase emulation, they become increasingly difficult the further one strays from the golden path. Now that I've started a new side project that uses Firestore for the back-end APIs, I was determined to figure this out once and for all.

This article assumes you're using Express, TypeScript, Firebase Admin, and already have some working knowledge of Firestore. These examples can be adapted for the standard non-privileged Firebase library.

The Problem

We have an API endpoint that retrieves data from our Firestore NoSQL database and does some work on it. Here is a very basic (and intentionally oversimplified) example:

interface DBProduct {
  name: string;
  price: number;
}

export default async (req: Request, res: Response) => {
  switch (req.method) {
    case 'GET':
      const productsSnapshot = await db
        .firestore()
        .collection('products')
        .orderBy('name', 'desc')
        .get();

      let productCount = 0;

      for (const productDocument of productsSnapshot.docs) {
        productCount += 1;
      }

      return res.status(200).json({ count: productCount });
  }
};

We don't particularly care about testing the internals of Firestore, but there is value in testing our homebrewed logic that runs on the retrieved data. Granted, even though all we're doing above is extrapolating the product count, in a real-world scenario this API function might be doing quite a bit of heavy lifting.

With Firestore's chained API, I had a lot of trouble using Jest to effectively mock it out in a reusable fashion.

The Solution: ts-mock-firebase && supertest

We can use the ts-mock-firebase library to make unit testing our logic less stressful. This library aims to simulate all of the Firestore functions with an in-memory database that you can define on every test, letting you set up mock data for your unit tests with ease.

If you're not already familiar with supertest, it's a library for ease-of-testing with Express endpoints. It is totally optional, but since the example above is an Express function rather than some util, it makes more sense to simulate the endpoint in our test in a manner that it might actually be used.

Let's see what a unit test in Jest might look like for the example above.

import express from 'express';
import * as admin from 'firebase-admin';
import request from 'supertest';
import { exposeMockFirebaseAdminApp } from 'ts-mock-firebase';

import productCount from './productCount';

const server = express();
server.use('/productCount', productCount);

const firebaseApp = admin.initializeApp({});
const mocked = exposeMockFirebaseAdminApp(firebaseApp);

describe('Api Endpoint: productCount', () => {
  afterEach(() => {
    mocked.firestore().mocker.reset();
  });

  // ...

  describe('GET', () => {
    it('returns the total number of products', async () => {
      // ARRANGE
      // ๐Ÿš€๐Ÿš€๐Ÿš€ Mock the products collection!
      mocked.firestore().mocker.loadCollection('products', {
        productOne: {
          name: 'mockProductOne',
          price: 9.99
        },
        productTwo: {
          name: 'mockProductTwo',
          price: 19.99
        }
      });

      // ACT
      const response = await request(server).get('/productCount');

      // ASSERT
      expect(response.status).toEqual(200);
      expect(response.body).toEqual({ count: 2 });
    });
  });
});

Being able to mock an entire collection with ts-mock-firebase's loadCollection function is extraordinarily powerful. It makes TDD possible and easy for Express endpoints that rely on Firestore queries.

A More Complex Example

The products collection example above was obviously extremely simplified. It is likely we will need to do something with much more heavy lifting in whatever Express endpoint we build.

Let's pretend we're building a high score tracking system for video games that relies on two collections: scores and games. The games collection has one sub-collection: tracks, which are the different rulesets that players might be competing on.

Here is a sample document for the games collection:

{
  hkzSjFA7IY4s3Qb1DJyA: {
    name: 'Donkey Kong',
    tracks: { // This is a subcollection!
      JFCYTi9sJLsazbzxVomW: {
        name: 'Factory settings'
      }
    }
  }
}

And here is a sample document for the scores collection:

{
  nkT6Gv3uD7NmTnDpVGKK: {
    finalScore: 1064500
    playerName: 'Steve Wiebe',

    // This is a ref to Donkey Kong.
    _gameRef: '/games/hkzSjFA7IY4s3Qb1DJyA',

    // This is a ref to the "Factory settings" track.
    _trackRef: '/games/hkzSjFA7IY4s3Qb1DJyA/tracks/JFCYTi9sJLsazbzxVomW'
  }
}

Now, let's say we have an endpoint that queries the scores collection and responds with an array of objects that looks like this:

[
  {
    playerName: 'Steve Wiebe',
    score: 1064500,
    gameName: 'Donkey Kong',
    trackName: 'Factory settings'
  }
];

The Express code for such an endpoint might look like:

async function getDocumentByReference(reference: DocumentReference<any>) {
  const snapshot = await reference.get();
  return snapshot.data();
}

export default async (req: Request, res: Response) => {
  switch (req.method) {
    case 'GET':
      const scoresSnapshot = await db.firestore().collection('scores').get();

      const formattedScores = [];

      for (const scoreDocument of scoresSnapshot.docs) {
        const {
          finalScore,
          playerName,
          _gameRef,
          _trackRef
        } = scoreDocument.data();

        const [game, track] = await Promise.all([
          getDocumentByReference(_gameRef),
          getDocumentByReference(_trackRef)
        ]);

        formattedScores.push({
          playerName,
          score: finalScore,
          gameName: game.name,
          trackName: track.name
        });
      }

      return res.status(200).send(formattedScores);
  }
};

Testing this without ts-mock-firebase is a nightmare. Let's see how easy it can make things for us!

import express from 'express';
import * as admin from 'firebase-admin';
import request from 'supertest';
import { exposeMockFirebaseAdminApp } from 'ts-mock-firebase';

import scores from './scores';

const server = express();
server.use('/scores', scores);

const firebaseApp = admin.initializeApp({});
const mocked = exposeMockFirebaseAdminApp(firebaseApp);

describe('Api Endpoint: scores', () => {
  afterEach(() => {
    mocked.firestore().mocker.reset();
  });

  // ...

  describe('GET', () => {
    it('returns a processed list of scores', async () => {
      // ARRANGE
      mocked.firestore().mocker.loadCollection('games', {
        gameOne: {
          name: 'Donkey Kong'
        }
      });

      // Look at how easy it is to mock a subcollection!
      mocked.firestore().mocker.loadCollection('games/gameOne/tracks', {
        trackOne: {
          name: 'Factory settings'
        }
      });

      mocked.firestore().mocker.loadCollection('scores', {
        scoreOne: {
          finalScore: 1064500,
          playerName: 'Steve Wiebe',

          // We can point directly to our mocked documents.
          _gameRef: mocked.firestore().docs('games/gameOne'),
          _trackRef: mocked.firestore().docs('games/gameOne/tracks/trackOne')
        }
      });

      // ACT
      const response = await request(server).get('/scores');

      // ASSERT
      expect(response.status).toEqual(200);
      expect(response.body).toHaveLength(1);
      expect(response.body.gameName).toEqual('Donkey Kong');
      expect(response.body.trackName).toEqual('Factory settings');
    });
  });
});

Voila! I've successfully used ts-mock-firebase with endpoints that do a lot of heavy lifting, and it has been a great testing experience.

If this has been helpful, be sure to leave a like!

Posted on Aug 27 '19 by:

wescopeland profile

Wes Copeland

@wescopeland

๐Ÿ‘‹ Angular enthusiast, classic arcade nerd, passionate about developer experience and learning new things.

Discussion

markdown guide
 

Yes! Thanks for addressing the issue and further breaking it down Wes ๐Ÿ‘† Great work

 

Always great to hear from This Dot! I hope you are all doing well.

 

Aw thanks Wes! Hope you are too :)

 

I love that your sentence 'I am not the only one who has had this experience.' has different links for each word. This article was much needed - Thanks for delivering!