使用 Firebase 模拟器套件进行本地开发

1. 开始之前

Cloud Firestore 和 Cloud Functions 等无服务器后端工具非常易于使用,但可能很难测试。 Firebase 本地模拟器套件允许您在开发计算机上运行这些服务的本地版本,以便您可以快速安全地开发应用程序。

先决条件

  • 简单的编辑器,例如 Visual Studio Code、Atom 或 Sublime Text
  • Node.js 10.0.0 或更高版本(要安装 Node.js,请使用 nvm ,要检查您的版本,请运行node --version
  • Java 7 或更高版本(要安装 Java,请使用这些说明,要检查您的版本,请运行java -version

你会做什么

在此 Codelab 中,您将运行并调试一个由多个 Firebase 服务提供支持的简单在线购物应用:

  • Cloud Firestore:具有实时功能的全球可扩展、无服务器、NoSQL 数据库。
  • Cloud Functions :运行以响应事件或 HTTP 请求的无服务器后端代码。
  • Firebase 身份验证:与其他 Firebase 产品集成的托管身份验证服务。
  • Firebase Hosting :快速、安全的网络应用托管。

您将应用程序连接到模拟器套件以启用本地开发。

2589e2f95b74fa88.png

您还将学习如何:

  • 如何将您的应用程序连接到模拟器套件以及如何连接各种模拟器。
  • Firebase 安全规则的工作原理以及如何针对本地模拟器测试 Firestore 安全规则。
  • 如何编写由 Firestore 事件触发的 Firebase 函数,以及如何编写针对模拟器套件运行的集成测试。

2. 设置

获取源代码

在此 Codelab 中,您将从接近完整的 Fire Store 示例版本开始,因此您需要做的第一件事是克隆源代码:

$ git clone https://github.com/firebase/emulators-codelab.git

然后进入 Codelab 目录,您将在其中完成本 Codelab 的其余部分:

$ cd emulators-codelab/codelab-initial-state

现在,安装依赖项以便您可以运行代码。如果您的互联网连接速度较慢,这可能需要一两分钟:

# Move into the functions directory
$ cd functions

# Install dependencies
$ npm install

# Move back into the previous directory
$ cd ../

获取 Firebase CLI

模拟器套件是 Firebase CLI(命令行界面)的一部分,可以使用以下命令将其安装在您的计算机上:

$ npm install -g firebase-tools

接下来,确认您拥有最新版本的 CLI。此 Codelab 应适用于 9.0.0 或更高版本,但更高版本包含更多错误修复。

$ firebase --version
9.6.0

连接到您的 Firebase 项目

如果您没有 Firebase 项目,请在Firebase 控制台中创建一个新的 Firebase 项目。记下您选择的项目 ID,稍后您将需要它。

现在我们需要将此代码连接到您的 Firebase 项目。首先运行以下命令登录Firebase CLI:

$ firebase login

接下来运行以下命令来创建项目别名。将$YOUR_PROJECT_ID替换为您的 Firebase 项目的 ID。

$ firebase use $YOUR_PROJECT_ID

现在您已准备好运行该应用程序了!

3.运行模拟器

在本部分中,您将在本地运行该应用程序。这意味着是时候启动模拟器套件了。

启动模拟器

在 Codelab 源目录中,运行以下命令来启动模拟器:

$ firebase emulators:start --import=./seed

您应该看到如下输出:

$ firebase emulators:start --import=./seed
i  emulators: Starting emulators: auth, functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, pubsub
i  firestore: Importing data from /Users/samstern/Projects/emulators-codelab/codelab-initial-state/seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://127.0.0.1:5000
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/samstern/Projects/emulators-codelab/codelab-initial-state/functions" for Cloud Functions...
✔  functions[calculateCart]: firestore function initialized.

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://127.0.0.1:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at 127.0.0.1:4400
  Other reserved ports: 4500

Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.

一旦您看到“所有模拟器已启动”消息,该应用程序就可以使用了。

将 Web 应用程序连接到模拟器

根据日志中的表,我们可以看到 Cloud Firestore 模拟器正在侦听端口8080 ,而身份验证模拟器正在侦听端口9099

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ 127.0.0.1:9099 │ http://127.0.0.1:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ 127.0.0.1:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘

让我们将前端代码连接到模拟器,而不是生产环境。打开public/js/homepage.js文件并找到onDocumentReady函数。我们可以看到代码访问了标准的 Firestore 和 Auth 实例:

公共/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

让我们更新dbauth对象以指向本地模拟器:

公共/js/homepage.js

  const auth = firebaseApp.auth();
  const db = firebaseApp.firestore();

  // ADD THESE LINES
  if (location.hostname === "127.0.0.1") {
    console.log("127.0.0.1 detected!");
    auth.useEmulator("http://127.0.0.1:9099");
    db.useEmulator("127.0.0.1", 8080);
  }

现在,当应用程序在本地计算机(由托管模拟器提供服务)上运行时,Firestore 客户端也会指向本地模拟器而不是生产数据库。

打开模拟器UI

在网络浏览器中,导航至http://127.0.0.1:4000/ 。您应该会看到模拟器套件 UI。

模拟器 UI 主屏幕

单击可查看 Firestore 模拟器的 UI。由于使用--import标志导入的数据, items集合已包含数据。

4ef88d0148405d36.png

4. 运行应用程序

打开应用程序

在您的 Web 浏览器中,导航到http://127.0.0.1:5000 ,您应该会看到 Fire Store 在您的计算机上本地运行!

939f87946bac2ee4.png

使用应用程序

在主页上选择一个商品,然后单击“添加到购物车” 。不幸的是,您将遇到以下错误:

a11bd59933a8e885.png

让我们修复这个错误吧!因为一切都在模拟器中运行,所以我们可以进行实验而不用担心影响真实数据。

5. 调试应用程序

找到错误

好的,让我们看看 Chrome 开发者控制台。按Control+Shift+J (Windows、Linux、Chrome 操作系统)或Command+Option+J (Mac) 可在控制台上查看错误:

74c45df55291dab1.png

addToCart方法似乎有一些错误,让我们看一下。我们在该方法中在哪里尝试访问称为uid的东西,为什么它会是null ?现在该方法在public/js/homepage.js中如下所示:

公共/js/homepage.js

  addToCart(id, itemData) {
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

啊哈!我们尚未登录该应用程序。根据Firebase 身份验证文档,当我们未登录时, auth.currentUsernull 。让我们为此添加一个检查:

公共/js/homepage.js

  addToCart(id, itemData) {
    // ADD THESE LINES
    if (this.auth.currentUser === null) {
      this.showError("You must be signed in!");
      return;
    }

    // ...
  }

测试应用程序

现在,刷新页面,然后单击“添加到购物车” 。这次你应该得到一个更好的错误:

c65f6c05588133f7.png

但是,如果您单击上方工具栏中的“登录” ,然后再次单击“添加到购物车” ,您将看到购物车已更新。

然而,这些数字看起来根本不正确:

239f26f02f959eef.png

别担心,我们很快就会修复这个错误。首先,让我们深入了解将商品添加到购物车时实际发生的情况。

6. 本地函数触发器

单击“添加到购物车”将启动涉及多个模拟器的一系列事件。在 Firebase CLI 日志中,将商品添加到购物车后,您应该会看到类似以下消息的内容:

i  functions: Beginning execution of "calculateCart"
i  functions: Finished "calculateCart" in ~1s

发生了四个关键事件来生成这些日志和您观察到的 UI 更新:

68c9323f2ad10f7a.png

1) Firestore 写入 - 客户端

新文档已添加到 Firestore 集合/carts/{cartId}/items/{itemId}/中。您可以在public/js/homepage.js内的addToCart函数中看到以下代码:

公共/js/homepage.js

  addToCart(id, itemData) {
    // ...
    console.log("addToCart", id, JSON.stringify(itemData));
    return this.db
      .collection("carts")
      .doc(this.auth.currentUser.uid)
      .collection("items")
      .doc(id)
      .set(itemData);
  }

2)云函数触发

云函数calculateCart通过使用onWrite触发器监听购物车项目发生的任何写入事件(创建、更新或删除),您可以在functions/index.js中看到:

函数/index.js

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);

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

3) Firestore 写入 - 管理员

calculateCart函数读取购物车中的所有商品,并将总数量和价格相加,然后用新的总计更新“购物车”文档(请参阅上面的cartRef.update(...) )。

4) Firestore读取-客户端

订阅 Web 前端以接收有关购物车更改的更新。在 Cloud Function 写入新总计并更新 UI 后,它会获得实时更新,如public/js/homepage.js中所示:

公共/js/homepage.js

this.cartUnsub = cartRef.onSnapshot(cart => {
   // The cart document was changed, update the UI
   // ...
});

回顾

干得好!您只需设置一个完全本地的应用程序,该应用程序使用三个不同的 Firebase 模拟器进行完全本地测试。

db82eef1706c9058.gif

但是等等,还有更多!在下一节中,您将学习:

  • 如何编写使用 Firebase 模拟器的单元测试。
  • 如何使用 Firebase 模拟器调试您的安全规则。

7. 创建适合您的应用程序的安全规则

我们的网络应用程序读取和写入数据,但到目前为止我们根本不担心安全性。 Cloud Firestore 使用称为“安全规则”的系统来声明谁有权读取和写入数据。模拟器套件是对这些规则进行原型设计的好方法。

在编辑器中,打开文件emulators-codelab/codelab-initial-state/firestore.rules 。您会看到我们的规则包含三个主要部分:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // TODO: Change these! Anyone can read or write.
      allow read, write: if true;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

