YSNHatenaBlog

主にアプリやWebサービス開発について

GitHub ActionsでFirestore Emulatorを使った自動テストを行うためにやったこと

いろいろ苦戦したけど何とかできた。

やりたかったこととしては、Firestore EmulatorをGitHub Actions実行時のCIサーバで起動してCloud Functionsとfirestore.rulesのテストを行うこと。 まだrulesのテストはできてないが、雛形はできたのであとは流れでできそう。

ローカルでテストを導入する

実際の差分はこんな感じ。 github.com

ポイントになる部分について追記。

firebase emulators:exec を使う

package.jsonにtestのscriptを追加するが、こんな感じにした。

  "scripts": {
    "test": "firebase emulators:exec 'jest --silent'"
  },

firebase emulators:exec コマンドは便利で、その後のコマンド実行前にFirebase Emulatorのダウンロード、起動、コマンド実行終了時のFirestore Emulatorの停止をやってくれる。

jest --silent にしたのは、テスト実行時にCloud Functionsのログが吐かれてテスト結果が見にくくなるため。

Cloud Functionsのindex.tsを分割

firebase-functions-test でラップするためにCloud Functionsの実装がexportされたファイルをimportする必要があるが、最初全部のCloud Functionが入ったindex.tsをimportしようとして、nextもロードいろいろ設定が足りなくてエラーになってしまう。そこでnextとそれ以外のCloud Functionを分けて、next以外のCloud Functionだけimportするようにした。

src/functions
├── index.ts
├── tsconfig.json
├── __tests__
├── firestore ← next以外(Firestore Trigger)のCloud Function
└── web ←nextのCloud Function

algoliaのモック

ハマったけどこんな感じ。

let mockSaveObject: ReturnType<typeof jest.fn>
jest.mock('algoliasearch', () => {
  mockSaveObject = jest.fn().mockReturnValue({})
  return {
    default: () => ({
      initIndex: () => ({
        saveObject: mockSaveObject,
      }),
    }),
  }
})

GitHub Actionsの導入

実際の差分はこんな感じ。 github.com

敢えて公開レポジトリでやっているが故にいろいろハマった。APIキーなどが入っている設定ファイルはすべてローカル(開発者のマシン上)にしかない状態にし、GitHub ActionsではそのファイルがなくてもFirestore Emulatorを起動させてテストが実行できるようにする必要があった。

結論としては設定ファイルの値がロードできなかった場合は適当な文字列をデフォルトでつけるようにした。

workflow設定

環境は現時点でのCloud Functionsの実行環境に合わせた。 初めてGitHub Actionsを使ったが、Dockerfileを書かなくてもこれでいい感じやってくれるのが嬉しい。

エミュレータを起動するプロジェクトを指定する必要があり、かつ指定するためにFirebaseの権限が必要になるため GCLOUD_PROJECTFIREBASE_TOKEN を「GitHub > Settings > Secrets」から指定した。

name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-18.04
    env:
      FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
      GCLOUD_PROJECT: ${{ secrets.GCLOUD_PROJECT }}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: '10.18.1'
      - uses: actions/setup-java@v1
        with:
          java-version: '15.0.2'
          java-package: jdk
          architecture: x64
      - name: Install dependencies
        run: yarn
      - name: Build
        run: yarn preserve
      - name: Test
        run: yarn test --project ${GCLOUD_PROJECT}

この設定にする前に調べてみたが、GitHub ActionsでFirestore Emulatorを使うのにDocker Composeを使う例が多い。 - docker-compose × Firebase Emulatorでローカル環境構築 - GitHub Actions上でFirestoreエミュレータを立ち上げてテストする - Qiita

やれることが違いそうだけど今回はシンプルにCloud Functionとfirestore.rulesのテストをしたいだけなので、firebase emulators:exec を使って特に複数コンテナは起動せずに実行することにした。設定がより複雑になりそうだったし。

functions.config()のところ

.runtimeconfigがあれば値を渡せるがpushしたくないので、こんな感じでデフォルト値を指定して、エラーにならないようにしておく。 この値を受け取る関数などは、当然本来あるはずの値が渡ってこないので、テスト時はモックする。

functions.config().algolia?.app_id ?? ''

フロントエンドで使うパラメータのところ

