Firebase Local Emulatorを使ったCloud FunctionsとFirestoreセキュリティルールのテスト（GitHub Actionsで実行するまで）

# はじめに

GitHub ActionsでFirebase Local Emulatorを起動させてCloud FunctionsとFirestoreセキュリティルールのテストを行うよう設定したので、得た知見を書きます。

１からのテストの導入を丁寧に解説する記事ではないので、そういう場合は公式ドキュメントや他記事を参考にしてみてください。

GitHub Actionsでのテスト実行を前提として書いていますが、今回改めてFirebaseのテスト環境を構築してみて、設定に迷ったところやハマったところなど、GitHub Actionsに関係ない点に関しても書いています。ポイントだけ書いているので全体の文章の流れはあまり意識できてないです。見出しを見て気になったところだけ読んでいただければと思います。

# 環境

- 言語: TypeScript
- テストフレームワーク: jest
- Firebase環境: Firestore、Cloud Function(Next.jsをホストするものと、Firestore Triggerで起動してAlgoliaを更新するもの2種類)

# package.jsonの設定

[ts-jest](https://typescript-jp.gitbook.io/deep-dive/intro-1/jest)やCloud Functions, Firestore rulesテスト用のパッケージを入れる。

```zsh
yarn add -D jest @types/jest ts-jest firebase-functions-test @firebase/rules-unit-testing
```

package.jsonのscriptsにtestを追加。

```json
  "scripts": {
    "test": "firebase emulators:exec --only firestore 'jest --silent --env=node'"
  }
```

`firebase emulators:exec` を使うと、`''` 内のスクリプト実行前にエミュレータのダウンロードと起動、スクリプト実行後にエミュレータの停止をやってくれる。

単純にFirestoreのルールや、Cloud Functionsの単体テストを実行するだけなら `--only firestore` をつけるとよい。Cloud Functionsのエミュレータも起動させると、Firestore Triggerを実際に発火させて結果を見るようなテストもできる。ただその場合、テスト中無駄にFirestore Triggerが発火しないよう注意しないといけないので、必要なときだけ起動するようにした方がよいと思う。

Cloud Functionsでログ出力している場合に、テスト実行中ログが出力されて内容が見にくくなってしまうので `--silent` を入れているが、不要なら消してもよい。

`--env=node` を入れないと次のエラーが発生する。

```
INTERNAL ASSERTION FAILED: Unexpected state
```

jest.configの `testEnvironment: 'node'` で指定してもよい。

# Cloud Functionsのテスト

### モジュールのモック化

必要に応じてテストコードの冒頭でモジュールをモックしておく。

例えばAlgoliaのインデックス登録を行っている場合、こんな感じでモック化し、正しいパラメータで関数が呼ばれたかをテストする。

```typescript
let mockSaveObject: ReturnType<typeof jest.fn>
jest.mock('algoliasearch', () => {
  mockSaveObject = jest.fn().mockReturnValue({})
  return {
    default: () => ({
      initIndex: () => ({
        saveObject: mockSaveObject,
      }),
    }),
  }
})
```

### `functions.config()` へのアクセス

Cloud Functionsの `functions.config()` で取得できる環境構成にアクセスする場合、`.runtimeconfig.json` が無いと `undefined` が返ってくる。GitHub Actionsではおそらく .runtimeconfig.json` はpushしないと思うので、そこでエラーにならないようにしておく。

```typescript
functions.config().algolia?.app_id // こうすればalgoliaがundefinedでもエラーにならない
```

`.runtimeconfig.json` を一緒にpushしてもよいが、その場合、自分の手元でCloud Functionsのエミュレータを立ち上げるときにも参照されてしまうと思うので注意。

### Next.js用のCloud Functionが含まれている場合

通常rootのindex.tsですべてのCloud Functionsがexportさせると思うが、テストの際にこのindex.tsをimportしようとすると、Next.jsの実装も一緒にimportされ、依存関係の対処でハマる。

なので、 `functions.CloudFunction` をexportするファイルを個別に作っておいて、rootのindex.tsはそれを束ねてexportし、テストは個別にimportするようにした方がよさそう。

# Firestoreセキュリティルールのテスト

### テストごとのデータの掃除について

各テスト前にFirestoreの状態をクリアする方法として2通りある。

* `beforeEach` でprojectIDを毎回変える。
* `afterEach` で都度 `clearFirestoreData` する。

前者でもよいが、projectIDを毎回切り替える実装をどこかに書く必要があり、面倒なので今回後者でやった。

`initializeAdminApp` や `initializeTestApp` で初期化したappは使ったら `delete` しないと、プロセスが残り続けるのかテストが終了してもCIが先に進まなくなる（ローカルで実行する分には終了する）。 `delete` 書き忘れて気づいたらCIがずっと止まってた、みたいな事態はありそう。一応 `jest --forceExit` で実行すれば強制終了させることもできる。

```typescript
const projectId = 'some-test-project-id' // テストファイル個別に付ける

beforeEach(async () => {
  firebase.loadFirestoreRules({
    projectId,
    rules: fs.readFileSync('firestore.rules', 'utf8'),
  })

  const adminApp = firebase.initializeAdminApp({ projectId })
  // テスト用データの書き込み

  await adminApp.delete()
})

afterEach(async () => {
  await firebase.clearFirestoreData({ projectId })
})
```

### assertSucceeds, assertFailsはawaitする

[公式ドキュメントだと付いてないけど](https://firebase.google.com/docs/rules/unit-tests?hl=ja)、assertSucceeds, assertFailsはawait付けないと正しいテスト結果にならなかった。

```typescript
test('read shoud be succeeded', async () => {
    await firebase.assertSucceeds(
        app.firestore().collection('somecollection').doc('somedocument').get()
    )
})
```

[公式YouTube](https://www.youtube.com/watch?v=VDulvfBpzZE)だと入れている。

### 型について

initializeAdminAppで取得したappでfirestoreに書き込む際、Timestampの型はfirebase-admin由来でないとエラーになった。

```
Value for argument "data" is not a valid Firestore document. Detected an object of type "Timestamp" that doesn't match the expected instance (found in field "createdAt"). Please ensure that the Firestore types you are using are from the same NPM package.)
```

# GitHub Actionsの設定方法

GitHub ActionsでFirebaseエミュレータを使う方法を調べるとDocker Composeを使う例が出てくる。それでもよさそうだが、その場合ローカルで同じテストを行う際にもDockerが必要になったり、Docker Composeで起動したエミュレータに接続するための設定が必要そうだった。

nodeとjavaがある環境ならそのままエミュレータを起動しつつテストできるので、今回はDocker Composeは使わず、workflowsのyamlをこんな感じで設定してみた。

```yaml
name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-18.04
    env:
      FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
      GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: '10.18.1'
      - uses: actions/setup-java@v1
        with:
          java-version: '15.0.2'
          java-package: jdk
          architecture: x64
      - name: Install dependencies
        run: yarn
      - name: Test
        run: yarn test --project ${GCLOUD_PROJECT}
```

設定はこれだけなのでシンプルで済むと思う。

環境はとりあえずCloud Functionsの環境を参考にしてみた。
[Node\.js 10 ランタイム  \|  Google Cloud Functions に関するドキュメント](https://cloud.google.com/functions/docs/concepts/nodejs-10-runtime?hl=ja)

> Node.js 10 ランタイムは、Node.js バージョン 10.18.1 がインストールされた Ubuntu 18.04 の実行環境を使用します。

`FIREBASE_TOKEN` と `GCLOUD_PROJECT` は[GitHubのシークレット](https://docs.github.com/ja/actions/reference/encrypted-secrets)に入れる。

* `FIREBASE_TOKEN` : `firebase login:ci` で取得できる値
* `GCLOUD_PROJECT` : テスト対象のFirebaseプロジェクトのID

# 余談

### withFunctionTriggersDisabled

特定のテストだけFirestore Triggerを発火させない方法が無いかと思っていたら `@firebase/rules-unit-testing`に[withFunctionTriggersDisabled](https://github.com/firebase/firebase-js-sdk/blob/97f26b7168a2765618c7469f5ed1bf86b7c4ee7e/packages/rules-unit-testing/index.ts#L37)というのを見つけた。

この引数のクロージャの中でFirestoreを書き換える分にはFirestore Triggerが発火しないらしい。

でも実装見てる感じ、クロージャの実行前後でエミュレータのWeb API叩いており、エミュレータ全体でdisableされてしまいそうなので、並列でテストが実行されていたりするとおかしなことになりそう。

### テスト用データのセットアップが面倒

毎回思うのは、`beforeEach` や `beforeAll` でadmin権限のappでテスト用の状態をせっせと作らないといけないのが面倒...。何かもうちょっといい方法はないかと思って調査したけど、結論から言うとなかった。

公式ドキュメントも `firebase.initializeAdminApp` を使ってルールをバイパスしてください、ということしか書いてなさそう。

[セキュリティ ルールのテスト  \|  Firestore  \|  Google Cloud](https://cloud.google.com/firestore/docs/security/test-rules-emulator?hl=ja#test_setup_is_very_complicated)

実はエミュレータのFirestoreのデータはexport/importできるので、これを使えないかと思ったが、エミュレータの起動時に、かつ指定のプロジェクトにしかimportできなそうで、テストごとに `beforeEach` で都度importするようなことはできなそうだった。残念。

[Local Emulator Suite のインストール、構成、統合  \|  Firebase](https://firebase.google.com/docs/emulator-suite/install_and_configure?hl=ja#export_and_import_emulator_data)