现在任何人都可以在我们的数据库中读取和写入数据!我们希望确保只有有效的操作才能通过,并且不会泄露任何敏感信息。

在此 Codelab 中,遵循最小权限原则,我们将锁定所有文档并逐渐添加访问权限,直到所有用户都拥有他们需要的所有访问权限,但不会更多。让我们通过将条件设置为false来更新前两条规则以拒绝访问:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // User's cart metadata
    match /carts/{cartID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // Items inside the user's cart
    match /carts/{cartID}/items/{itemID} {
      // UPDATE THIS LINE
      allow read, write: if false;
    }

    // All items available in the store. Users can read
    // items but never write them.
    match /items/{itemID} {
      allow read: if true;
    }
  }
}

8. 运行模拟器和测试

启动模拟器

在命令行上,确保您位于emulators-codelab/codelab-initial-state/中。您可能仍在运行前面步骤中的模拟器。如果没有,请再次启动模拟器:

$ firebase emulators:start --import=./seed

模拟器运行后,您可以在本地针对它们运行测试。

运行测试

emulators-codelab/codelab-initial-state/目录中新终端选项卡的命令行上

首先进入函数目录(我们将在 Codelab 的剩余部分中留在这里):

$ cd functions

现在在函数目录中运行摩卡测试,然后滚动到输出的顶部:

# Run the tests
$ npm test

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

  shopping carts
    1) can be created and updated by the cart owner
    2) can be read only by the cart owner

  shopping cart items
    3) can be read only by the cart owner
    4) can be added only by the cart owner

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


  0 passing (364ms)
  1 pending
  4 failing

现在我们有四个失败。当您构建规则文件时,您可以通过观察更多测试的通过情况来衡量进度。

9. 安全的购物车访问

前两个失败是“购物车”测试,它测试:

  • 用户只能创建和更新自己的购物车
  • 用户只能读取自己的购物车

函数/test.js

  it('can be created and updated by the cart owner', async () => {
    // Alice can create her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Bob can't create Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    }));

    // Alice can update her own cart with a new total
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").update({
      total: 1
    }));

    // Bob can't update Alice's cart with a new total
    await firebase.assertFails(bobDb.doc("carts/alicesCart").update({
      total: 1
    }));
  });

  it("can be read only by the cart owner", async () => {
    // Setup: Create Alice's cart as admin
    await admin.doc("carts/alicesCart").set({
      ownerUID: "alice",
      total: 0
    });

    // Alice can read her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart").get());

    // Bob can't read Alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart").get());
  });

让我们让这些测试通过。在编辑器中,打开安全规则文件firestore.rules ,并更新match /carts/{cartID}中的语句:

firestore.规则

rules_version = '2';
service cloud.firestore {
    // UPDATE THESE LINES
    match /carts/{cartID} {
      allow create: if request.auth.uid == request.resource.data.ownerUID;
      allow read, update, delete: if request.auth.uid == resource.data.ownerUID;
    }

    // ...
  }
}

这些规则现在只允许购物车所有者进行读写访问。

为了验证传入数据和用户身份验证,我们使用每个规则上下文中可用的两个对象:

10. 测试购物车访问

每当保存firestore.rules时,模拟器套件都会自动更新规则。您可以通过在运行模拟器的选项卡中查看消息Rules updated来确认模拟器已更新规则:

5680da418b420226.png

重新运行测试,并检查前两个测试现在是否通过:

$ npm test

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

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

  shopping cart items
    1) can be read only by the cart owner
    2) can be added only by the cart owner

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

  2 passing (482ms)
  1 pending
  2 failing

好工作!您现在已获得对购物车的访问权限。让我们继续进行下一个失败的测试。

11.检查UI中的“添加到购物车”流程

目前,尽管购物车所有者可以读取和写入购物车,但他们无法读取或写入购物车中的单个商品。这是因为虽然所有者有权访问购物车文档,但他们无权访问购物车的items 子集合

对于用户来说,这是一种破碎的状态。

返回到在http://127.0.0.1:5000,并尝试将一些东西添加到您的购物车。您会收到Permission Denied错误,可以从调试控制台看到该错误,因为我们尚未授予用户对items子集合中创建的文档的访问权限。

12.允许访问购物车物品

这两个测试确认用户只能将商品添加到自己的购物车或从自己的购物车中读取商品:

  it("can be read only by the cart owner", async () => {
    // Alice can read items in her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/milk").get());

    // Bob can't read items in alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/milk").get())
  });

  it("can be added only by the cart owner",  async () => {
    // Alice can add an item to her own cart
    await firebase.assertSucceeds(aliceDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));

    // Bob can't add an item to alice's cart
    await firebase.assertFails(bobDb.doc("carts/alicesCart/items/lemon").set({
      name: "lemon",
      price: 0.99
    }));
  });

因此,我们可以编写一条规则,如果当前用户与购物车文档上的ownerUID 具有相同的UID,则允许访问。由于不需要为create, update, delete指定不同的规则,因此您可以使用write规则,该规则适用于所有修改数据的请求。

更新 items 子集合中文档的规则。条件中的get是从 Firestore 读取一个值 - 在本例中是购物车文档上的ownerUID

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // ...

    // UPDATE THESE LINES
    match /carts/{cartID}/items/{itemID} {
      allow read, write: if get(/databases/$(database)/documents/carts/$(cartID)).data.ownerUID == request.auth.uid;
    }

    // ...
  }
}

13.测试购物车物品访问

现在我们可以重新运行测试。滚动到输出顶部并检查是否通过了更多测试:

$ npm test

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

  shopping carts
    ✓ can be created and updated by the cart owner (195ms)
    ✓ can be read only by the cart owner (136ms)

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


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


  4 passing (401ms)
  1 pending

好的!现在我们所有的测试都通过了。我们有一项待定测试,但我们将通过几个步骤完成该测试。

14.再次检查“添加到购物车”流程

返回 Web 前端 ( http://127.0.0.1:5000 ) 并将商品添加到购物车。这是确认我们的测试和规则与客户所需的功能相匹配的重要步骤。 (请记住,上次我们尝试 UI 用户无法将商品添加到购物车!)

69ad26cee520bf24.png

保存firestore.rules时,客户端会自动重新加载规则。因此,尝试将一些东西添加到购物车中。

回顾

干得好!您刚刚提高了应用程序的安全性,这是准备生产的重要一步!如果这是一个生产应用程序,我们可以将这些测试添加到我们的持续集成管道中。这将使我们充满信心,即使其他人正在修改规则,我们的购物车数据也将具有这些访问控制。

ba5440b193e75967.gif

但是等等,还有更多!

如果您继续,您将学到:

  • 如何编写由 Firestore 事件触发的函数
  • 如何创建跨多个模拟器工作的测试

15. 设置 Cloud Functions 测试

到目前为止,我们重点关注 Web 应用程序的前端和 Firestore 安全规则。但此应用程序还使用 Cloud Functions 来使用户的购物车保持最新状态,因此我们也想测试该代码。

模拟器套件使测试 Cloud Functions 变得非常容易,甚至是使用 Cloud Firestore 和其他服务的功能。

在编辑器中,打开emulators-codelab/codelab-initial-state/functions/test.js文件并滚动到文件中的最后一个测试。现在,它被标记为待处理:

//  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 () => {
    ...
  });
});

要启用测试,请删除.skip ,因此它看起来像这样:

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

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

接下来,找到文件顶部的REAL_FIREBASE_PROJECT_ID变量并将其更改为您真实的 Firebase 项目 ID:

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

如果您忘记了项目 ID,可以在 Firebase 控制台的项目设置中找到您的 Firebase 项目 ID:

d6d0429b700d2b21.png

16. 演练功能测试

由于此测试验证 Cloud Firestore 和 Cloud Functions 之间的交互,因此它比之前的 Codelab 中的测试涉及更多设置。让我们完成这个测试并了解它的期望。

创建购物车

Cloud Functions 在受信任的服务器环境中运行,并且可以使用 Admin SDK 使用的服务帐户身份验证。首先,使用initializeAdminApp而不是initializeApp初始化应用程序。然后,为我们将添加商品的购物车创建一个DocumentReference并初始化购物车:

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

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

    ...
  });

触发功能

然后,将文档添加到购物车文档的items子集合中以触发该功能。添加两项以确保您正在测试函数中发生的添加。

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

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

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

    ...
    });
  });

设定测试期望

使用onSnapshot()为购物车文档上的任何更改注册侦听器。 onSnapshot()返回一个函数,您可以调用该函数来取消注册侦听器。

对于此测试,添加两项合计成本为 9.98 美元的项目。然后,检查购物车是否具有预期的itemCounttotalPrice 。如果是这样,那么该函数就完成了它的工作。

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.
    await new Promise((resolve) => {
      const 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) {
          // Call the function returned by `onSnapshot` to unsubscribe from updates
          unsubscribe();
          resolve();
        };
      });
    });
   });
 });

17. 运行测试

您可能仍在运行之前测试的模拟器。如果没有,请启动模拟器。从命令行运行

$ firebase emulators:start --import=./seed

打开一个新的终端选项卡(保持模拟器运行)并移至函数目录。您可能仍然在安全规则测试中保持打开状态。

$ cd functions

现在运行单元测试,您应该看到总共 5 个测试:

$ npm 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

如果查看具体的故障,似乎是超时错误。这是因为测试正在等待函数正确更新,但它永远不会。现在,我们准备编写函数来满足测试。

18. 编写一个函数

要修复此测试,您需要更新functions/index.js中的函数。虽然这个函数的一部分已经写出来了,但是它并不完整。这是该函数当前的样子:

// 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) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      let totalPrice = 125.98;
      let itemCount = 8;
      try {
        
        const cartRef = db.collection("carts").doc(context.params.cartId);

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

该函数正确设置了购物车引用,但它不是计算totalPriceitemCount的值,而是将它们更新为硬编码的值。

获取并迭代

items子集合

初始化一个新常量itemsSnap作为items子集合。然后,迭代集合中的所有文档。

// 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) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }


      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) {
      }
    });

计算totalPrice和itemCount

首先,我们将totalPriceitemCount的值初始化为零。

然后,将逻辑添加到我们的迭代块中。首先,检查该商品是否有价格。如果该项目没有指定数量,则默认为1 。然后,将数量添加到itemCount的运行总计中。最后,将商品的价格乘以数量添加到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) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      try {
        // CHANGE THESE LINES
        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
        })

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

您还可以添加日志记录以帮助调试成功和错误状态:

// 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) => {
      console.log(`onWrite: ${change.after.ref.path}`);
      if (!change.after.exists) {
        // Ignore deletes
        return;
      }

      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);
          }
        });

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

        // OPTIONAL LOGGING HERE
        console.log("Cart total successfully recalculated: ", totalPrice);
      } catch(err) {
        // OPTIONAL LOGGING HERE
        console.warn("update error", err);
      }
    });

19. 重新运行测试

在命令行上,确保模拟器仍在运行并重新运行测试。您无需重新启动模拟器,因为它们会自动获取对功能的更改。您应该看到所有测试都通过了:

$ npm 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)

好工作!

20. 使用 Storefront UI 进行尝试

对于最终测试,返回到 Web 应用程序 ( http://127.0.0.1:5000/ ) 并将商品添加到购物车。

69ad26cee520bf24.png

确认购物车已更新为正确的总数。极好的!

回顾

您已经完成了 Cloud Functions for Firebase 和 Cloud Firestore 之间的复杂测试用例。您编写了一个云函数来使测试通过。您还确认了新功能正在 UI 中运行!您在本地完成了所有这些操作,在自己的计算机上运行模拟器。

您还创建了一个针对本地模拟器运行的 Web 客户端、定制的安全规则来保护数据,并使用本地模拟器测试了安全规则。

c6a7aeb91fe97a64.gif