🫥

Cloud Functions の単体テストを考える

2023/12/22に公開

こんにちは!アルダグラムでエンジニアをしている内倉です。

本記事は株式会社アルダグラム Advent Calendar 2023 22日目の記事です🥳

これまで、時折 Cloud Functions のコードを書くことはあったのですが、テストについてあまりきちんと知らなかったので、今回は 公式ドキュメント を読みながら、プロジェクトに合った単体テストについて考えてみたいと思います。

テストのセットアップ

firebase-functions-test という公式のユニットテストライブラリがあるそうです。

これを使えば firebase-functions で必要とされる環境変数の設定や設定解除など、テストの適切なセットアップと破棄を行ってくれるとのこと。

なんだかテストがすごく簡単に書けそうですね。

早速、準備していきましょう。

テストフレームワークは、お好みのもので。

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

今回は Jest を使うので、Jest の設定もやっておきます。

import { Config } from '@jest/types'
type InitialOptions = Config.InitialOptions

const config: InitialOptions = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: [
    "**/*.spec.ts"
  ]
}

module.exports = config

オンラインモードか、オフラインモードか

firebase-functions-test は、実際にテスト用データベースに書き込みを行うオンラインモードと、すべての操作をスタブで行うオフラインモード、どちらかを選択して利用することできます。

通常は、外部リソースにアクセスするコードのテストでは、該当箇所をモック化するというのが、最も手軽で一般的なアプローチです。

しかし、Cloud Functions では、Firestore や Realtime Database など、単なるデータベース以上の機能を持つサービスとやりとりする場合が多く、完全にその挙動を模倣するのは難しいため、オンラインモードの利用が推奨されています。

今回のプロジェクトでも、Firestore を多く利用しているので、おすすめのオンラインモードにしてみます。

とはいえ、本物のサービスアカウントを用意するのは大変なので、エミュレーターを使ってみたいと思います。

サンプルコード

Firestore を利用する、次のような Functions をテストしてみたいと思います。

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'

admin.initializeApp()

export const createChat = functions.https.onCall(async (data, context) => {
  const initialUsers = data.users; // 初期ユーザーの配列
  const chatName = data.name; // チャットの名前
  // チャットドキュメントの作成
  const chatData = {
      name: chatName,
      users: initialUsers,
      createdAt: admin.firestore.FieldValue.serverTimestamp()
  };
  try {
      const chatRef = await admin.firestore().collection('chats').add(chatData)
      return { chatId: chatRef.id }
  } catch (error) {
      throw new functions.https.HttpsError('unknown', 'Failed to create chat', error)
  }
})

サンプルコードのテスト

準備

事前に、Firebase Emulator を立ち上げておきます。

もし、はじめて Emulator を利用する場合は、 以下のコマンドでセットアップします。

firebase init emulators

firebase emulators:start で Emulator を起動します。デフォルトでは、 firebase.json に記載してあるものが一式起動しますが、 —only オプションで必要なものだけを指定することもできます。

今回は、Firestore だけあればいいので、以下のようにしておきましょう。

firebase emulators:start --only firestore

テストを書いていきます💪

大体、こんな感じになります。

import * as admin from 'firebase-admin'
import { describe, expect, it } from '@jest/globals'

const TEST_PROJECT_ID = 'project-id-dayo'
// ① オンラインモードで Firebase Test SDK を初期化
const functionsTest = require('firebase-functions-test')({
  projectId: TEST_PROJECT_ID,
})

describe('createChat', () => {
  let myFunctions: any

  beforeAll(() => {
    // ② emulator を見てもらうための環境変数をセット
    process.env.FIRESTORE_EMULATOR_HOST = 'localhost:5003'
    myFunctions = require('../src/testIndex')
  })

  afterEach(async () => {
    // ④ テストごとにデータをクリアする
    await fetch(
      `http://${process.env.FIRESTORE_EMULATOR_HOST}/emulator/v1/projects/${TEST_PROJECT_ID}/databases/(default)/documents`,
      { method: 'DELETE' },
    )
  })

  afterAll(() => {
    // ⑤ テスト終了時には、 Firebase Test SDK をクリーンアップ
    functionsTest.cleanup()
  })

  describe('success to saved', () => {
    const data = {
      users: ['user1', 'user2'],
      name: 'test chat'
    }

    it('return chatId', async () => {
      // ③ テスト対象の function をラップすることで、任意のタイミングで呼び出せる
      const wrapped = functionsTest.wrap(myFunctions.createChat)
      const result = await wrapped(data)

      const chatId = result.chatId

      const chatRef = admin.firestore().collection('chats').doc(chatId);
      const savedChat = await chatRef.get()

      // データが保存されているか確認
      expect(savedChat.exists).toBeTruthy()
      expect(savedChat.data()).toEqual(expect.objectContaining(data))
    })
  })
})

firebase-functions-test の初期化

引数を渡すことで、オンラインモードで初期化します。

引数なしで、 require('firebase-functions-test')() のようにした場合は、オフラインモードにできます。

FIRESTORE_EMULATOR_HOST セット

Emulator を利用するために、どこかしらで FIRESTORE_EMULATOR_HOST に、Emulator が起動しているホストをセットしておきます。

③ テスト対象の function を wrap でラップして、実行

data(wrap した function に渡すデータ)と、eventContextOptions (イベントコンテキスト)を引数にとります。eventContextOption は、特にカスタマイズしたい項目がなければ、省略できます。

例えば「特定のユーザーの操作ということにしたい」「特定の時間にトリガーとなるイベントが発生したことにしたい」みたいなときに、必要な項目だけをセットするかんじです。

トリガーのテストとかも、任意のタイミングで実行できるので嬉しいですね。

④ テストデータのリセット

テスト中に作成したデータを、自力で消さないといけません。

Emulator だと、通常のデータ操作で消さずとも、まるまる削除するエンドポイントがあるので、リセットの面でもちょっとだけ楽できます。

⑤ おそうじ

テストの最後に、④ のクリーンアップ関数を呼び出して終わります。

テスト実行したらエラーになる?

最初、テスト実行したら次のエラーが出たことがありました。

Error: 13 INTERNAL: Received RST_STREAM with code 2 triggered by internal client error: Protocol error

これはローカルで、Firebase Emulator を使ってテストを行っているときに、発生することがあるようです。

単に、Emulator との接続がうまくいっていない可能性が高いので、 FIRESTORE_EMULATOR_HOST や Emulator の起動を確認してみましょう。

(自分のときは、 —only firestore したつもりが --only functions しちゃっていました)

感想

本当は、GitHub Actions の CI で Firestore Emulator を使うのをやってみたかったのですが、全然たどり着きませんでした🥲

Jest 使っていたら、テストの並列実行が簡単にできるけれど、データリセットのことを考えると、結構工夫が必要な点がありそうです。

データ追加・変更を行うテストだけ直列で実行するとか(データ参照だけで済むことがあんまりない = ほぼほぼ直列になってしまうな?)、Emulator 複数起動するとか(ほんとに?)、コレクション名などを外から指定できるような実装にしておいて、テスト時はプレフィックスをつけたりして他のテストに影響しないようにするとか(ほんとに??)。

とりあえず、テスト毎に Emulator のデータまるごと削除!するのは、逆に大変なのかもしれないな〜。

なんか、そんな面倒なやつじゃない方法があるはずだと思うので、また調べていきたいです。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion