📝

Cloud Functions(第 2 世代)による Cloud Firestore の拡張のテストコードを書く

2024/05/07に公開

この記事について

Cloud Functions(第2世代)による Firestore の拡張のテストコードの書き方は Cloud Functions(第1世代)とほとんど同じなので、基本的には Unit testing of Cloud Functions を読んで実装していけば良く、Zenn にも参考となる記事が複数あります。

しかし第1世代との違いが一点あり、その一点に関する情報をうまく探せず時間を費やしたため第2世代の書き方として記事にしました。

テストコードを書く前提となる部分(テスト対象となる関数の書き方、Firebase CLI のインストール、エミュレータの導入等)は説明しません。

環境

開発環境

  • Node.js: 20.x
  • OpenJDK: 21(※エミュレータ用)
  • firebase CLI: 13.8.0

npm libraries

  • firebase-admin: 12.1.0
  • firebase-functions: 5.0.1
  • firebase-functions-test: 3.2.0
  • vitest: 1.6.0

完成版のソースコード

https://github.com/k1350/functions-sample

※実際にデプロイして無事に動いたという実績が無いので、テストコードの書き方以外は参考にしないほうがいいです。

テストコードの書き方

setupFile

公式ドキュメントの Importing your functions の記載によると、テスト対象の関数を import する前に firebase-functions-test を初期化する必要があります。
よって firebase-functions-test の初期化は setupFile に分け、テストコードの実行前に実行されるようにしました。

今回はオンラインモードでテストするのですが、Firestore についてはエミュレータを使用するので初期化時に指定する projectId はダミーの値で良いです。
今回は databaseURL や storageBucket の指定も不要でした。

https://github.com/k1350/functions-sample/blob/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions/setup.ts#L4-L8

(※公式ドキュメントでは test という変数名を使っているのですが、vitest の test と名前が被るので featuresList という変数名を使用しています。型が FeaturesList という名前だったのでこの変数名としました。)

Test cleanup も忘れずに行います。

https://github.com/k1350/functions-sample/blob/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions/setup.ts#L18-L20

その他、Cloud Functions から Firestore のエミュレータへ接続する部分も setup に含めます。

エミュレータへの接続に関してはテストコードの書き方のドキュメントではなく Connect your app to the Cloud Firestore Emulator のほうに記載されています。
Admin SDKs の部分に記載の通り、FIRESTORE_EMULATOR_HOST という環境変数が指定された環境では Admin SDK は Firestore のエミュレータに接続します。
よってテスト実行時に環境変数を指定すれば良いです。

https://github.com/k1350/functions-sample/blob/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions/setup.ts#L14-L16

テストごとにエミュレータ上のデータを削除するには Clear your database between tests に記載の URL を DELETE で叩けば良いです。

https://github.com/k1350/functions-sample/blob/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions/setup.ts#L22-L27

以上を合わせて setupFile とします。

https://github.com/k1350/functions-sample/blob/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions/setup.ts

単体テストの実装

単体テストの実装は Testing background (non-HTTP) functions に記載の通りで、wrap という関数でテスト対象の関数をラップし、テスト対象のデータとオプションを渡して実行すればいいです。

しかし wrap でラップした後の関数へ第1世代と同じように引数を渡すと型が合わずエラーになります。

第2世代に関するドキュメントである Extend Cloud Firestore with Cloud Functions (2nd gen) にはテストコードについての記載がありません。

リポジトリを探してみると Incorrect "wrap" typing for v2 onCall cloud functions という issue が見つかり、どうやら firebase-functions-test はまだ第2世代に完全対応できていないことがわかります。
上記の issue のコメントを丹念に読むと wrap が第2世代の firestore function triggers に対応していると言っている人と対応していないと言っている人がいて混乱に拍車をかけますが、自分で型情報を確認して実際に試した結論としては、第2世代の firestore function triggers には wrap は対応しています。

第1世代では wrap でラップした後の関数は第1引数がデータ, 第2引数がその他オプションです。
第2世代では第1引数にデータとオプションが全部入るようになります。
関係あるところだけ抜粋するとこんな感じになります。

