Cloud Firestoreのrulesのテストを全てローカルエミュレータを使うように書き換えた話

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が違うものに変わってしまいテストが通らない...なんてこともありました。
ローカルエミュレータを使うことでそれらの問題から解放され、デプロイ前に手元でテストができるようになります。
変更前と後を図で示すとこのようになります。

  • Before

f:id:sgrksmt:20181105134322j:plain

  • After

f:id:sgrksmt:20181105134349j:plain

また、テストの所要時間ですが変更前後でこのように変わりました。(各5回ずつ実行の平均値を取っています。)
テストケースはおよそ60ケースほどです。

Before After
約50秒 約13秒

テストケースの大きさ、複雑度によって変わりますが、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() {
    // Create new project ID for each test.
    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を繰り返していました。

// userAとして認証してモデルを作成
const userA = await auth.signInAnnonymously()
firestore.collection('post').doc().set({ title: 'test1', body: 'xxxx', author: userA.user.uid })
await auth.signOut()

// userBとして認証してモデルを作成
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()

// 認証したuserAで書き込みをする
userADB.collection('post').doc().set({ title: 'test1', body: 'xxxx', auhtor: 'userA' })
// 認証したuserBで書き込みをする
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への書き込みの成功失敗を評価する

書き込みが成功する/失敗するのを評価する場合は、assertSucceedsassertFailsを使います。

const db = firebase.initializeTestApp({
  projectId: getProjectId(),
  auth: { uid: 'user' }
}).firestore()
await firebase.assertSucceeds(db.collection('users').doc('user').get())

ちなみに、assertSucceedsassertFailsの実装はこちらで確認ができます。
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で試していこうと思います。

参考

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/