Testing Firebase Functions with Firestore

Figuring out how to set up testing for Firebase functions that use Firestore has been a nightmare. The Firebase docs have been out of date for almost a year. Here's how I got it working. My versions:

"firebase-admin": "^12.1.0",
"firebase-functions": "^6.0.1",
"firebase-functions-test": "^3.3.0",

and firebase (the cli) version 8.10.0. Hopefully this helps until the next major bump.

I'm trying to test a function

import { FirestoreEvent, onDocumentCreated } from "firebase-functions/v2/firestore";
import * as logger from "firebase-functions/logger";
import {initializeApp} from "firebase-admin/app";
import { getFirestore, QueryDocumentSnapshot } from "firebase-admin/firestore";

export const app = initializeApp({ projectId: 'my-app' });

type Event = FirestoreEvent<QueryDocumentSnapshot | undefined>;

export const myFunction = onDocumentCreated(
  "/products/{documentId}",
  async (event: Event) => {
    if (event.data) {
      const statusDoc = getFirestore().doc(`/${event.document}/statuses/current`);
      const status = await statusDoc.get();
      logger.log(event.data.data());
      logger.log(status.data());
    }
  }
);

Using the npm script

"test": "firebase emulators:exec --only firestore jest"

I run the following test

import * as admin from 'firebase-admin';
import * as logger from "firebase-functions/logger";
import * as firebaseFunctionsTest from 'firebase-functions-test';

const test = firebaseFunctionsTest({ projectId: 'my-app' });

// I read somewhere that it's important to do the require after
// initializing `firebase-functions-test`, so that it will pick
// up some configuration overrides or something. It doesn't seem
// to matter in my setup, but maybe it will for yours.
const { myFunction } = require('./index');

const db = admin.firestore();

describe('myFunction', () => {
  let loggerSpy: jest.SpyInstance;

  beforeAll(() => {
    if (!admin.apps.length) {
      admin.initializeApp();
    }
  });

  beforeEach(() => {
    loggerSpy = jest.spyOn(logger, 'log');
  });

  afterAll(async () => {
    await Promise.all(admin.apps.map(app => app!.delete()));
    await deleteCollection('products');
    await deleteCollection('statuses');
  });

  it('runs my function and gets both event and status data', async () => {
    wrapped = test.wrap(myFunction);
    const mockProduct: any = {
      name: 'Widget 2000'
    };
    const { productSnapshot } = await createProduct(mockProduct);
   
    // This event just gets passed through to the function
    const event = { data: productSnapshot, document: 'products/product1' }
    
    await wrapped(event);

    expect(loggerSpy).toHaveBeenCalledWith(expect.any(Object));
  });
});


/** This function creates the event and status documents
 *  in the emulated firestore */
export async function createProduct(product: object) {
  const status = { status: "pending" };
  const productRef = db.collection('products').doc('product1')
  await productRef.set(product);
  const statusRef = productRef.collection('statuses').doc('current');
  await statusRef.set(status);
  const productSnapshot = await productRef.get();
  const statusSnapshot = await statusRef.get();
  return { productSnapshot, statusSnapshot };
}

/** Clean up! */
export async function deleteCollection(collection: string) {
  const snapshot = await db.collection(collection).get()
  for (const doc of snapshot.docs) {
    await doc.ref.delete();
  }
}

Since we are using the firestore emulator, we can be confident that the function behavior will be pretty realistic.