Komerco事業部エンジニアの岸本(id:sgrksmt)です。
先日Firebase Summit2018が催され、その中でCloud Firestore(以下Firestore)とRealtime Databaseにローカルエミュレータがβ版として追加されたという発表がありました。
Komercoでは、前回投稿した記事の通り、テスト用のfirebaseプロジェクトを立てて、そこにrulesをデプロイし、オンラインテストといった形でrulesをテストしていましたが、
全てローカルエミュレータを用いたrulesのテストに書き換えました。
今回はローカルエミュレータを用いたFirestoreのrulesのテストの話をします。
使うと何が変わるか
ローカルエミュレータを使ったrulesのテストに切り替えることによって、良い点がいくつかでてきます。
- テストを実行するためのfirebaseプロジェクトを別途用意する必要がなくなる
- firebaseプロジェクトにrulesをデプロイしてからテストする必要がなくなる
- オンラインテストと比べてテストの所要時間が短くなる
- 開発環境のバッティングによってテストが壊れることがなくなる
特にテストの時間が短くなることと、開発環境のバッティングによってテストが壊れることがなくなるのは大きいです。
KomercoではPull Requestが出たり、masterにマージされたタイミングでにCIでテストを実行しているのですが、rulesの変更が伴う機能開発や改善があると、
CIで用いるfirebaseプロジェクトは1つなのでCIの走るタイミングによってはrulesが違うものに変わってしまいテストが通らない...なんてこともありました。
ローカルエミュレータを使うことでそれらの問題から解放され、デプロイ前に手元でテストができるようになります。
変更前と後を図で示すとこのようになります。
また、テストの所要時間ですが変更前後でこのように変わりました。(各5回ずつ実行の平均値を取っています。)
テストケースはおよそ60ケースほどです。
テストケースの大きさ、複雑度によって変わりますが、1/3ほどに減らせたのはとても大きい改善となりました。
実際に使ってみる
実際にFirestoreのローカルエミュレータを使ったテストを書くところまで説明していきます。
公式のドキュメントはこちらにもあるのですが、細かく解説していきます。
※なお、この記事の投稿時点ではβの機能となっています。
事前準備
toolsの更新とemulatorのインストール
まず最初に、firebase-toolsのバージョンは6.0.0以上である必要があります。
npm i -g firebase-tools
あるいは、package.json等でバージョン管理している場合は
"devDependencies": {
"firebase-tools": "^6.0.0",
}
のように記述し、firebase-toolsをインストールあるいはアップデートします。
その後、emulatorの準備をします。
firebase --open-sesame emulators
firebase setup:emulators:firestore
firebase --open-sesame emulators
をすると、 firebase login
で再ログインを求められることがあるのでその場合は再ログインします.
CIの場合は基本的に FIREBASE_TOKEN
を使って各種コマンドを実行していると思うので、firebase login
をし直すのは不要かと思います。
ここまで成功したら、firebase serve --only firestore
を実行し、プロセスが起動してlocalhost:8000が立ち上がるのを確認します。
$ firebase serve --only firestore
✔ firestore: started on http://localhost:8080
Dev App Server is now running.
API endpoint: http://localhost:8080
.gitignoreに追加
firebase serve --only firestore
を実行すると、firestore-debug.logが吐き出されるので、不要ならignoreします。
# .gitignore
firestore-debug.log
firebase/testingの追加
package.jsonにfirebase/testingを追加し、インストールをします。
"devDependencies": {
"@firebase/testing": "0.3.0"
}
ここまで問題なければ事前準備は終了です。
firebase/testing
firebase/testingモジュールを使うことで、firestoreの読み書きのテストを書くことができるようになります。
テストを書くのに使用するインターフェースを紐解いていきます。
projectIDについて
firebase/testingを使ってテスト用のFirebaseのAppを作成します。
その時にprojectIDを指定して作成するのですが、このIDはテストケース毎に一意である必要があります。
ドキュメントには
The Cloud Firestore emulator persists data. This might impact your results. To run tests independently, assign a different project ID for each, independent test. When you call firebase.initializeAdminApp or firebase.initializeTestApp, append a unique ID, timestamp, or random integer to the projectID. You can also resolve race conditions by waiting for promises to resolve through async or await keywords in JavaScript.
と書かれており、エミュレータ起動中は作成したデータを保持しているので、異なるテストケースで同じIDを使ってFirebase Appを立ててアクセスしてしまうと、他のテストケースで作成したデータが混じっていてテスト結果に影響を及ぼす可能性があります。
なので、projectIDを作成するときはタイムスタンプや乱数を含めて一意にすると良さそうです。
公式のサンプルでは次のようにprojectIDを作っています。
import * as firebase from '@firebase/testing';
const projectIdBase = 'firestore-emulator-example-' + Date.now();
let testNumber = 0;
function getProjectId() {
return `${projectIdBase}-${testNumber}`;
}
class TestingBase {
async before() {
testNumber++;
await firebase.loadFirestoreRules({
projectId: getProjectId(),
rules: rules,
});
}
}
テストケース毎に、
firestore-emulator-example-[テスト開始時のタイムスタンプ]-[テスト番号]
という文字列でprojectIDを生成しています。testNumber
はbefore(Each)が実行されるときにインクリメントしています。
このように一意となるprojectIDを生成すれば、テスト時に問題になることはないでしょう。
rulesファイルをロードする
エミュレータにprojectIDを指定してrulesを読み込ませます。
先のコードの例にもありましたが、このようにしてrulesの内容を文字列として渡してあげます。
import * as firebase from '@firebase/testing'
const rules = fs.readFileSync('firestore.rules', 'utf8')
await firebase.loadFirestoreRules({
projectId: getProjectId(),
rules: rules
})
今後はこのprojectIDと一致するFirebaseのAppを作成することで、そのAppはここでロードしたrulesを見るようになります。(後述)
ProjectID、authを指定してFirebaseのAppを作成する
テストで用いるFirebase Appを作成する場合は、initializeTestApp
を使います
const db = firebase.initializeTestApp({
projectId: getProjectId(),
auth: { uid: 'user' }
}).firestore()
引数にはprojectIDと、auth情報を渡します。
注目すべき点は、認証情報を変えた複数のFirebase App(Firestore)を用意できる点です。
今までのrulesのテストの場合、認証ユーザーを切り替えながらデータを作成するときは次のように一々signInとsignOutを繰り返していました。
const userA = await auth.signInAnnonymously()
firestore.collection('post').doc().set({ title: 'test1', body: 'xxxx', author: userA.user.uid })
await auth.signOut()
const userB = await auth.signInAnnonymously()
firestore.collection('post').doc().set({ title: 'test2', body: 'xxxx', author: userB.user.uid })
await auth.signOut()
firebase/testingを使う場合はユーザーごとにfirestoreを分けることができ、
const userADB = firebase.initializeTestApp({
projectId: getProjectId(),
auth: { uid: 'userA' }
}).firestore()
const userBDB = firebase.initializeTestApp({
projectId: getProjectId(),
auth: { uid: 'userB' }
}).firestore()
userADB.collection('post').doc().set({ title: 'test1', body: 'xxxx', auhtor: 'userA' })
userBDB.collection('post').doc().set({ title: 'test2', body: 'xxxx', author: 'userB' })
このように書くことができます。
signInしたりsignOutしたりがなくなるので見通しがよくなりますし、「userAで作成したドキュメントのpathを基に、userBでは書き込みができない」、といったテストを以前より簡単に書くことができるようになります。
また、認証が通っていないユーザーとして振る舞いたい場合は、auth
パラメータにnull
を渡します。
const unAuthedDB = firebase.initializeTestApp({
projectId: getProjectId(),
auth: null
}).firestore()
これで認証の通っていないユーザーに対するテストもかけるようになります。
admin権限でFirebaseのAppを作成する
テストを書くときに、事前にFirestoreにテストデータを入れたい場合があります。このときに、rulesの影響を受けずにデータを作成したり、作成したデータを読み出したい場合には、admin権限でFirebaseのAppを作って利用します。
const adminDB = firebase.initializeAdminApp({
projectId: this.getProjectID()
}).firestore()
後始末
各テストケース実行後は、そのテストケース内で作成したFirebase Appを削除する必要があります。
await Promise.all(firebase.apps().map(app => app.delete()))
これらの処理をclassにまとめてモジュール化
rulesファイルのロードから、各テスト実行ごとに一意のProjectIDを作成してAppを作ってfirestoreを利用する処理を、classとしてまとめてimportできるようにすると便利になります。
KomercoではFirestoreTestProvider
というクラスを作り、各テストファイルでimportしています。
import * as firebase from '@firebase/testing'
import * as fs from 'fs'
export default class FirestoreTestProvider {
private testNumber: number = 0
private projectName: string
private rules: string
constructor(projectName: string, rulesFilePath: string = 'firestore.rules') {
this.projectName = projectName + '-' + Date.now()
this.rules = fs.readFileSync(rulesFilePath, 'utf8')
}
increment() {
this.testNumber++
}
private getProjectID() {
return `${this.projectName}-${this.testNumber}`
}
async loadRules() {
return firebase.loadFirestoreRules({
projectId: this.getProjectID(),
rules: this.rules
})
}
getFirestoreWithAuth(auth?: { [key in 'uid' | 'email']?: string }) {
return firebase.initializeTestApp({
projectId: this.getProjectID(),
auth: auth
}).firestore()
}
getAdminFirestore() {
return firebase.initializeAdminApp({ projectId: this.getProjectID() }).firestore()
}
async cleanup() {
return Promise.all(firebase.apps().map(app => app.delete()))
}
}
エミュレートしているfirestoreへの書き込みの成功失敗を評価する
書き込みが成功する/失敗するのを評価する場合は、assertSucceeds
、assertFails
を使います。
const db = firebase.initializeTestApp({
projectId: getProjectId(),
auth: { uid: 'user' }
}).firestore()
await firebase.assertSucceeds(db.collection('users').doc('user').get())
ちなみに、assertSucceeds
、assertFails
の実装はこちらで確認ができます。
Succeedsは単純にpromiseを返し、failsの場合はPromiseが成功したらエラーを返し、失敗したらエラーを値として返すようにしています。(成功と失敗を反転させている)
これは使っても使わなくても問題ありません。(jestの場合はexpectで評価することもできますし。)
テストを書く
先程のFirestoreTestProvider
と、テストフレームワークであるjestを使ってテストを実際に書いてみます。
rulesとテストケースはCloud Firestore emulator quickstartと同じもので組んでみました。
import * as ftest from '@firebase/testing'
import FirestoreTestProvider from './firestoreTestProvider'
const testName = 'firestore-emulator-example'
const provider = new FirestoreTestProvider(testName)
function getUsersRef(db: firebase.firestore.Firestore) {
return db.collection('users')
}
describe(testName, () => {
beforeEach(async () => {
provider.increment()
await provider.loadRules()
})
afterEach(async () => {
await provider.cleanup()
})
describe('users collection test', () => {
test('require users to log in before creating a profile', async () => {
const db = provider.getFirestoreWithAuth(null)
const profile = getUsersRef(db).doc('alice')
await ftest.assertFails(profile.set({ birthday: 'January 1' }))
})
test('should let anyone create their own profile', async () => {
const db = provider.getFirestoreWithAuth({ uid: 'alice' })
const profile = getUsersRef(db).doc('alice')
await ftest.assertSucceeds(profile.set({ birthday: 'January 1' }))
})
test('should let anyone read any profile', async () => {
const db = provider.getFirestoreWithAuth(null)
const profile = getUsersRef(db).doc('alice')
await ftest.assertSucceeds(profile.get())
})
})
})
これで、 firebase serve --only firestore
を実行してエミュレータを起動した上で、jestを実行するとテストが動きます。
サンプルはこちらにあります。
最後に
ローカルエミュレータは投稿時点ではβ版ということですが、とても強力かつ手軽に扱えるのでこれからテスト書こうかなとか、以前僕が書いた記事のようにテスト書いてる場合は是非検討してみてはいかがでしょうか。
ローカルエミュレータ以外にも様々な発表があったので、キャッチアップしていきながら導入や実践できそうなものがあればどんどんKomercoで試していこうと思います。
参考