LoginSignup
9
3

More than 3 years have passed since last update.

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

Posted at

はじめに

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

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

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

環境

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

package.jsonの設定

ts-jestやCloud Functions, Firestore rulesテスト用のパッケージを入れる。

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

package.jsonのscriptsにtestを追加。

  "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のインデックス登録を行っている場合、こんな感じでモック化し、正しいパラメータで関数が呼ばれたかをテストする。

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しないと思うので、そこでエラーにならないようにしておく。

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を毎回切り替える実装をどこかに書く必要があり、面倒なので今回後者でやった。

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

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する

公式ドキュメントだと付いてないけど、assertSucceeds, assertFailsはawait付けないと正しいテスト結果にならなかった。

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

公式YouTubeだと入れている。

型について

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をこんな感じで設定してみた。

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 に関するドキュメント

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

FIREBASE_TOKENGCLOUD_PROJECTGitHubのシークレットに入れる。

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

余談

withFunctionTriggersDisabled

特定のテストだけFirestore Triggerを発火させない方法が無いかと思っていたら @firebase/rules-unit-testingwithFunctionTriggersDisabledというのを見つけた。

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

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

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

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

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

セキュリティ ルールのテスト  |  Firestore  |  Google Cloud

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

Local Emulator Suite のインストール、構成、統合  |  Firebase

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3