Firebase Cloud Functionsの単体テストでJestとTypeScriptを使うセットアップ等


Cloud Functionsを書く際に、簡単な関数ならテストなしでも書けますが、少し複雑になってくるとテストがあると作るのが楽です。

この記事では、JestとTypeScriptを使ってCloud Functionsのテストを書く準備を行う方法をご紹介します。

公式ドキュメントでは mocha を使っていますが、今回は普段使っているJestを採用しました。
前提として firebase init で functions ありで TypeScript で初期化されているものとします。

まずは必要なnpmパッケージを追加します。

npm install --save-dev firebase-functions-test jest @types/jest ts-jest sinon @types/sinon

jestをTypeScriptで使うためにts-jestを入れています。

% npx ts-jest config:init

こちらを実行することで jest.config.js が自動生成されます。
特に内容を変更する必要はありませんでした。

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

テストはsrcフォルダーに入れる方式にしました。
例えば notifyArticle.ts があったとすると
notifyArticle.test.ts というファイル名でテストを作成します。

上記の手法でやると% firebase deployの際に
テストがデプロイ対象ファイルになってしまって
面倒なのでtestフォルダーに分けたほうが良かったです。
test/notifyArticle.test.ts となるようにしました。

package.json の scripts に "test": "jest" を追加します。

例としてFCMを利用して通知を送るプログラムのテストを作成してみました。

import { FeaturesList } from 'firebase-functions-test/lib/features'
import * as sinon from 'sinon'
import * as admin from 'firebase-admin'

let test: FeaturesList
let myFunctions: any
let adminMessageSend: sinon.SinonStub<[admin.messaging.Message, (boolean | undefined)?]>

beforeEach(() => {
  test = firebaseFunctionsTest({
    databaseURL: "https://[PROJECT_ID].firebaseio.com",
    projectId: "[PROJECT_ID]"
  }, "[サービスアカウントの秘密鍵のPATH]")

  myFunctions = require('../src/index')
});

afterEach(() => {
  test.cleanup()
});

describe('notifyArticle', () => {
  beforeEach(async () => {
    // テストでは実際にFCMは送れないため、sinonを使ってstubを作成しています
    adminMessageSend = sinon.stub(admin.messaging(), "send")
  })

  afterEach(async () => {
    // sinon.stubは重複して行えないため毎回restoreします
    await adminMessageSend.restore()
  })

  it('nominal scenarios', async () => {
    // test.wrapでテスト対象にしたい関数を包みます
    const wrapped = test.wrap(myFunctions.notifyArticle)

    // このように前提になるドキュメントを作成する事ができます
    await admin.firestore().collection("users").doc("0").set({})

    // onCreateに送りつけるドキュメントを生成する事ができます
    const snap = test.firestore.makeDocumentSnapshot({}, 'articles/0123456789');

    // Functionsは複数回呼ばれる可能性があるので2回以上実行します
    await wrapped(snap, { eventId: "EVENT_ID"})
    await wrapped(snap, { eventId: "EVENT_ID"})

    // この関数ではFCMを飛ばそうとしているので
    // sinonを使用してstubが何回呼ばれた・どんな引数で呼ばれたかについて検証するようにしています
    expect(adminMessageSend.callCount).toEqual(1)
    expect(adminMessageSend.getCall(0).args[0]).toEqual({
      notification: {
        title: 'ARTICLE_TITLE', 
        body: 'ARTICLE_BODY'
      },
      token: 'FCM_TOKEN'
    })

    // テストで使用したデータを削除します
    await admin.firestore().collection('users').doc('0').delete()
    await admin.firestore().collection('systemEvents').doc('EVENT_ID').delete()
  })
})

参考記事

関連記事