Testing Firestore locally with Firebase emulators

database node.js firestore javascript

Image credit: Firebase

Cloud Firestore is a NoSQL cloud database from Firebase and Google Cloud Platform. It's easy to get started with and provides a fast and convenient way to store the data without needing to setup a database manually. 

However, since it is a cloud database, a question soon arises - how do we test it locally without making unnecessary requests, or setting up a separate project for testing purposes only? Until about less than a year ago running Firestore locally wasn't possible, but luckily things have changed with the release of Firebase Emulators. Although the main purpose of the emulators is to test Firebase's security rules, they can be adapted, with some tweaking, to test CRUD operations against a local database instance. 

For the purposes of this tutorial we'll use Node.js environment, however the basic principles should also be applicable in case you're running Firestore queries directly from the client side.  

We'll start by making sure that Firebase CLI is installed and setup. Next we'll need to install and setup the emulator itself.

npm i -D @firebase/testing

firebase setup:emulators:firestore

If everything went well, we'd be able to run the emulator locally now.

firebase serve --only firestore

This should result in the following console output.

i  firestore: Emulator logging to firestore-debug.log
✔  firestore: Emulator started at http://localhost:8080

Now that we have the emulator setup and running, let's add a few CRUD operations that we're gonna test. The actual real-world usage is likely to be more complex, however in this tutorial, in order to be concise, we'll stick to simplified examples. 

// constants.js

exports.COLLECTION_NAME = "test_collection";

// operations.js

const { NotFound } = require("http-errors");
const admin = require("firebase-admin");
const { COLLECTION_NAME } = require("./constants");

const db = admin.firestore();

async function listItems(userId) {
  try {
    const collection = await db.collection(COLLECTION_NAME);
    let snapshot;
    if (userId) {
      snapshot = await collection.where("userId", "==", userId).get();
    } else {
      snapshot = await collection.get();
    }
    
    let result = [];
    snapshot.forEach(doc => {
      const { name, created, type, description, url } = doc.data();

      result.push({
        name,
        created,
        type,
        description,
        url,
        id: doc.id
      });
    });
    return result;
  } catch (e) {
    throw e;
  }
}

exports.listItems = listItems;

async function getItem(itemId, userId) {
  try {
    const snapshot = await db
      .collection(COLLECTION_NAME)
      .doc(itemId)
      .get();

    const data = snapshot.data();
    if (!data || data.userId !== userId) {
      throw new NotFound("Item not found");
    }
    return data;
  } catch (error) {
    return error;
  }
}

exports.getItem = getItem;

async function createItem(newRecord) {
  try {
    const addDoc = await db.collection(COLLECTION_NAME).add(newRecord);
    return { ...newRecord, id: addDoc.id };
  } catch (error) {
    throw error;
  }
}

exports.createItem = createItem;

async function updateItem(itemId, data) {
  try {
    const itemRef = await db.collection(COLLECTION_NAME).doc(itemId);
    const item = await itemRef.get();
    if (!item.exists) {
      throw new NotFound("Item not found");
    }
    const newRecord = {
      ...data,
      updated: Date.now()
    };
    await itemRef.update(newRecord);
    return { ...item.data(), ...newRecord, id: itemId };
  } catch (error) {
    throw error;
  }
}

exports.updateItem = updateItem;

async function deleteItem(itemId) {
  try {
    const docRef = db.collection(COLLECTION_NAME).doc(itemId);
    const snapshot = await docRef.get();
    const data = snapshot.data();
    if (!data) {
      throw new NotFound("No record found");
    }
    await docRef.delete();
    return { status: "OK" };
  } catch (error) {
    throw error;
  }
}

exports.deleteItem = deleteItem;

Now that we have our basic operations setup, it's time to start writing tests for them. But before that, looking at the operations we defined, we can see that we're not using the emulator here but our 'real' database. Normally what we'd want is to run the operations against the actual database in production and use the emulator when running tests. One way to achieve this would be to make operation functions to accept database instance as an extra parameter, so we could pass it depending on the use case, however it doesn't seem like the best approach. Ideally we'd like the necessary database setup to be detected automatically based on environment where the app is run. 

