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

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

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

[公式ドキュメント](https://firebase.google.com/docs/functions/unit-testing?hl=ja)では mocha を使っていますが、今回は普段使っているJestを採用しました。
前提として firebase init で functions ありで TypeScript で初期化されているものとします。

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

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

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

```zsh
% npx ts-jest config:init
```

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

```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を利用して通知を送るプログラムのテストを作成してみました。

```typescript
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()
  })
})
```

## 参考記事

- [Cloud Firestoreの勘所 パート4 — 単体テスト. Cloud FunctionsのFirestoreトリガーの自動テスト🤖 | by mono  | google-cloud-jp | Medium](https://medium.com/google-cloud-jp/firestore4-28f1fb44a1e8)

## 関連記事

- [Cloud Functions用Cloud FirestoreトリガーのeventIdを使ったべき等関数の一部ケースに関する検証](https://qiita.com/yoneapp/items/ebdcf251b5eb631aba6a)
- [Cloud FirestoreでUniqueを実現する2手法とその使い分け](https://qiita.com/yoneapp/items/10ece76fed7e8af6c871)