いままで config.ts に書くようにしてgitignoreでpushしないようにしていたが、それだとCI側でビルドが通らなくなってしまう。 結論としてはnextで .env が使えるのでそれを読み込むようにし、 .envがなくて設定値が取れないときはデフォルトの値を読むようにした。

nextjs.org

.env場所はsrc/appの下。クライアントサイドでも読み込む可能性のある値は NEXT_PUBLIC_ をprefixに付ける必要がある。

NEXT_PUBLIC_FIREBASE_API_KEY=xxx

最初JSONファイルを作って読み込ませようと思ったが、Dynamic Importもrequireもビルド時にそのファイルが無いとエラーになってしまうことを知った。 また firebase.initializeApp に渡す値は空文字だとエラーになってしまう。

結果

f:id:yosuke403:20210305060628p:plain

まとまった時間がとれず随分時間がかかった。まだrulesのテストが無いので追加したい。

ビルドいらなかった

ここまで書いてふと「別にテストであれば実際にFirestore TriggerのCloud Functionが発火しなくてもいいわけで、firestoreのエミュレータだけ起動すれば、Cloud Functionsのビルドってしなくていいはずだよな...」と気づいてしまった。

github.com

やっぱりこれでよかった。無駄な苦労したかも。

まぁフロントエンドのテストもこれから書いてみたいし、.envは環境ごとに切り替えたりもできそうで便利だから、まぁいいか...。

firebaseの設定ファイルをpushしたくない

テストはfirebaseの設定ファイルいらないはずなので、pushしなくてもGitHub Actionsを回せるのだが、手元の開発環境では設定ファイル参照して、GitHub Actions実行時は設定ファイルを無視あるいはダミーする、みたいなことがいい感じにできないか考え中。

一応他の開発者の参考になればと思い、公開レポジトリにしているのでこの悩み。

github.com

結局ユーザには見える値なのでどうしても隠さなければいけない情報ではないのだけど、レポジトリに含まれてしまうのは気持ち悪いなと。

この辺の環境変数が使えそうだなぁということろまで。

nextjs.org

GitHub ActionsでFirestore Emulatorを使ったCIをやりたい

この辺が参考になりそう。 qiita.com

調べてみるとDocker Composeでやってる例が多そう。

これも参考になりそう。 zenn.dev

import, exportできるのよい。テスト用のデータセット最初に作ってしまうのがよさそう。 importの際にCloud Functionsは発火しないのかな?

algoliasearchをモックする

algoliasearch がmockできない。

i  Running script: jest
 FAIL  src/functions/__tests__/index.test.ts
  ● Test suite failed to run

    TypeError: algoliasearch_1.default is not a function

      2 | import * as functions from 'firebase-functions'
      3 | 
    > 4 | const client = algoliasearch(
        |                             ^
      5 |   functions.config().algolia.app_id,
      6 |   functions.config().algolia.admin_key
      7 | )

      at Object.<anonymous> (src/functions/firestore/index.ts:4:29)
      at Object.<anonymous> (src/functions/__tests__/index.test.ts:13:1)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        6.62 s
Ran all test suites.

algoliaのclientはリクエストとレスポンスを上書きできるらしい。 https://discourse.algolia.com/t/mock-js-client-for-instantsearch-ui-intergration-testing/9751 https://www.algolia.com/doc/api-client/advanced/configure-the-client/javascript/?language=javascript&client=javascript#requester

これならいけそう。 https://stackoverflow.com/questions/47056694/jest-mocking-default-exports-require-vs-import/47058957 でもrequire使っていなはずなのだけど...。

jest.mock('algoliasearch', () => {
  return {
    default: jest.fn()
  }
})

jest.fn().mockReturnValue({})を事前に定義するとモックされない ↓ダメパターン

const mockSaveObject = jest.fn().mockReturnValue({})
jest.mock('algoliasearch', () => ({
  default: () => ({
    initIndex: () => ({
      saveObject: mockSaveObject,
    }),
  }),
}))

でもmockSaveObjectにアクセスできないと、この関数が呼ばれたかの判断ができない。

なにやらmockの初期化関数内でjest.fn()呼ばないとだめそう。 これならOK。

let mockSaveObject: ReturnType<typeof jest.fn>
jest.mock('algoliasearch', () => {
  mockSaveObject = jest.fn().mockReturnValue({})
  return {
    default: () => ({
      initIndex: () => ({
        saveObject: mockSaveObject,
      }),
    }),
  }
})

