Cloud Functions 单元测试

本页面介绍了可用于为您的函数编写单元测试(例如将作为持续集成 [CI] 系统一部分的测试)的最佳实践和工具。Firebase 提供了 Firebase Test SDK for Cloud Functions,以便您能够更轻松地进行测试。该 SDK 以 firebase-functions-test 的名称在 NPM 上分发,是 firebase-functions 的配套测试 SDK。Firebase Test SDK for Cloud Functions 提供以下功能:

  • 负责对您的测试进行适当设置和收尾清理,例如设置 firebase-functions 所需的环境变量并在测试后取消。
  • 生成示例数据和事件上下文,这样您就只需指定与测试相关的字段。

测试设置

在函数文件夹中运行以下命令,安装 firebase-functions-testMocha(一个测试框架):

npm install --save-dev firebase-functions-test
npm install --save-dev mocha

接下来,在函数文件夹内创建一个 test 文件夹,并在其中创建一个新文件以保存您的测试代码,将该文件命名为类似于 index.test.js 的名称。

最后,修改 functions/package.json 以添加以下内容:

"scripts": {
  "test": "mocha --reporter spec"
}

编写完测试后,便可以在函数目录中运行 npm test 来运行测试。

初始化 Firebase Test SDK for Cloud Functions

您可以通过两种方法使用 firebase-functions-test

  1. 在线模式(推荐):编写与专门用于测试的 Firebase 项目交互的测试,以确保数据库写入、用户创建等操作会实际执行,并且您的测试代码可以检查结果。这也意味着您函数中使用的其他 Google SDK 也可以正常运行。
  2. 离线模式:编写独立的离线单元测试,避免副作用。 这意味着与 Firebase 产品交互(例如向数据库写入数据或创建用户)的所有方法调用都需要进行存根操作。如果您有 Cloud FirestoreRealtime Database 函数,一般不建议使用离线模式,因为这会大大增加测试代码的复杂性。

在线模式下初始化 SDK(推荐)

如果您想要编写与某个测试项目交互的测试,则需要提供通过 firebase-admin 初始化应用所需的项目配置值,以及服务账号密钥文件的路径。

如需获取 Firebase 项目的配置值,请执行以下操作:

  1. Firebase 控制台中打开您的项目设置。
  2. 您的应用中,选择所需的应用。
  3. 在右侧窗格中,选择下载 Apple 和 Android 应用的配置文件的选项。

    对于 Web 应用,选择配置以显示配置值。

如需创建密钥文件,请执行以下操作:

  1. 打开 Google Cloud 控制台的“服务账号”窗格
  2. 选择 App Engine 默认服务账号,然后使用右侧的选项菜单选择创建密钥
  3. 出现提示时,选择 JSON 作为密钥类型,然后点击创建

保存密钥文件后,按以下方式初始化 SDK:

// At the top of test/index.test.js
const test = require('firebase-functions-test')({
  databaseURL: 'https://my-project.firebaseio.com',
  storageBucket: 'my-project.appspot.com',
  projectId: 'my-project',
}, 'path/to/serviceAccountKey.json');

在离线模式下初始化 SDK

如果您想要编写完全离线的测试,则可以不使用任何参数初始化 SDK:

// At the top of test/index.test.js
const test = require('firebase-functions-test')();

模拟配置值

如果您在函数代码中使用了 functions.config(),则可以模拟配置值。例如,如果 functions/index.js 包含以下代码:

const functions = require('firebase-functions/v1');
const key = functions.config().stripe.key;

您便可以在测试文件中模拟该值,具体如下所示:

// Mock functions config values
test.mockConfig({ stripe: { key: '23wr42ewr34' }});

导入您的函数

如需导入您的函数,请使用 require 将主要函数文件作为模块导入。请务必仅在初始化 firebase-functions-test 并模拟配置值后执行此操作。

// after firebase-functions-test has been initialized
const myFunctions = require('../index.js'); // relative path to functions code

如果您在离线模式下初始化了 firebase-functions-test,并且函数代码中有 admin.initializeApp(),则需要先对它进行存根,然后再导入函数:

// If index.js calls admin.initializeApp at the top of the file,
// we need to stub it out before requiring index.js. This is because the
// functions will be executed as a part of the require process.
// Here we stub admin.initializeApp to be a dummy function that doesn't do anything.
adminInitStub = sinon.stub(admin, 'initializeApp');
// Now we can require index.js and save the exports inside a namespace called myFunctions.
myFunctions = require('../index');

测试后台(非 HTTP)函数

