Spotted a bug? Have a great idea? Help us make google.dev great!

This codelab relies on having completed two previous codelabs:

  1. Getting Started with the Firebase Emulator Suite
  2. Test-Drive Firestore Security Rules

If you have not completed both of those codelabs, please go back! Otherwise, the code below will not work.

What you'll build

In this codelab, you'll run tests against the local Cloud Firestore and Cloud Functions emulators and use those tests to validate the implementation of a new Cloud Function.

What you'll learn

  • How to write a Firebase Function that is triggered by Firestore events.
  • How to write integration tests that runs against the Firebase Emulators

You might have the emulators running from the previous codelab. If not, make sure you're in the right directory, emulators-codelab/codelab-initial-state, and start the emulators:

$ firebase emulators:start
i  Starting emulators: ["functions","firestore","hosting"]
✔  functions: Using node@8 from host.
✔  functions: Emulator started at http://localhost:5001
i  firestore: Emulator logging to firestore-debug.log
✔  firestore: Emulator started at http://localhost:8080
i  firestore: For testing set FIRESTORE_EMULATOR_HOST=localhost:8080
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
✔  hosting: Emulator started at http://localhost:5000
i  functions: Watching ".../emulators-codelab/codelab-initial-state/functions" for Cloud Functions...
✔  functions: [firestore] function calculateCart initialized.
✔  All emulators started, it is now safe to connect.

Once the emulators are running, you can run the tests against them.

In the editor, open the emulators-codelab/codelab-initial-state/functions/test.js file and scroll to the last test in the file. Right now, it's marked as pending:

//  REMOVE .skip FROM THIS LINE
describe.skip("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

To enable the test, remove .skip, so it looks like this:

describe("adding an item to the cart recalculates the cart total. ", () => {
  // ...

  it("should sum the cost of their items", async () => {
    ...
  });
});

Next, find the REAL_FIREBASE_PROJECT_ID variable at the top of the file and change it to your real Firebase Project ID.:

// CHANGE THIS LINE
const REAL_FIREBASE_PROJECT_ID = "changeme";

You can find your Firebase Project ID in the Project Settings in the Firebase Console:

Because this test validates the interaction between Cloud Firestore and Cloud Functions, it involves more setup than the tests in the previous codelabs. Let's walk through this test and get an idea of what it expects.

Create a cart

Cloud Functions run in a trusted server environment and can use the service account authentication used by the Admin SDK . First, you initialize an app using initializeAdminApp instead of initializeApp. Then, you create a DocumentReference for the cart we'll be adding items to and initialize the cart:

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    ...
  });

Trigger the function

Then, add documents to the items subcollection of our cart document in order to trigger the function. Add two items to make sure youre testing the addition that happens in the function.

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });

    ...
    });
  });

Set test expectations


Use onSnapshot() to register a listener for any changes on the cart document. onSnapshot() returns a function that you can call to unregister the listener.

For this test, add two items that together cost $9.98. Then, check if the cart has the expected itemCount and totalPrice. If so, then the function did its job.

it("should sum the cost of their items", (done) => {
    const db = firebase
        .initializeAdminApp({ projectId: REAL_FIREBASE_PROJECT_ID })
        .firestore();

    // Setup: Initialize cart
    const aliceCartRef = db.doc("carts/alice")
    aliceCartRef.set({ ownerUID: "alice", totalPrice: 0 });

    //  Trigger calculateCart by adding items to the cart
    const aliceItemsRef = aliceCartRef.collection("items");
    aliceItemsRef.doc("doc1").set({name: "nectarine", price: 2.99});
    aliceItemsRef.doc("doc2").set({ name: "grapefruit", price: 6.99 });

    // Listen for every update to the cart. Every time an item is added to
    // the cart's subcollection of items, the function updates totalPrice
    // and itemCount attributes on the cart.
    // Returns a function that can be called to unsubscribe the listener.
    unsubscribe = aliceCartRef.onSnapshot(snap => {
      // If the function worked, these will be cart's final attributes.
      const expectedCount = 2;
      const expectedTotal = 9.98;

      // When the itemCount and totalPrice match the expectations for the
      // two items added, the promise resolves, and the test passes.
      if (snap.data().itemCount === expectedCount && snap.data().totalPrice == expectedTotal) {
        done();
      };
    });
  });

Test cleanup

Calling onSnapshot subscribes the caller to changes on the cart. It also returns a function that you can later call to stop receiving updates when the cart changes. Declare the listener in global scope and set its value when calling onSnapshot(). You can unsubscribe from changes in after() or afterEach() blocks, which run after your tests complete.

