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

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 身份验证:与其他 Firebase 产品集成的托管身份验证服务。
  • Firebase 托管:快速、安全的网络应用托管。

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

2589e2f95b74fa88.png

您还将学习如何:

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

2. 设置

获取源代码

在此 Codelab 中,您将从一个几乎完整的 The 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 或更高版本,但更高版本包含更多错误修复。

$ 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://localhost: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://localhost:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost:5000 │ n/a                             │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at localhost: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 ,而身份验证模拟器正在侦听端口9099

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Functions      │ localhost:5001 │ http://localhost:4000/functions │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8080 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Hosting        │ localhost: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 === "localhost") {
    console.log("localhost detected!");
    auth.useEmulator("http://localhost:9099");
    db.useEmulator("localhost", 8080);
  }

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

打开模拟器界面

在您的网络浏览器中,导航到http://localhost:4000/ 。您应该会看到 Emulator Suite UI。

模拟器 UI 主屏幕

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

4ef88d0148405d36.png

4. 运行应用程序

打开应用程序

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

939f87946bac2ee4.png

使用应用程序

在主页上选择一个项目,然后单击Add to Cart 。不幸的是,您将遇到以下错误:

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 Authentication docs ,当我们未登录时, auth.currentUsernull 。让我们为此添加一个检查:

公共/js/homepage.js

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

    // ...
  }

测试应用程序

现在,刷新页面,然后单击Add to Cart 。这次你应该得到一个更好的错误:

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) 触发云函数

Cloud Function 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 使用称为“安全规则”的系统来声明谁有权读取和写入数据。 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 目录(我们将在代码实验室的其余部分留在这里):

$ cd functions

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

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

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

函数/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.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 subcollection

这对用户来说是一个破碎的状态。

返回到在http://localhost:5000,上运行的 Web UI,并尝试将一些东西添加到您的购物车中。您会在调试控制台中看到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 与购物车文档上的 ownerUID 相同,则允许访问。由于不需要为create, update, delete指定不同的规则,因此您可以使用write规则,该规则适用于所有修改数据的请求。

更新项目子集合中文档的规则。条件中的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://localhost: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 之间的交互,因此与之前的代码实验室中的测试相比,它涉及的设置更多。让我们通过这个测试来了解它的预期。

创建购物车

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://localhost:5000/ ) 并将商品添加到购物车。

69ad26cee520bf24.png

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

回顾

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

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

c6a7aeb91fe97a64.gif