To achieve this we're gonna use a little trick, which leverages the fact that objects in JavaScript are passed by reference, therefore allowing us to modify them after they have been initialized. So, in this case we'll define two methods - getDb and setDb, which would return the required database instance and allow us to overwrite it if needed. We'll also move the database initialisation to a separate db.js file.

// db.js

const admin = require("firebase-admin");

let db;

if (process.env.NODE_ENV !== "test") {
  db = admin.firestore();
}

exports.getDb = () => {
  return db;
};

exports.setDb = (database) => {
  db = database;
};

Here we export getDb method instead of the db variable, so we can always get correct instance of the database even after it was modified. By default db will be an actual production database, and in case we need to change that, setDb function is provided. We purposely do not set the emulator instance here to have a clean separation between production and test code.

Finally, we can get to our tests, which live in operations.test.js file. Also remember to change db to the newly added getDb method in operations.js. As you've probably guessed, we'll need to setup the emulator instance first.

// operations.test.js

const firebase = require("@firebase/testing");

// Helper function to setup the test db instance
function authedApp(auth) {
  return firebase
    .initializeTestApp({ projectId: 'test-project', auth })
    .firestore();
}

beforeEach(() => {
// Set the emulator database before each test
  setDb(authedApp(null));
});

beforeEach(async () => {
  // Clear the database before each test
  await firebase.clearFirestoreData({ projectId: 'test-project' });
});

afterEach(async () => {
  await Promise.all(firebase.apps().map(app => app.delete()));
});

More examples of setting up the emulator are available in the Firebase quickstart repository. Now on to the actual tests!

// operations.test.js

const { BOOKMARK_COLLECTION_NAME } = require("./constants");
const {
  listItems,
  getItem,
  createItem,
  updateItem,
  deleteItem
} = require("./operations");

// Setup some mock data
const userId = "123";
const record = {
  name: "Test item",
  type: "Page",
  userId: userId,
  created: "1000000"
};

it("should properly retrieve all items for a user", async () => {
  await getDb()
    .collection(COLLECTION_NAME)
    .add(record);

  const resp = await listItems(userId);
  expect(resp).toBeDefined();
  expect(resp.length).toEqual(1);
  expect(resp[0]).toEqual(expect.objectContaining(record));

  // Test that another user cannot see other user's items
  const resp2 = await listItems("124");
  expect(resp2.length).toEqual(0);
});

it("should retrieve correct items", async () => {
  const db = getDb();
  const ref = await db.collection(COLLECTION_NAME).add(record);
  await db
    .collection(COLLECTION_NAME)
    .add({ ...record, name: "another record" });
  const resp = await getItem(ref.id, userId);
  expect(resp).toBeDefined();
  expect(resp).toEqual(expect.objectContaining(record));

  // Check that other user can't get the same item
  await expect(getItem(ref.id, "124")).rejects.toThrowError(
    "Item not found"
  );
});

it("should correctly create items", async () => {
  const item = await createItem(record);
  expect(item.id).toBeDefined();
  expect(item.name).toEqual(record.name);
});

it("should correctly update items", async () => {
  const db = getDb();
  const ref = await db.collection(COLLECTION_NAME).add(record);
  await updateItem(ref.id, { name: "Updated name" });
  const item = await db
    .collection(COLLECTION_NAME)
    .doc(ref.id)
    .get();
  expect(item.data().name).toEqual("Updated name");
});

it("should correctly delete items", async () => {
  const db = getDb();
  const ref = await db.collection(COLLECTION_NAME).add(record);
  await deleteItem(ref.id);

  const collection = await db
    .collection(COLLECTION_NAME)
    .where("userId", "==", record.userId)
    .get();
  expect(collection.empty).toBe(true);
});

The tests themselves are quite straightforward. We're using Jest assertions to check the results. Some database actions, like a creating an item, could be probably abstracted into utility factory methods, but that is left as an exercise for the reader ;) 

Hopefully now you have a better idea of how to approach unit testing Firestore operations locally. Got any questions/comments or other kinds of feedback about this post? Let me know on Twitter.