describe("adding an item to the cart recalculates the cart total. ", () => {
  let unsubscribe;

  after(() => {
    ...
    // Call the function returned by 
onSnapshot
 to unsubscribe
    if (unsubscribe) {
      unsubscribe();
    }
  });

  it("should sum the cost of their items", (done) => {
    ...

    // Listen for every update to the cart. Every time an item is added to
    // the cart's subcollection of items, the function updates 
totalPrice
    // and 
itemCount
 attributes on the cart.
    // Returns a function that can be called to unsubscribe the listener.
    unsubscribe = aliceCartRef.onSnapshot(snap => {
      const expectedCount = 2;
      const expectedTotal = 9.98;
      if (snap.data().itemCount === expectedCount && snap.data().totalPrice == expectedTotal) {
        done();
      };
    });
  });
});

You might still have the emulators running from the previous tests. If not, start the emulators. From the command line, run

$ firebase emulators:start

In a new tab, run the tests. There will now be five tests.

$ npm --prefix=functions test

> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (82ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (42ms)

  shopping cart items
    ✓ items can be read by the cart owner (40ms)
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    1) should sum the cost of their items

  4 passing (2s)
  1 failing

If you look at the specific failure, it appears to be a timeout error. This is because the test is waiting for the function to correctly update, but it never does. Now, we're ready to write the function to satisfy the test.

To fix this test, you need to update the function in functions/index.js. Although some of this function is written, it's not complete. This is how the function currently looks:

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      let totalPrice = 125.98;
      let itemCount = 8;
      try {
        
        const cartRef = db.collection("carts").doc(context.params.cartId);

        return cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

The function is correctly setting the cart reference, but then instead of calculating the values of totalPrice and itemCount, it updates them to hardcoded ones.

Fetch and iterate through the items subcollection

Initialize a new constant, itemsSnap, to be the items subcollection. Then, iterate through all the documents in the collection.

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      try {
        let totalPrice = 125.98;
        let itemCount = 8;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        // ADD LINES FROM HERE
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
        })
        // TO HERE
       
        return cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

Calculate totalPrice and itemCount

First, let's initialize the values of totalPrice and itemCount to zero.

Then, add the logic to our iteration block. First, check that the item has a price. If the item doesn't have a quantity specified, let it default to 1. Then, add the quantity to the running total of itemCount. Finally, add the item's price multiplied by the quantity to the running total of totalPrice:

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      try {
        let totalPrice = 0;
        let itemCount = 0;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          // ADD LINES FROM HERE
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
          // TO HERE
        })

        return await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
      }
    });

You can also add logging to help debug success and error states:

// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
      let totalPrice = 0;
      let itemCount = 0;
      try {
        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
        });
        console.log("Cart total successfully recalculated: ", totalPrice);

        return await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
        // ADD LINES FROM HERE
        if (itemCount === 0) {
          return;
        }
        console.error("Cart could not be recalculated. ", err);
        // TO HERE
      }
    });
// Recalculates the total cost of a cart; triggered when there's a change
// to any items in a cart.
exports.calculateCart = functions
    .firestore.document("carts/{cartId}/items/{itemId}")
    .onWrite(async (change, context) => {
    let itemCount
     try {
        let totalPrice = 0;
        itemCount = 0;

        const cartRef = db.collection("carts").doc(context.params.cartId);
        const itemsSnap = await cartRef.collection("items").get();

        itemsSnap.docs.forEach(item => {
          const itemData = item.data();
          if (itemData.price) {
            // If not specified, the quantity is 1
            const quantity = (itemData.quantity) ? itemData.quantity : 1;
            itemCount += quantity;
            totalPrice += (itemData.price * quantity);
          }
        });
        console.log("Cart total successfully recalculated: ", totalPrice);

        return await cartRef.update({
          totalPrice,
          itemCount
        });
      } catch(err) {
        if (itemCount === 0) {
          return;
        }
        console.error("Cart could not be recalculated. ", err);
      }
    });

On the command line, make sure the emulators are still running and re-run the tests. You don't need to restart the emulators because they pick up changes to the functions automatically. You should see all the tests pass:

$ npm --prefix=functions test
> functions@ test .../emulators-codelab/codelab-initial-state/functions
> mocha

  shopping cart creation
    ✓ can be created by the cart owner (306ms)

  shopping cart reads, updates, and deletes
    ✓ cart can be read by the cart owner (59ms)

  shopping cart items
    ✓ items can be read by the cart owner
    ✓ items can be added by the cart owner

  adding an item to the cart recalculates the cart total. 
    ✓ should sum the cost of their items (800ms)


  5 passing (1s)

Good job!

For the final test, return to the web client set up in the first codelab, http://localhost:5000/, and add an item to the cart.

Confirm that the cart updates with the correct total. Fantastic!

You've walked through a complex test case between Cloud Functions for Firebase and Cloud Firestore. You wrote a Cloud Function to make the test pass. You also confirmed the new functionality is working in the UI! You did all this locally, running the emulators on your own machine.

In the course of this playlist, you've also created a web client that's running against the local emulators, tailored security rules to protect the data, and tested the security rules using the local emulators.

This is ready to deploy! If you'd like to ship this project to production, deploy away!

$ firebase deploy