はじめに
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を毎回切り替える実装をどこかに書く必要があり、面倒なので今回後者でやった。
initializeAdminApp
や initializeTestApp
で初期化した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_TOKEN
と GCLOUD_PROJECT
はGitHubのシークレットに入れる。
-
FIREBASE_TOKEN
:firebase login:ci
で取得できる値 -
GCLOUD_PROJECT
: テスト対象のFirebaseプロジェクトのID
余談
withFunctionTriggersDisabled
特定のテストだけFirestore Triggerを発火させない方法が無いかと思っていたら @firebase/rules-unit-testing
にwithFunctionTriggersDisabledというのを見つけた。
この引数のクロージャの中でFirestoreを書き換える分にはFirestore Triggerが発火しないらしい。
でも実装見てる感じ、クロージャの実行前後でエミュレータのWeb API叩いており、エミュレータ全体でdisableされてしまいそうなので、並列でテストが実行されていたりするとおかしなことになりそう。
テスト用データのセットアップが面倒
毎回思うのは、beforeEach
や beforeAll
でadmin権限のappでテスト用の状態をせっせと作らないといけないのが面倒...。何かもうちょっといい方法はないかと思って調査したけど、結論から言うとなかった。
公式ドキュメントも firebase.initializeAdminApp
を使ってルールをバイパスしてください、ということしか書いてなさそう。
セキュリティ ルールのテスト | Firestore | Google Cloud
実はエミュレータのFirestoreのデータはexport/importできるので、これを使えないかと思ったが、エミュレータの起動時に、かつ指定のプロジェクトにしかimportできなそうで、テストごとに beforeEach
で都度importするようなことはできなそうだった。残念。