TypeScript
Firebase
cloudfunctions
Firestore

firebase-functions-test を使って Firestore の offline テストを書いてみる

The Firebase Blog: Launching Cloud Functions for Firebase v1.0 で、 Easier unit testing という項目がありました。
firebase/firebase-functions-test という npm が公開されたので、これは実際のところ何ができるのか調べてみました。(Firestore 目線での調査です。)

手探りでやっているため、間違いがあったらマサカリ歓迎です。

firebase-functions-test/firestore.ts

firebase-functions-test/firestore.ts の interface はこれだけです。
単に DocumentSnapshot を作るだけの Utility ですね。

import { Change } from 'firebase-functions';
import { firestore, app } from 'firebase-admin';
export interface DocumentSnapshotOptions {
    readTime?: string;
    createTime?: string;
    updateTime?: string;
    firebaseApp?: app.App;
}
export declare function makeDocumentSnapshot(
    data: {
        [key: string]: any;
    }, 
    refPath: string, options?: DocumentSnapshotOptions
): any;
export declare function exampleDocumentSnapshot(): firestore.DocumentSnapshot;
export declare function exampleDocumentSnapshotChange(): Change<firestore.DocumentSnapshot>;

内部コードを読んでみましたが、他に利用できるコードはなさそうです。
offline テストができると blog には書いてありましたが、 offline で動作するデータベースが提供されたのではなく、テストヘルパーが提供されたという認識が正しいです。

Realtime Database のテストサンプルを Firestore で書き直す

functions-samples/test.offline.js という、 RealtimeDB の offline テストのサンプルがあります。
しかし Firestore のサンプルはないので、 RealtimeDB のサンプルを Firebase に書き換えてみます。

ここでは、 jest + TypeScript でテストを書いています。

テスト対象の Function

export const addMessage = functions.https.onRequest((req, res) => {
  const original = req.query.text
  return admin.firestore().collection('messages').add({ original: original }).then((writeResult) => {
    return res.json({ result: `Message with ID: ${writeResult.id} added.` })
  })
})

テストコード

import * as admin from 'firebase-admin'
import * as fft from 'firebase-functions-test'
const ft = fft()
import * as sinon from 'sinon'
import { makeUppercase } from './makeUppercase'

describe('offline', () => {
  describe('addMessage', () => {
    it('should return a 303 redirect', async () => {
      const collection = 'messages'
      const addParam = { original: 'input' }
      const firestoreStub = sinon.stub()
      const refStub = sinon.stub()
      const addStub = sinon.stub()

      sinon.stub(admin, 'firestore').get(() => firestoreStub)
      firestoreStub.returns({ collection: refStub })
      refStub.withArgs(collection).returns({ add: addStub })
      addStub.withArgs(addParam).returns(Promise.resolve({ ref: 'new_ref' }))

      const req = { query: { text: 'input' } } as any
      const res = {
        redirect: (code, url) => {
          expect(code).toBe(303)
          expect(url).toBe('new_ref')
        }
      } as any

      await addMessage(req, res)
    })
  })
})

Firestore で書き直してみた感想としては以下で、 私は firebase-functions-test を使って本番の Project の offline テストを書くのは諦めました。

  • admin.firestore() を stub して、 collection も stub して... という感じで、結局全てを stub していかなければならない
  • addMessage は簡単な Function なので良いが、もっと複雑なものは大量の stub が必要
    • Functions 内で複数の Document を触ったり更新する場合は、それを全て stub しなければならない
    • transaction, batch などを stub し続けていくのは現実的ではないし、結局内部コードのほとんどが stub になるのでテストしていると言えるか微妙
  • これなら functions-samples/test.online.js のように実際に online でデータを書き込む方法のが実用的
  • (functions-samples/test.offline.js では sinon.stub(admin, 'initializeApp') しているが、今回はしなくても動いた)

おわり

offline でテストを書くなら、現時点では soumak77/firebase-mock が良い気がします。これは offline で動作する Firestore Database を提供してくれます、一部バグがありますが。(Firestore だけでなく、 RealtimeDB などもローカルで動かせます。)
soumak77/firebase-mock についてはまた別で記事を書こうと思います。

makeUppercase のテストも gist に置いておきます。

最初にも書きましたが、他に参考になるテストコードサンプルなどがなく気合いで書いたので、間違っていたらコメントいただけると助かります。