使用 Firebase Emulator Suite 进行本地开发

1. 准备工作

Cloud Firestore 和 Cloud Functions 等无服务器后端工具非常易于使用,但可能很难测试。借助 Firebase Local Emulator Suite,您可以在开发机器上运行这些服务的本地版本,以便快速安全地开发应用。

前提条件

  • 一个简单的编辑器,例如 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 Authentication:与其他 Firebase 产品集成的代管式身份验证服务。
  • Firebase Hosting:快速安全地托管 Web 应用。

您需要将应用连接到 Emulator Suite 以启用本地开发。

2589e2f95b74fa88

您还将学习如何:

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

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

Emulator Suite 是 Firebase CLI(命令行界面)的一部分,可使用以下命令安装在您的计算机上:

$ npm install -g firebase-tools

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

$ 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. 运行模拟器

在本部分中,您将在本地运行应用。这意味着该启动 Emulator Suite 了。

启动模拟器

从 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.

看到 All emulators started 消息后,应用就可以开始使用了。

将 Web 应用连接到模拟器

根据日志中的表格,我们可以看到 Cloud Firestore 模拟器正在监听端口 8080,而 Authentication 模拟器正在监听端口 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 实例:

public/js/homepage.js

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

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

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

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

打开 EmulatorUI

在网络浏览器中,导航到 http://127.0.0.1:4000/。您应该会看到 Emulator Suite 界面。

模拟器界面主屏幕

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

4ef88d0148405d36

4.运行应用

打开应用

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

939f87946bac2ee4

使用应用

在首页上选择一件商品,然后点击添加到购物车。很遗憾,您会遇到以下错误:

a11bd59933a8e885

我们来修复这个错误!由于一切都在模拟器中运行,因此我们可以进行实验,而不必担心影响实际数据。

5. 调试应用

查找 bug

我们来看一下 Chrome 开发者控制台。按 Control+Shift+J(Windows、Linux、ChromeOS)或 Command+Option+J (Mac) 查看控制台上的错误:

74c45df55291dab1.png

addToCart 方法中似乎出现了错误,我们来看一下。我们在该方法中的哪个位置尝试访问名为 uid 的内容,为什么它是 null?现在,该方法在 public/js/homepage.js 中如下所示:

public/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 Authentication 文档,当我们未登录时,auth.currentUsernull。下面我们添加一项检查:

public/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. 本地函数触发器

点击 Add to Cart 会启动涉及多个模拟器的一系列事件。在 Firebase CLI 日志中,您将商品添加到购物车后,应该会看到如下消息:

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

发生了四个关键事件,以生成这些日志以及您观察到的界面更新:

68c9323f2ad10f7a

1) Firestore 写入 - 客户端

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

public/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) 触发 Cloud Functions 函数

Cloud Functions 函数 calculateCart 会使用 onWrite 触发器监听购物车商品发生的任何写入事件(创建、更新或删除),该触发器可在 functions/index.js 中查看:

functions/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 函数会读取购物车中的所有商品,将总数量和价格相加,然后使用新的总计金额更新“cart”文档(请参阅上面的 cartRef.update(...))。

4) Firestore 读取 - 客户端

Web 前端已订阅接收有关购物车更改的最新动态。它会在 Cloud Functions 函数写入新的总计值并更新界面后获取实时更新,如 public/js/homepage.js 中所示:

public/js/homepage.js

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

回顾

非常棒!您刚刚设置了一个完全本地的应用,该应用使用三个不同的 Firebase 模拟器进行完全本地测试。

db82eef1706c9058.gif

等一下,更多精彩等着你!在下一部分中,您将了解以下内容:

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

7. 为您的应用量身创建安全规则

我们的 Web 应用可读取和写入数据,但到目前为止我们并未真正担心安全性。Cloud Firestore 使用一个名为“安全规则”的系统来声明谁有权读取和写入数据。Emulator Suite 是对这些规则进行原型设计的好方法。

在编辑器中,打开文件 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/ 目录下)

首先进入 functions 目录(在此 Codelab 的其余部分,我们会留在这里):

$ cd functions

现在,在函数目录中运行 Mocha 测试,并滚动到输出的顶部:

# 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. 安全购物车访问

前两个失败项是“购物车”测试,用于测试:

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

functions/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

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 时,Emulator Suite 都会自动更新规则。您可以在运行模拟器的标签页中查看是否显示 Rules updated 消息,确认该模拟器更新了规则:

5680da418b420226

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

$ 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. 查看界面中的“添加到购物车”流程

目前,虽然购物车所有者对购物车中的商品执行读写操作,但无法对购物车中的个别商品执行读写操作。这是因为所有者可以访问购物车文档,但无权访问购物车的商品子集合

对用户来说,这是一种损坏状态。

返回在 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
    }));
  });

我们可以编写一条规则,在当前用户的 UID 与购物车文档中的所有者 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. 再次查看“添加到购物车”流程

返回网络前端 ( http://127.0.0.1:5000),并将商品添加到购物车。这一步非常重要,可以确认我们的测试和规则是否与客户端所需的功能相匹配。(请注意,我们上次尝试使用界面的用户无法将商品添加到购物车!)

69ad26cee520bf24

保存 firestore.rules 后,客户端会自动重新加载规则。因此,不妨尝试向购物车中添加商品。

回顾

真棒!您刚刚提升了应用的安全性,这是让应用为正式版做好准备的关键一步!如果这是一个正式版应用,我们可以将这些测试添加到持续集成流水线中。这样,我们今后就可以放心地对购物车数据实施这些访问权限控制,即使其他人正在修改这些规则也是如此。

ba5440b193e75967.gif

别急,还有更多精彩等着你!

如果继续,您将学到:

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

15. 设置 Cloud Functions 函数测试

到目前为止,我们重点介绍了 Web 应用的前端和 Firestore 安全规则。不过,此应用还使用 Cloud Functions 来使用户的购物车保持最新状态,因此我们也希望测试该代码。

借助 Emulator Suite,您可以轻松测试 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

16. 浏览 Functions 测试

由于此测试会验证 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

打开新的终端标签页(让模拟器保持运行状态),然后进入 functions 目录。您可能仍会在安全规则测试中将其打开。

$ 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. 通过店面界面试用

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

69ad26cee520bf24

确认购物车使用正确的总金额更新。太好了!

回顾

您了解了 Cloud Functions for Firebase 与 Cloud Firestore 之间的一个复杂测试用例。您编写了一个 Cloud Functions 函数来使测试通过。此外,您还确认新功能可以在界面中正常运行!您都是在本地完成的,在您自己的机器上运行模拟器。

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

c6a7aeb91fe97a64.gif