测试非 HTTP 函数的过程包含以下步骤:

  1. 使用 test.wrap 方法封装您想要测试的函数
  2. 构建测试数据
  3. 使用您构建好的测试数据以及您想要指定的所有事件上下文字段调用封装的函数。
  4. 就行为创建断言。

首先封装您想要测试的函数。假设 functions/index.js 中有一个名为 makeUppercase 的函数,并且您想要测试该函数,请在 functions/test/index.test.js 中编写以下内容:

// "Wrap" the makeUpperCase function from index.js
const myFunctions = require('../index.js');
const wrapped = test.wrap(myFunctions.makeUppercase);

wrapped 是一个函数,当它被调用时会调用 makeUppercasewrapped 接受 2 个参数:

  1. data(必需):要发送到 makeUppercase 的数据。这直接对应于发送到您编写的函数处理程序的第一个参数。firebase-functions-test 提供了用于构建自定义数据或示例数据的方法。
  2. eventContextOptions(可选):您希望指定的事件上下文的字段。事件上下文是发送到您编写的函数处理程序的第二个参数。如果您在调用 wrapped 时未提供 eventContextOptions 参数,系统仍会生成包含适当字段的事件上下文。您可以在此处指定具体的字段,以替换系统生成的部分字段。请注意,您只需提供想要替换的字段即可。系统会生成您未替换的所有字段。
const data = … // See next section for constructing test data

// Invoke the wrapped function without specifying the event context.
wrapped(data);

// Invoke the function, and specify params
wrapped(data, {
  params: {
    pushId: '234234'
  }
});

// Invoke the function, and specify auth and auth Type (for real time database functions only)
wrapped(data, {
  auth: {
    uid: 'jckS2Q0'
  },
  authType: 'USER'
});

// Invoke the function, and specify all the fields that can be specified
wrapped(data, {
  eventId: 'abc',
  timestamp: '2018-03-23T17:27:17.099Z',
  params: {
    pushId: '234234'
  },
  auth: {
    uid: 'jckS2Q0' // only for real time database functions
  },
  authType: 'USER' // only for real time database functions
});

构建测试数据

经过封装的函数的第一个参数是用来调用底层函数的测试数据。您可以通过多种方法构建测试数据。

使用自定义数据

firebase-functions-test 有若干用于构建测试函数所需数据的函数。例如,使用 test.firestore.makeDocumentSnapshot 可以创建 Firestore DocumentSnapshot。其中第一个参数是数据,第二个参数是完整引用路径,可选的第三个参数用于您可以指定的其他快照属性。

// Make snapshot
const snap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Call wrapped function with the snapshot
const wrapped = test.wrap(myFunctions.myFirestoreDeleteFunction);
wrapped(snap);

如果您要测试 onUpdateonWrite 函数,则需要创建两个快照:一个代表之前的状态,另一个代表之后的状态。然后,您可以使用 makeChange 方法创建一个包含这些快照的 Change 对象。

// Make snapshot for state of database beforehand
const beforeSnap = test.firestore.makeDocumentSnapshot({foo: 'bar'}, 'document/path');
// Make snapshot for state of database after the change
const afterSnap = test.firestore.makeDocumentSnapshot({foo: 'faz'}, 'document/path');
const change = test.makeChange(beforeSnap, afterSnap);
// Call wrapped function with the Change object
const wrapped = test.wrap(myFunctions.myFirestoreUpdateFunction);
wrapped(change);

如需了解所有其他数据类型的类似函数,请参阅 API 参考文档

使用示例数据

如果您不需要自定义测试中使用的数据,firebase-functions-test 提供了用于为每个函数类型生成示例数据的方法。

// For Firestore onCreate or onDelete functions
const snap = test.firestore.exampleDocumentSnapshot();
// For Firestore onUpdate or onWrite functions
const change = test.firestore.exampleDocumentSnapshotChange();

如需了解用于为每个函数类型获取示例数据的方法,请参阅 API 参考文档

使用桩数据(针对离线模式)

如果您在离线模式下初始化了 SDK,并且要测试 Cloud FirestoreRealtime Database 函数,则应使用具有桩的普通对象,而不是创建实际的 DocumentSnapshotDataSnapshot

假设您要为以下函数编写单元测试:

// Listens for new messages added to /messages/:pushId/original and creates an
// uppercase version of the message to /messages/:pushId/uppercase
exports.makeUppercase = functions.database.ref('/messages/{pushId}/original')
    .onCreate((snapshot, context) => {
      // Grab the current value of what was written to the Realtime Database.
      const original = snapshot.val();
      functions.logger.log('Uppercasing', context.params.pushId, original);
      const uppercase = original.toUpperCase();
      // You must return a Promise when performing asynchronous tasks inside a Functions such as
      // writing to the Firebase Realtime Database.
      // Setting an "uppercase" sibling in the Realtime Database returns a Promise.
      return snapshot.ref.parent.child('uppercase').set(uppercase);
    });