その他知ったこと

ややこしいリセット系
  • clearAllMocks - モックに格納された値だけリセット。
  • resetAllMocks - 上記に加えて実装もリセット。
  • restoreAllMocks - すべての振る舞いをオリジナルに戻す。ただしspyOnで作ったものだけ。
jestのオブジェクトの比較
toStrictEqual
jest中のconsoleログはsilentオプションで消せる
jest --silent

Cloud Functionsのテスト導入中

CloudFunctionsテスト導入。

このエラーが出る。

% yarn test
yarn run v1.16.0
$ firebase emulators:exec 'jest'
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
✔  functions: Using node@10 from host.
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: dist/public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/yosuke/Work/webapp/tennico/dist/functions" for Cloud Functions...
✔  functions[nextApp]: http function initialized (http://localhost:5001/tennico-f93a4/us-central1/nextApp).
✔  functions[courtCreated]: firestore function initialized.
✔  functions[courtUpdated]: firestore function initialized.
i  Running script: jest
 FAIL  src/functions/__tests__/index.test.ts
  ● Test suite failed to run

    src/functions/__tests__/index.test.ts:2:8 - error TS1259: Module '"/Users/yosuke/Work/webapp/tennico/node_modules/firebase-functions-test/lib/index"' can only be default-imported using the 'esModuleInterop' flag

    2 import functionsTest from 'firebase-functions-test'
             ~~~~~~~~~~~~~

      node_modules/firebase-functions-test/lib/index.d.ts:4:1
        4 export = _default;
          ~~~~~~~~~~~~~~~~~~
        This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        5.213 s

tsconfig見てない?試しに__test__直下にtsconfig置いて、compilerOptionsを親のものをコピペしてみる。

% yarn test
yarn run v1.16.0
$ firebase emulators:exec 'jest'
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
✔  functions: Using node@10 from host.
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: dist/public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/yosuke/Work/webapp/tennico/dist/functions" for Cloud Functions...
✔  functions[nextApp]: http function initialized (http://localhost:5001/tennico-f93a4/us-central1/nextApp).
✔  functions[courtCreated]: firestore function initialized.
✔  functions[courtUpdated]: firestore function initialized.
i  Running script: jest
 FAIL  src/functions/__tests__/index.test.ts
  ● Test suite failed to run

    Could not find a valid build in the '/Users/yosuke/Work/webapp/tennico/src/functions/next' directory! Try building your app with 'next build' before starting the server.

      4 | import * as url from 'url'
      5 | 
    > 6 | const app = next({
        |                 ^
      7 |   dev: false,
      8 |   dir: __dirname,
      9 |   conf: {

      at Server.readBuildId (node_modules/next/next-server/server/next-server.ts:1934:15)
      at new Server (node_modules/next/next-server/server/next-server.ts:192:25)
      at Function.createServer [as default] (node_modules/next/server/next.ts:41:10)
      at Object.<anonymous> (src/functions/index.ts:6:17)
      at Object.<anonymous> (src/functions/__tests__/index.test.ts:6:1)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.77 s
Ran all test suites.
(node:13284) ExperimentalWarning: The fs.promises API is experimental
⚠  Script exited unsuccessfully (code 1)
i  emulators: Shutting down emulators.
i  functions: Stopping Functions Emulator
i  hosting: Stopping Hosting Emulator
i  firestore: Stopping Firestore Emulator
i  hub: Stopping emulator hub

Error: Script "jest" exited with code 1
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

nextが呼ばれてしまうので、nextの設定がいろいろないとテストが実行されなそう。 functionsごとに別ファイルで切り出して、next以外の関数だけimportする。

更にハマる。またこれ。

FAIL  src/functions/__tests__/index.test.ts
  ● Test suite failed to run

    src/functions/__tests__/index.test.ts:2:8 - error TS1259: Module '"/Users/yosuke/Work/webapp/tennico/node_modules/firebase-functions-test/lib/index"' can only be default-imported using the 'esModuleInterop' flag

    2 import functionsTest from 'firebase-functions-test'
             ~~~~~~~~~~~~~

      node_modules/firebase-functions-test/lib/index.d.ts:4:1
        4 export = _default;
          ~~~~~~~~~~~~~~~~~~
        This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.

こうじゃなくて

import functionsTest from 'firebase-functions-test'

これでよかった

import * as functionsTest from 'firebase-functions-test'

前はlintエラーだったのに起きなくなった。

そしてこれ。え?まじか。

Cannot encode [object Object]to a Firestore Value. Local testing does not yet support Firestore geo points.

オブジェクトの構造だけ合わせる。

-          geo: new admin.firestore.GeoPoint(35.635557, 139.786987),
+          geo: {
+            _latitude: 35.635557,
+            _longitude: 139.786987,
+          },

あとはalgoliasearchをモックできれば動きそうな気配。

Firebaseのプロジェクトにテストを導入してみる(Jest入れるまで)

firebaseプロジェクトにテストを導入してみる。

馴染みがあるjestを使う。やり方は2種類。

直接Babelは使ってないのでts-jestで。型チェックも効く。

yarn add -D jest @types/jest ts-jest firebase-functions-test @firebase/rules-unit-testing

適当にテストを書くとLintエラー。import/exportがないモジュールは駄目。

index.test.ts cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.

こんな構成にしつつ、

src/functions
├── __tests__
│   ├── index.test.ts
│   └── tsconfig.json
├── index.ts
└── tsconfig.json

tests下のtsconfigはこうするとLintエラーが直る。

{
    "extends": "../tsconfig.json",
    "compilerOptions": {
      "isolatedModules": false
    },
}

jest.config.js

module.exports = {
  roots: ['<rootDir>/src'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/?(*.)+(spec|test).+(ts|tsx|js)',
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
}

package.jsonのscriptsに追加

+    "test": "jest"

動いた

% yarn test         
yarn run v1.16.0
$ jest
(node:9850) ExperimentalWarning: The fs.promises API is experimental
 PASS  src/functions/__tests__/index.test.ts
  ✓ basic (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.58 s, estimated 6 s
Ran all test suites.
✨  Done in 3.13s.

そのうちpathの指定やりたい。

qiita.com

emulatorの起動入れてみる。 package.json修正

"test": "firebase emulators:exec 'jest'"
% yarn test
yarn run v1.16.0
$ firebase emulators:exec 'jest'
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
✔  functions: Using node@10 from host.
i  firestore: downloading cloud-firestore-emulator-v1.11.11.jar...
Progress: ======================================================> (100% of 64MB
i  firestore: Removing outdated emulator files: cloud-firestore-emulator-v1.8.4.jar
i  firestore: Firestore Emulator logging to firestore-debug.log
i  hosting: Serving hosting files from: dist/public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/yosuke/Work/webapp/tennico/dist/functions" for Cloud Functions...
⚠  It looks like you're trying to access functions.config().algolia but there is no value there. You can learn more about setting up config here: https://firebase.google.com/docs/functions/local-emulator
⚠  TypeError: Cannot read property 'app_id' of undefined
    at Object.<anonymous> (/Users/yosuke/Work/webapp/tennico/dist/functions/index.js:69:66)
    at Module._compile (internal/modules/cjs/loader.js:776:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:787:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Module.require (internal/modules/cjs/loader.js:690:17)
    at require (internal/modules/cjs/helpers.js:25:18)
    at initializeRuntime (/Users/yosuke/Work/webapp/tennico/node_modules/firebase-tools/lib/emulator/functionsEmulatorRuntime.js:676:29)
    at process._tickCallback (internal/process/next_tick.js:68:7)
⚠  We were unable to load your functions code. (see above)

構成変数が読めてないのでこれを実行。

firebase functions:config:get > .runtimeconfig.json

firebase.google.com

動いた。

参考

セキュリティルールのテスト firebase.google.com

Cloud Functionsのテスト firebase.google.com

Firestoreの設定をfirebase.jsonに入れる

そろそろテストを書いてみる。

firestoreのルールもテストしたいので、この際firestoreの設定も含めるようにしてみる。

index取得。

firebase firestore:indexes > firestore.indexes.json

ルールはコピペするしかない? firestore.rulesに書く。現状の内容をコピペ。一人しかwrite権限ナシ。

firebase.json編集

}
     ],
     "predeploy": "npm run build-public"
+  },
+  "firestore": {
+    "rules": "firestore.rules",
+    "indexes": "firestore.indexes.json"
   }
 }