Testing Firestore Locally with Firebase Emulators

Updated on · 4 min read
Testing Firestore Locally with Firebase Emulators

Cloud Firestore is a NoSQL cloud database provided by Firebase and Google Cloud Platform. It offers a fast and convenient way to store data without the need to manually set up a database.

However, since Firestore is a cloud database, developers often wonder how to test it locally without making unnecessary requests or setting up a separate project solely for testing purposes. Fortunately, we can take advantage of the Firebase Emulators for that. Although the primary purpose of these emulators is to test Firebase's security rules, they can also be configured to test CRUD operations against a local database instance with some adjustments.

In this tutorial, we'll explore the process of setting up and testing Firestore operations against a locally running database. We'll use the Firebase JavaScript SDK version 8 for this tutorial. For this, it's important to use @firebase/rules-unit-testing package version 1.3, as the later versions have a different API. For using the JavaScript SDK version 9 some pointers are available in the Firebase docs.

Getting started

For this tutorial, we will use the Node.js environment. However, the basic principles should also apply if you are running Firestore queries directly from the client side.

First, ensure that the Firebase CLI is installed and set up.

bash
npm install -g firebase-tools
bash
npm install -g firebase-tools

Next, we will install and configure the emulator itself.

bash
npm i -D @firebase/rules-unit-testing@1.3.16
bash
npm i -D @firebase/rules-unit-testing@1.3.16
bash
firebase setup:emulators:firestore
bash
firebase setup:emulators:firestore

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

bash
firebase emulators:start --only firestore
bash
firebase emulators:start --only firestore

This should result in the following console output.

shell
i firestore: Firestore Emulator logging to firestore-debug.log firestore: Firestore Emulator UI websocket is running on 9150. ┌─────────────────────────────────────────────────────────────┐ All emulators ready! It is now safe to connect your app. └─────────────────────────────────────────────────────────────┘ ┌───────────┬────────────────┐ Emulator Host:Port ├───────────┼────────────────┤ Firestore 127.0.0.1:8080 └───────────┴────────────────┘ Emulator Hub not running. Other reserved ports: 4500, 9150
shell
i firestore: Firestore Emulator logging to firestore-debug.log firestore: Firestore Emulator UI websocket is running on 9150. ┌─────────────────────────────────────────────────────────────┐ All emulators ready! It is now safe to connect your app. └─────────────────────────────────────────────────────────────┘ ┌───────────┬────────────────┐ Emulator Host:Port ├───────────┼────────────────┤ Firestore 127.0.0.1:8080 └───────────┴────────────────┘ Emulator Hub not running. Other reserved ports: 4500, 9150

Adding database operations

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

javascript
// constants.js exports.COLLECTION_NAME = "test_collection";
javascript
// constants.js exports.COLLECTION_NAME = "test_collection";
javascript
// 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) { return Promise.reject(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) { return Promise.reject(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) { return Promise.reject(new NotFound("No record found")); } await docRef.delete(); return { status: "OK" }; } catch (error) { throw error; } } exports.deleteItem = deleteItem;
javascript
// 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) { return Promise.reject(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) { return Promise.reject(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) { return Promise.reject(new NotFound("No record found")); } await docRef.delete(); return { status: "OK" }; } catch (error) { throw error; } } exports.deleteItem = deleteItem;

Here, we're using the http-errors package to simplify the process of returning HTTP errors.

Setting up the database

Now that we have our basic operations set up, it's time to start writing tests for them. However, before that, looking at the operations we defined, we can see that we're not using the emulator 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 accept a database instance as an extra parameter, allowing us to pass it depending on the use case. However, this doesn't seem like the best approach. Ideally, we'd like the necessary database setup to be detected automatically based on the environment where the app is run.

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

javascript
// 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; };
javascript
// 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 the getDb method instead of the db variable, so we can always get the correct instance of the database even after it has been modified. By default, db will be an actual production database, and in case we need to change that, the setDb function is provided. We purposely do not set the emulator instance here to maintain a clean separation between production and test code.

Adding the tests

Finally, we can proceed to our tests, which are located in the operations.test.js file. Remember to change db to the newly added getDb method in operations.js. As you've probably guessed, we'll need to set up the emulator instance first.

javascript
// operations.test.js const { initializeAdminApp, clearFirestoreData, apps, } = require("@firebase/rules-unit-testing"); const { setDb, getDb } = require("./db"); // Helper function to set up the test db instance async function authedApp() { const app = await initializeAdminApp({ projectId: "test-project" }); return app.firestore(); } beforeEach(async () => { const app = await authedApp(); // Set the emulator database before each test setDb(app); }); beforeEach(async () => { // Clear the database before each test await clearFirestoreData({ projectId: "test-project" }); }); afterEach(async () => { // Clean up the apps between tests. await Promise.all(apps().map((app) => app.delete())); });
javascript
// operations.test.js const { initializeAdminApp, clearFirestoreData, apps, } = require("@firebase/rules-unit-testing"); const { setDb, getDb } = require("./db"); // Helper function to set up the test db instance async function authedApp() { const app = await initializeAdminApp({ projectId: "test-project" }); return app.firestore(); } beforeEach(async () => { const app = await authedApp(); // Set the emulator database before each test setDb(app); }); beforeEach(async () => { // Clear the database before each test await clearFirestoreData({ projectId: "test-project" }); }); afterEach(async () => { // Clean up the apps between tests. await Promise.all(apps().map((app) => app.delete())); });

Here we are using the initializeAdminApp from @firebase/rules-unit-testing to create an initialized admin Firebase app. This app bypasses security rules when performing reads and writes, so we get an app authenticated as an admin to set the state for tests.

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

javascript
// operations.test.js const { 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({ name: "Test item", type: "Page", created: "1000000", }), ); // 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 users 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); });
javascript
// operations.test.js const { 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({ name: "Test item", type: "Page", created: "1000000", }), ); // 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 users 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 creating an item, could probably be 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 of Firestore operations locally.

Sometimes, when running tests, you might get the error connect ECONNREFUSED 127.0.0.1:80. This is because the Firestore emulator is running on port 127.0.0.1 and not localhost. To fix this, we can add the FIRESTORE_EMULATOR_HOST variable to our test script in package.json:

json
"scripts": { "test": "FIRESTORE_EMULATOR_HOST='127.0.0.1:8080' jest --watch" },
json
"scripts": { "test": "FIRESTORE_EMULATOR_HOST='127.0.0.1:8080' jest --watch" },

Conclusion

In this tutorial, we've explored how to set up and test Firestore operations against a locally running database using Firebase Emulators. This approach allows you to perform unit testing without making unnecessary requests or setting up a separate project solely for testing purposes. We've also shown you how to configure your code to use the appropriate database instance based on the environment.

References and resources