import firebaseFunctionsTest from "firebase-functions-test";
import { myFunction } from "../index";

const test = firebaseFunctionsTest({
  projectId: "demo-functions",
});
const wrapped = test.wrap(myFunction);

const data = test.firestore.makeDocumentSnapshot(
  {
    dummy: "dummy",
  },
  "/path",
);

// 第1世代の書き方
// wrapped(data, {
//   params: {
//     pushId: '234234'
//   }
// });

// 第2世代の書き方
wrapped({
  data: data,
  params: {
    pushId: '234234'
  },
});

wrap でラップした後の引数の取り方が異なる以外は第1世代と第2世代のテストコードの書き方は何も変わりません。
参考として onDocumentDeleted のテストコードは下記のようになります。

onDeleted.test.ts
import { getFirestore } from "firebase-admin/firestore";
import type { FeaturesList } from "firebase-functions-test/lib/features";
import { beforeAll, describe, expect, test } from "vitest";
import { onDeleted } from "../../functions/src";
import { getFeaturesList } from "./setup";

const TEST_URL = "https://example.com/1";

describe("onDeleted", () => {
  let featuresList: FeaturesList;
  beforeAll(() => {
    featuresList = getFeaturesList();
  });

  test("like の総数を保存できる", async () => {
    // テストデータを Firestore に登録する場合に特殊な構文は不要
    // Admin SDK の接続先がエミュレータなので、Admin SDK を使って普通にデータを登録すればエミュレータに登録される。
    const db = getFirestore();
    const id = "l1";
    await db.collection("/likes").add({
      url: TEST_URL,
      createdAt: {
        _seconds: 1578762627,
        _nanoseconds: 828000000,
      },
    });

    // onDocumentDeleted に渡すデータ作成
    const deleted = featuresList.firestore.makeDocumentSnapshot(
      {
        url: TEST_URL,
        createdAt: {
          _seconds: 1578762629,
          _nanoseconds: 828000000,
        },
      },
      `/likes/${id}`,
    );

    // テスト対象を `wrap` でラップしてデータを渡して実行する
    const wrapped = featuresList.wrap(onDeleted);
    await wrapped({
      params: {
        likeId: id, // onDocumentDeleted 側で event.params.likeId で取得できる値
      },
      data: deleted, // onDocumentDeleted 側で event.data.data() で取得できる値
    });

    const data = (
      await db.doc(`/summary/${encodeURIComponent(TEST_URL)}`).get()
    ).data();
    expect(data).toEqual({
      url: TEST_URL,
      total: 1,
    });
  });
});

その他の例は下記をご参照ください。

https://github.com/k1350/functions-sample/tree/6bf9e63c4c1aac9a19b1a0235bddb8cd45b6841a/tests/functions

テストの設定

テストごとに Firestore エミュレータのデータを全削除している影響で、複数のテストが並列で走ると消してはいけないデータも消える可能性があります。

テストの実装方法を工夫することによって解決できるかもしれませんが、今回は単純にテストを直列実行することにし、vitest で下記のように設定しました。

vitest.workspace.ts
import { defineConfig, defineWorkspace } from "vitest/config";

export default defineWorkspace([
  defineConfig({
    test: {
      name: "functions",
      include: ["tests/functions/**/*.test.ts"],
      setupFiles: ["tests/functions/setup.ts"],
      fileParallelism: false,
      poolOptions: {
        threads: {
          singleThread: true,
        },
      },
    },
  }),
]);

※テストを直列実行するにはどういう設定が必要かが結構難しく、理解しきれていないところがあります。
上記の設定で直列実行はできていますが、最適な設定ではないかもしれません。

テストの実行

テスト実行時は Firestore のエミュレータを起動してからテストコードを実行します。
私は下記のような scripts を設定して npm run test:functions でテストを実行しています。

package.json
{
  "scripts": {
    // 略
    "vitest:functions": "vitest run --project functions",
    "test:functions": "firebase emulators:exec --only firestore 'npm run vitest:functions'"
  },
  // 略
}

以上です。
ソースコードのすべてを説明してはいないので、不明点については完成版のソースコードを参照してみてください。

chot Inc. tech blog

Discussion