在该函数内使用了两次 snap

  • snap.val()
  • snap.ref.parent.child('uppercase').set(uppercase)

在测试代码中,创建一个普通对象(这两个代码路径在该对象中都可正常工作),并使用 Sinon 对这些方法进行存根。

// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);

创建断言

在初始化 SDK、封装函数和构建数据之后,您可以使用构造的数据调用封装的函数,并就其行为创建断言。您可以使用 Chai 等库创建这些断言。

在在线模式下创建断言

如果您采用在线模式初始化了 Firebase Test SDK for Cloud Functions,则可以使用 firebase-admin SDK 断言期望的操作(如数据库写入)已被执行。

以下示例断言 ‘INPUT’ 已写入测试项目的数据库中。

// Create a DataSnapshot with the value 'input' and the reference path 'messages/11111/original'.
const snap = test.database.makeDataSnapshot('input', 'messages/11111/original');

// Wrap the makeUppercase function
const wrapped = test.wrap(myFunctions.makeUppercase);
// Call the wrapped function with the snapshot you constructed.
return wrapped(snap).then(() => {
  // Read the value of the data at messages/11111/uppercase. Because `admin.initializeApp()` is
  // called in functions/index.js, there's already a Firebase app initialized. Otherwise, add
  // `admin.initializeApp()` before this line.
  return admin.database().ref('messages/11111/uppercase').once('value').then((createdSnap) => {
    // Assert that the value is the uppercased version of our input.
    assert.equal(createdSnap.val(), 'INPUT');
  });
});

在离线模式下创建断言

您可以就函数的预期返回值创建断言:

const childParam = 'uppercase';
const setParam = 'INPUT';
// Stubs are objects that fake and/or record function calls.
// These are excellent for verifying that functions have been called and to validate the
// parameters passed to those functions.
const childStub = sinon.stub();
const setStub = sinon.stub();
// The following lines creates a fake snapshot, 'snap', which returns 'input' when snap.val() is called,
// and returns true when snap.ref.parent.child('uppercase').set('INPUT') is called.
const snap = {
  val: () => 'input',
  ref: {
    parent: {
      child: childStub,
    }
  }
};
childStub.withArgs(childParam).returns({ set: setStub });
setStub.withArgs(setParam).returns(true);
// Wrap the makeUppercase function.
const wrapped = test.wrap(myFunctions.makeUppercase);
// Since we've stubbed snap.ref.parent.child(childParam).set(setParam) to return true if it was
// called with the parameters we expect, we assert that it indeed returned true.
return assert.equal(wrapped(snap), true);

您还可以使用 Sinon spy 来断言某些方法已被调用且采用了您预期的参数。

测试 HTTP 函数

如需测试 HTTP onCall 函数,请使用与测试后台函数相同的方法。

如果您要测试 HTTP onRequest 函数,则应在以下情况下使用 firebase-functions-test

  • 您使用 functions.config()
  • 您的函数与 Firebase 项目或其他 Google API 进行交互,并且您希望使用真实的 Firebase 项目及其凭据进行测试。

HTTP onRequest 函数接受两个参数:一个请求对象和一个响应对象。您可以按以下方法测试 addMessage() 示例函数

  • 替换响应对象中的 redirect 函数,因为 sendMessage() 会调用该函数。
  • 在 redirect 函数中,使用 chai.assert 创建应该使用什么参数来调用 redirect 函数的断言:
// A fake request object, with req.query.text set to 'input'
const req = { query: {text: 'input'} };
// A fake response object, with a stubbed redirect function which asserts that it is called
// with parameters 303, 'new_ref'.
const res = {
  redirect: (code, url) => {
    assert.equal(code, 303);
    assert.equal(url, 'new_ref');
    done();
  }
};

// Invoke addMessage with our fake request and response objects. This will cause the
// assertions in the response object to be evaluated.
myFunctions.addMessage(req, res);

测试清理

在测试代码的最后,调用 cleanup 函数。这将取消初始化时 SDK 设置的环境变量,并删除可能创建的 Firebase 应用(前提是您使用 SDK 创建了实时数据库 DataSnapshot 或 Firestore DocumentSnapshot)。

test.cleanup();

查看完整示例并了解详情

您可以在 Firebase GitHub 代码库中查看完整的示例。

如需了解详情,请参阅 firebase-functions-testAPI 参考文档