この記事はFirebaseアドベントカレンダー 16日目の記事です。
今年のFirebase Summitの発表は、Firebase ExtensionsやiOS/Androidと比べてサポートが弱かったWeb周りの強化などが目玉でした。ですが、その裏でEmulator SuitesとしてFirestore, Cloud Functions, Hostingのエミュレータも整備されてこれまでよりも便利になりました。
今回はこの各種エミュレータ + 最近追加されたばかりのPubSubエミュレータを使ったテストの書き方や、その使い方の注意点を解説します。
tl;dr;
- 基本はcodelabで体験するのが早いのでおすすめ
- Cloud Functions, PubSubエミュレータはテストコードでのproject_idを一致させる必要がある
- PubSubエミュレータはPUBSUB_EMULATOR_HOST、PUBSUB_PROJECT_IDの環境変数が必要
- CIでの実行にはjavaが必要
Emulator Suite
https://firebase.google.com/docs/emulator-suite
リンク先の図を見てもらうのが分かりやすいのですが、Firestore, Cloud Functions, Hosting(+Realtime Database)の各種エミュレータが整備され、本物のFirebaseのようにそれぞれが連携して動作するようになりました。
Firestoreのエミュレータは以前から存在していましたが、iOS/Androidからは使うことができませんでした。そのため、実質的にはWeb SDKを使ったSecuriy Ruleのチェックぐらいしか使えませんでしたが、ついにiOS/Androidからもエミュレータを使えるようになりました。
ただ、今回の記事では自分が得意なWeb SDKとNode.js Admin SDKによるjs/tsオンリーで解説をしていきます。
(Summit以前のエミュレータについては段々と記憶が薄れてきているので、もしかしたら若干間違っているかもしれません。Admin SDKがエミュレータと共に使えたかどうかが一番覚えていない・・・)
codelabがオススメ
では早速サンプルコードをお見せしながら解説・・・と考えていたのですが、Firebase公式のエミュレータを体験できるcodelabのできが非常に良かったので、もうこれを紹介した方が良いのでは?🤔 という気になりました。
https://google.dev/playlists/firebase-emulators
コーディングと座学(YouTube)を交互に繰り返す一連のcodelabで構成されていており、コーディングの題材は以下のようになっています。
- Firestore, Hosting, Functionsのエミュレータを協調させることで、実際のFirebaseへのデプロイをせずに開発できる様子を体験
- TDDでSecurity Rulesを直す
- TDDでFunctionsでのFirestoreトリガーを実装
テストコードでの@firebase/testingにおけるお作法や、before, afterのブロックで何を行うべきかがしっかり書かれており、非常に参考になりました。
codelabで使用するリポジトリはGitHubで公開されており、最後の完成形も置かれています。テストに関してのドキュメントはあまり整備されていないこともあり、Firebaseのテストを書くためのサンプルとしても非常に参考になるでしょう。
https://github.com/firebase/emulators-codelab/tree/master/codelab-final-state
実際に試してみた(実践)
codelabの説明も素晴らしいものではありますが、あくまで出来合いのサンプルなので実際に自分でも試してみました。そのためのサンプルとして、レストランのレビューサイトを題材に考えてみます。
要件
- レストランの情報は運営(admin)だけが編集可能
- ユーザーはレストランにレビュー文と、1-5の評価を付けることができる
- レストランには過去のレビュー評価の平均点が表示される
- 平均点によるランキング機能がある
- ランキングは1日1回更新
Firestore設計
これを実現するためのFirestoreの設計は以下のようになりました。
- /restaurants/{restaurantId}
- (レストラン情報はユーザーからはread only)
- rateAvg: number(レビュー平均点数)
- rateNum: number(レビュー数)
- name: string(レストラン名)
- /reviews/{userId}
- (ログイン済みユーザーのみread/write可能、店舗ごとに一人のユーザーがレビュー投稿できるのは1回のみ)
- rate: number(レビュー点数)
- text: string(レビュー本文)
- timestamp: timestamp(投稿時間)
- userId: string(ユーザーid)
- /rankings/{id}
- (ランキングはユーザーからはread only)
- rank: number(順位)
- rateAvg: number(レビュー平均点数)
- restaurantId: string(レストランid)
- restaurantName: string(レストラン名)
Functions設計
以下の要件は、ユーザーからのwriteで自由に書き換えられてしまうとダメなのでSecurity Ruleでガードしつつ、Cloud FunctionsからAdmin SDKを使ってFirestoreを更新することにします。
- レストランには過去のレビュー評価の平均点が表示される
- ユーザーがレビューを投稿したときのFirestoreトリガーで平均点を計算し直す
- ランキングは1日1回更新
- Cloud SchedulerからPubSubを発火させ、PubSubトリガーでランキングを再集計する
設計はこれで完了です。ではそれぞれでエミュレータを使って、テストを書きながら実装をしていきます。
エミュレーターのセットアップ
codelabではfirebase emulators:start
で全てのエミュレータをデフォルトの設定で起動しています。実はfirebase-tools v7.8.0からfirebase init emulators
というコマンドが追加されており、これを実行して対話形式で必要なエミュレータを選択可能です。
Firestore, Functions, Hosting, RealtimeDatabase, PubSubの中からインストールするエミュレータを選択し、それぞれのポートもここでデフォルトから変更が可能です。
設定はfirebase.jsonに書き込まれます。全てデフォルトのままにした場合にはこのようになります。
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"hosting": {
"port": 5000
},
"pubsub": {
"port": 8085
}
}
Firestore
Firestore単体でのテストにおいて最も重要なのはSecurity Ruleを正しく書けているかチェックすることでしょう。
今回の題材では、例えば以下の点はしっかりSecurity Ruleでガードしておかないと悪意あるユーザーによってデータがメチャクチャにされてしまう可能性があります。
- ユーザーがwriteできるのはレビューだけ
- レビュー点数は1-5の範囲
Security Ruleをチェックするためのテストコードの書き方はcodelabを参考にするのが良いでしょう。一応、今回自分が作成したテストコードも載せておきます。
ポイント
projectIdについて
Firestoreのテストコードを書く場合には、@firebase/testingというライブラリを使用します。
そしてテストの実行前に以下のようなコードが必要です。
const projectId = `test-${uuid()}`
firebase.loadFirestoreRules({
projectId,
rules: readFileSync('firestore.rules', 'utf8')
})
重要なのはprojectIdです。実はFirestoreのエミュレータはこのprojectIdごとにデータ領域が別々になっています。逆に言うと、同じprojectIdの場合はデータを共有します。
最近のjs/ts界隈ではjestというテストフレームワークが流行っていますが、jestはデフォルトの設定でマシンのCPUコア数に応じて並列にテストを実行してくれます。
仮にテストコードがtest1, test2と2つのファイルに分割されており、両方とも同じprojectIdを使用したとしましょう。jestによってtest1とtest2のテストが並列に実行された場合、同じFirestoreのデータを同時に更新する可能性があるので、テストが期待通りの動作をしなくなってしまいます。
uuidでも自作の乱数でも何でもいいのですが、それぞれのテストコード毎にprojectIdが被らないようにしておくことでテストを並列に実行しても安全になります。
initializeTestAppとinitializeAdminAppについて
@firebase/testingでテストコードを書く際のfirestoreは、通常とは異なる方法で取得する必要があります。
import * as firebase from '@firebase/testing'
const userFirestore = firebase.initializeTestApp({
projectId,
auth: { uid }
}).firestore()
Security RulesのチェックにはWeb SDKの方を使用するのですが、テストのためのダミーデータを用意するときにはSecurity Rulesの制約を無視できるAdmin SDKを併用する必要があるかもしれません。その場合はinitializeAdminAppも同時に使用します。
import * as firebase from '@firebase/testing'
import { firestore as admin_firestore } from "firebase-admin" // 型情報のためのimport
// Web SDKとAdmin SDKのFirestoreは型レベルでは別物だが、initializeAdminAppはWeb SDKのFirestoreを返すので無理やりキャストする
const adminFirestore = firebase.initializeAdminApp({
projectId
}).firestore() as unknown as admin_firestore.Firestore
FIRESTORE_EMULATOR_HOSTについて
ここまで@firebase/testingを使ったテストコードからエミュレータを使う想定の話がメインでしたが、実はテストに関係ない普通のjsスクリプトからもエミュレータのFirestoreに接続させることが可能です。
Firestoreエミュレータを起動した状態でFIRESTORE_EMULATOR_HOST=localhost:8080
などと環境変数を設定しておくだけでOKです。Firestoreを使うコードの挙動を確認したい場合などの書き捨てのスクリプトを試す場合などに便利です。
Functions
Cloud Functionsには様々なトリガーが用意されていますが、ここではFirestoreのドキュメントが更新されたタイミングでトリガーされるFunctionsのテストを扱います。
今回の要件では、ユーザーのレビュー(/restaurants/{restaurantId}/reviews/{reviewId})がcreateされたタイミングで、レストラン(/restaurants/{restaurantId})のレビュー平均点を再計算させます。
この記事ではFunctionsの書き方や、テストの書き方は本題ではないので省略して自分が作成したサンプルへのリンクだけ貼っておきます。気になる人はぜひ見てみてください。
reviewがcreateされたトリガーでrestaurantのレビュー平均点を更新するfunction
https://github.com/Kesin11/TestingFirebase/blob/v2/functions/src/index.ts#L10-L31
updateRestaurantRateのテストコード
https://github.com/Kesin11/TestingFirebase/blob/v2/__tests__/functions/update_restaurant_rate.ts
ポイント
FunctionsのテストではprojectIdをランダムにしてはいけない
実はcodelabでも注意が書いてあるのですが、Firestoreのテストのときとは反対にFunctionsエミュレータを併用するテストの場合は、projectIdを固定する必要があります。それも、現在firebase use
しているproject idと一致させる必要があります。
// FunctionsのエミュレータのprojectIdは.firebasercで定義されているものが使われる
// 本物のFirebaseプロジェクトのprojectIdと一致させないとFirestoreトリガーのFunctionsがエミュレータで発火されない
const projectId = `testing-firebase-test`
firebase.loadFirestoreRules({
projectId,
rules: readFileSync('firestore.rules', 'utf8')
})
projctIdが異なっていた場合、あるいはfirebase emulators:start
してからfirebase use
でプロジェクトを切り替えた場合はFunctionsのエミュレータがFirestoreのトリガーを実行してくれません。
なぜかFunctionsエミュレータが期待通りに動作しなかった場合、projectIdのtypoを疑ったり、エミュレータの再起動をしてみましょう。(自分はこれに気がつくまで時間を無駄にしました・・・)
Functionsのテストは並列に実行してはいけない
前述したように、FunctionsではprojectIdを実際に使用するFirebaseのproject idと一致させる必要があります。つまり、Functionsエミュレータを使用するテストコードが複数存在する場合でも、全てのprojectIdを同じにする必要があります。
これが前述のFirestoreの項で説明したテストを並列に実行してくれるjestと相性が悪いのです。残念ですが、Functionsのテストを実行する際にはjest —runInBand
のオプションを付けてテストが並列実行されないようにしておきましょう。
PubSub
PubSubのエミュレータはfirebase-tools v7.8.0で登場したばかりであり、執筆時の12/16時点ではまだアンドキュメントな機能です。今のところ参考になる情報としては、PubSubエミュレータをFirebaseに取り込んだpull-reqと、gcloud側のPubSubエミュレータのドキュメントになります。
今回の要件では、レストラン(/restaurants/{restaurantId})のレビュー平均点を集計して1日1回の頻度でランキング(/rankings)を作り直すのですが、この1日1回のスケジュールをCloud SchedulerとPubSubの組み合わせ1で実現させます。
スケジュールで実行する想定の、rankingsを更新するfunction
https://github.com/Kesin11/TestingFirebase/blob/v2/functions/src/index.ts#L39-L72
// functions/src/index.ts
type Ranking = {
rank: number,
rateAvg: number,
restaurantId: string,
restaurantName: string,
}
export const cronRestaurantRanking = functions.pubsub.topic('cron-restaurant-ranking').onPublish(async (msg, ctx) => {
// ランキングの入れ替えは同時に行う必要があるのでbatchを使用
const batch = firestore.batch()
// 前日のランキングを削除
const lastDaySnapshot = await firestore.collection('/rankings').get()
lastDaySnapshot.forEach((doc) => {
batch.delete(doc.ref)
})
// 当日のランキングを作成
let rank = 1
const snapshot = await firestore.collection('/restaurants').orderBy('rateAvg', 'desc').get()
snapshot.forEach((doc) => {
const data = doc.data()
const ranking: Ranking = {
rank: rank,
rateAvg: data.rateAvg,
restaurantId: doc.id,
restaurantName: data.name,
}
batch.set(firestore.collection('rankings').doc(), ranking)
rank += 1
})
// deleteとaddを同時に実行
await batch.commit()
})
このようにPubSubトリガーのfunctionsを実装し、firebase init emulators
でPubSubエミュレータを設定した状態でfirebase emulators:start
を実行します。以下のようなログが表示され、エミュレータからもPubSubトリガーのfunctionsが認識されていることがわかります。
> testingfirebase@1.0.0 emulators:start /Users/kesin/github/TestingFirebase
> firebase emulators:start
i emulators: Starting emulators: functions, firestore, pubsub
✔ functions: Using node@10 from host.
✔ functions: Emulator started at http://localhost:5001
i firestore: Serving ALL traffic (including WebChannel) on http://localhost:8080
⚠ firestore: Support for WebChannel on a separate port (8081) is DEPRECATED and will go away soon. Please use port above instead.
i firestore: Emulator logging to firestore-debug.log
✔ firestore: Emulator started at http://localhost:8080
i firestore: For testing set FIRESTORE_EMULATOR_HOST=localhost:8080
i pubsub: Emulator logging to pubsub-debug.log
✔ pubsub: Emulator started at http://localhost:8085
i functions: Watching "/Users/kesin/repo/TestingFirebase/functions" for Cloud Functions...
> [functions] Start functions
✔ functions[updateRestaurantRate]: firestore function initialized.
✔ functions[pubsubFn]: pubsub function initialized.
✔ functions[cronRestaurantRanking]: pubsub function initialized.
✔ functions[scheduledFunctionCrontab]: pubsub function initialized.
✔ All emulators started, it is now safe to connect.
テストコード側では、functionsでトリガーに設定したtopicに対してpublishし、functionsが正しく実行されたことを検証します。今回は/rankingsに平均レビュー点数が高い順にドキュメントが作成されることを期待するので、そのようなコードを書きます。
cronRestaurantRankingのテストコードから抜粋
https://github.com/Kesin11/TestingFirebase/blob/v2/__tests__/functions/cron_restaurant_ranking.ts#L88-L114
test('trigger', async () => {
// PubSubにトピックを投げてcronで回す想定のfunctionを起動
await pubsub.topic('cron-restaurant-ranking').publishJSON({})
// functions側の処理が完了するまで待つため、
// rankがmaxになったsnapshotを観測するまで待つ
await new Promise((resolve) => {
unsubscribe = rankingModel.collectionRef().where('rank', '==', dummyRestaurants.length).onSnapshot((snap) => {
snap.docChanges().forEach((change) => {
if (change.type === 'added') resolve()
})
})
})
// /rankings のdocをrank順に取得し、比較用に配列に詰め直す
const rankings: typeof expectRankings = []
const snapshot = await rankingModel.getAllOrderByRank()
snapshot.forEach((doc) => {
const data = doc.data()
rankings.push({
rateAvg: data.rateAvg,
restaurantName: data.restaurantName,
rank: data.rank
})
})
// rankの順番通り、かつ中身が期待通りであるかチェック
expect(rankings).toEqual(expectRankings)
})
Firestoreトリガーのテストコードでも同じですが、Functions → テストコード側にFunctionsの実行が終わったことを伝える方法がないため、FirestoreのonSnapshotや、Promiseを駆使したなかなかトリッキーなコードになっています。
ポイント
PUBSUB_EMULATOR_HOSTとPUBSUB_PROJECT_IDの環境変数
前述したように、FirestoreもFIRESTORE_EMULATOR_HOST
という環境変数で指定したエミュレータに接続するという挙動が存在します。ですが、おそらく@firebase/testingを使用したテスト時には自動的に設定してくれるためか、通常は意識する必要がありません。
残念ながらまだPubSubエミュレータまでは面倒を見てくれないのか、必要な環境変数を自分で設定する必要があります。
-
PUBSUB_EMULATOR_HOST
: http://localhost:8085(デフォルトから変更した場合はfirebase.jsonを参照) -
PUBSUB_PROJECT_ID
: Functionsの注意点同様に、実際に使用しているFirebase Projectと同一のidを設定します。
functions.pubsub.scheduleはエミュレータが認識してくれない
現在ではfunctionsのコード内でfunctions.pubsub.schedule()を書くことで、デプロイしたタイミングでFirebaseがCloud SchedulerやPubSubを自動的に設定してくれます。PubSubのエミュレータがこれを正しく解釈して自動的に生成されるtopicに対してのトリガーを受け付けてくれると最高だったのですが、自分が試した限りではエミュレータは認識してくれませんでした。
そのため、今回のサンプルではCloud SchedulerとPubSubを手動で設定する一昔前の手法を採用しています。今後のPubSubエミュレータの改善に期待しましょう。
最終的なpackage.json
ここまで説明したように、現在の状況では各種エミュレータを使用したテストを実行する際にはいくつか注意点があります。これらを考慮し、この後に解説するCI用の設定を追加した各種npm runのためのpackage.jsonはこのようになりました。
"scripts": {
// firestoreとfunctionsのテストを両方とも実行
"test": "npm run test:firestore && npm run test:functions",
// firestoreのテストのみ実行
"test:firestore": "JEST_JUNIT_OUTPUT_NAME=firestore.xml jest __tests__/firestore",
// functionsのテストのみ実行。PubSub用のenvもセット。--runInBandでテストを直列に実行
"test:functions": "JEST_JUNIT_OUTPUT_NAME=functions.xml PUBSUB_EMULATOR_HOST=http://localhost:8085 PUBSUB_PROJECT_ID=testing-firebase-test jest -i __tests__/functions",
// CIでのテスト用。Firebase projectを切り替え、テストの前後でエミュレータの起動・終了
"test:ci": "firebase --project test emulators:exec 'npm run test'",
// TDDで開発するときはnpm run emulators:startしておき、jest --watchでテストを実行しっぱなしにしておく
"test:watch": "PUBSUB_EMULATOR_HOST=http://localhost:8085 PUBSUB_PROJECT_ID=testing-firebase-test jest --watch -i",
"emulators:start": "firebase emulators:start"
}
firebase emulators:exec
は、渡されたコマンドの前後でエミュレータの起動・終了を行ってくれる便利なコマンドです。
ローカルではnpm run emulators:start
でエミュレータを立ち上げっぱなし状態でnpm run test:watch
をしながら開発し、CIではnpm test:ci
でテストの前後でエミュレータを起動・終了させています。
CI(CircleCI)
いよいよ最後にCI環境でエミュレータを使用したテストを行う方法です。今回は使用者が多いと思われるCircleCIでサンプルを用意しました。
必要な設定はだいたいpackage.jsonとjest.config.js側に書いてあるため、非常にシンプルになりました。
version: 2.1
jobs:
test-with-emulators:
docker:
- image: circleci/openjdk:latest-node
working_directory: ~/project
steps:
- checkout
- run:
name: Setup functions
command: |
npm --prefix functions ci
npm --prefix functions run build
- run: npm ci
- run:
name: Test
command: npm run test:ci
- store_test_results:
path: ./junit
workflows:
version: 2
firebase-test:
jobs:
- test-with-emulators
ポイント
エミュレータの実行にはjavaが必要
エミュレータの実行にはjavaの環境が必要なため、nodejs + javaがインストール済みのdockerイメージが必要であり、少々めんどくさいです。
幸いにも、CircleCIでは各種言語 + nodejsのイメージを公式にメンテしてくれているので、このイメージを使うのが一番簡単です。今回はcircleci/openjdk:latest-nodeを使用しましたが、必要であれば異なるjdkのバージョンやdebianのバージョンで作られたイメージを使用すると良いでしょう。
まとめ
簡単なサンプルアプリケーションの実装を通して、Firestore, Functions, そして登場したばかりのPubSubエミュレータの使い方を解説しました。解説に使用したコード全体はこちらのリポジトリを参照してください。
今までFirebaseを使用したプロダクトを開発する場合にはチームでProjectを共有したり、一人ひとりに個別のProjectを作成するなど微妙に開発をしづらい場面があったかと思います。エミュレータが整備されたことで今後はだいぶ開発しやすくなるでしょう。
そして、テストガチ勢としては@firebase/testingのエミュレータとの統合が進み、TDDスタイルで開発できる場面が増えたことは喜ばしい限りです。
しかし、ここまで書いておいてアレですが自分はエミュレータを最終的には信用していません。エミュレータは所詮エミュレータですので、バグもあるでしょうし登場したばかりの最新機能はすぐにはサポートされない可能性もあるでしょう。
過度な期待はせず、ですが効率的に使える場所を見極めてエミュレータによる快適なFirebase開発をしていきましょう。
-
今回は本題ではないため、Cloud SchedulerとPubSubの組み合わせる方法については省略します。 ↩