Vue.js + Firebase のプロジェクトにE2Eテストを導入したときの記録

# 概要

Firebase のE2Eテストを作成するにあたって詰まったところがあったので備忘的にまとめる。

1. Firebase のemulatorを使う
1. emulatorのデータ初期化
1. Firebase Authentication の認証通過

※下記のLT発表に組み込めなかった内容
<script async class="speakerdeck-embed" data-id="32192305784b4e66a708771cbf759fea" data-ratio="1.77725118483412" src="//speakerdeck.com/assets/embed.js"></script>

# 前提

## 動作環境

```json:package.json
{
  "dependencies": {
    "firebase": "^8.10.0",
    "firebaseui": "^5.0.0",
    "vue": "^2.6.10"
  },
  "devDependencies": {
    "@cypress/webpack-preprocessor": "^5.11.1",
    "@vue/cli-plugin-babel": "^3.11.0",
    "@vue/cli-plugin-e2e-cypress": "^4.5.15",
    "@vue/cli-service": "^3.11.0",
    "firebase-tools": "^9.23.3"
  },
}
```

# その1. Firebase のemulatorを使う

テストで使うには本番の Firebase だと取り回しづらいので、まずは [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite) を使えるようにする。

## 設定

`npx firebase init emulators`で自動生成されるデフォルト値から特に変更していない。

```json:firebase.json
{
  "emulators": {
    "auth": {
      "port": 9099
    },
    "firestore": {
      "port": 3000
    },
    "ui": {
      "enabled": true
    }
  }
}
```

## emulator関連のコマンド

emulatorの起動、初期データのインポート／エクスポートは以下のコマンドで実施

```bash
# Firebase のemulatorを起動
npx firebase emulators:start --only firestore,auth
## 起動時にデータをインポートする場合は --import を指定
npx firebase emulators:start --only firestore,auth --import=./emulator-data

# （emulator起動中に実行）データをエクスポートする
npx firebase emulators:export ./emulator-data
```

## Vueアプリケーションからemulatorに接続する

emulatorを使わない場合だと以下のような形でFirebaseに接続することになる。

```js
const firebaseApp = firebase.initializeApp(config)
const firestore = firebase.firestore(firebaseApp)
const auth = firebase.auth()
```

Firestore や Authentication のemulatorを使うには`useEmulator`を呼び出す必要がある。
接続先を本番／emulatorにするかは環境変数に依存させ、ビルド時に指定できるようにした。

まずは、エミュレートモードで使用する`.env`ファイルを用意しておく。
※エミュレートモードは勝手な命名で、`production`のような公式で定義されているモードではないので注意

```text:.env.emulate
VUE_APP_EMULATE=true
```

続いて`useEmulator`を呼び出す。
Cypress の場合、 Firestore との通信がうまくいかず、データが読み込めないので追加の設定が必要。

```js:firebase.js
const firebaseApp = firebase.initializeApp(config)
const firestore = firebase.firestore(firebaseApp)
const auth = firebase.auth()

// 環境変数によって接続先を切り替える
const useEmulate = process.env.VUE_APP_EMULATE
if (useEmulate) {
  // Note: Cypress でテストするときの設定。useEmulator より前に設定が必要
  //       https://zenn.dev/cauchye/articles/20210816_yutaro-elk
  //       https://github.com/cypress-io/cypress/issues/2374#issuecomment-1012928429
  firestore.settings({ experimentalForceLongPolling: true })

  firestore.useEmulator('localhost', 3000)
  auth.useEmulator('http://localhost:9099')
}
```

コレで準備が整ったのでエミュレートモードで起動すれば、Vueアプリケーションからemulatorに接続できる。

```bash
npx vue-cli-service serve --mode emulate
```

# その2. emulatorのデータ初期化

E2Eテストのケースにはもちろん登録／更新系の内容が存在するため、できる限りクリーンなデータで毎回ケースを流したい。しかしemulatorのリファレンスなどを確認した限り、**emulator起動時以外にデータをインポートできない**ことがわかった。
いずれ公式でいい具合に機能追加されることを願いつつ、現時点ではワークアラウンドとして、自前で毎回emulatorを起動する仕組みを導入することで対応した。

```bash
#!/bin/bash
set -x

exit_code=0

file_paths=$(find cypress/specs -name '*.spec.js')
while read -r path
do
  SCREEN_SHOT_PATH=$(basename ${path}) firebase emulators:exec --only firestore,auth --import=./emulator-data \
      "vue-cli-service --mode emulate test:e2e --headless --spec ${path}"
  if [ $? -ne 0 ]; then
    exit_code=1
  fi
done << END
  ${file_paths}
END

exit ${exit_code}

```

上記のスクリプトを実行すると、1ファイルごとにemulator起動→テスト実行を繰り返す。
Cypressはエラー時にスクリーンショットを撮ってくれるが、実行ごとにスクリーンショットを削除するため、デフォルトの設定だと一番最後に実行したテストファイルのスクリーンショットしか残らない。その対応として多少強引ではあるが、**テストファイルごとに保存ディレクトリを分ける**ようにした。
また同様の問題としてデフォルト設定だと、テストレポートがテストファイルごとに分割されて標準出力に出力されてしまう。Cypressのドキュメントを確認した限りでは対応できそうに思えるが、現時点であまり困っていないので対応していない。


# その3. Firebase Authentication の認証通過

続いて、Firebase Authentication の認証を通過する方法について。
今回のE2Eテスト対象はGitHub認証のみを採用しており、真正面からテストを組むなら個人のGitHubアカウントで認証が必要となる。それはセキュリティ的にNGだろうと思って別の方法を考えた。

## ①テスト時のみパスワード認証を許可する

プロダクションコードにテストのための設定が多くなりすぎるので避けたいが、一番簡単に実現できそうなので検討してみた。
結果としては、そもそも**emulatorではGitHubの認証画面に飛ばされない**（以下のようなemulatorの画面に飛ばされる）ことに気づいたので、認証方法を変える意味はあまりなかった。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675806/7ac62a3d-bd80-c23e-3187-241ff90698a6.png" width="500">

## ②Cypressでemulatorの画面を操作する

上記画像のような画面が出るのであれば、Cypressで画面操作すればよいのでは？と思って試してみたが、うまく行かなかった。
CypressはOAuth目的であれ、サードパーティのサイトへのアクセスを推奨していないことが原因。今回のケースだと、Vueアプリケーションが`localhost:8080`で上記画像の画面が`localhost:9099`と食い違っていたのでダメだった。

https://docs.cypress.io/guides/references/best-practices#When-logging-in


## ③Cypress上でFirebase Authentication の認証を通過する

UI操作で認証を通過することができなさそうなので、以下のサイトを参考に、Cypressのスクリプト上でリクエストを送信し認証を通過させる。

https://zenn.dev/ucwork/articles/21b7caaeb1cc61

大まかな流れは参考サイトのやり方と同じで問題ないが、Firebaseの初期化については今回のアプリケーション固有の対応が必要だった。
> 1. .envの環境変数をCypressでも読めるようにする
> 1. Cypressでfirebaseの初期化
> 1. テスト作成

具体的には、Firebaseの初期化に関する設定値の管理方法が異なる。
参考サイトでは固定値で持っているが、今回のアプリケーションではFirebaseプロジェクトの切り替えに対応するため、以下のようにしてwebpackビルド時に動的に参照するようにしていた。

```js:firebase-config-loader.js
const firebaseTools = require('firebase-tools')

module.exports = function FirebaseConfigLoader () {
  const callback = this.async()
  firebaseTools.apps.sdkconfig('web', '', {}).then(config => {
    callback(null, JSON.stringify(config))
  }).catch(err => callback(err))
}
```

もちろんプロダクションコードの内容をCypress側にもコピペすれば動くかもしれないが、ライブラリ関連の処理を複数箇所に散らばった状態が嫌だったので、Cypressからプロダクションコードのソースを参照させるようにした。
以下のような修正を加えることで、プロダクションコードのwebpackビルドを実行しCypressからプロダクションコードを参照できるようになる。

```diff:cypress/plugins/index.js
- //const webpack = require('@cypress/webpack-preprocessor')
+ const webpack = require('@cypress/webpack-preprocessor')

  module.exports = (on, config) => {
+   on('file:preprocessor', webpack({
+     webpackOptions: require('@vue/cli-service/webpack.config'),
+     watchOptions: {}
+   }))
  
    return Object.assign({}, config, {
      fixturesFolder: 'cypress/fixtures',
      integrationFolder: 'cypress/specs',
      screenshotsFolder: 'cypress/screenshots/${process.env.SCREEN_SHOT_PATH}',
      videosFolder: 'cypress/videos',
      supportFile: 'cypress/support/index.js'
    })
  }
```

またメインの認証処理については参考サイトとあまり変わらないので、コードを記載するに留める。

```js:cypress/support/firebase.js
import { auth } from '@/firebase'

export const signIn = (email, password) => {
  return auth.signInWithEmailAndPassword(email, password)
}
```

```js:cypress/specs/sample.spec.js
describe('test', () => {
  beforeEach(() => {
    cy.wrap(signIn('email', 'pass'))
    cy.visit('/')
  })

  it('case', () => {
    // test
  })
})
```

# まとめ

当初は「emulatorあるし、認証も突破できそうだし簡単そう」くらいの気持ちでいたので、意外とやることが多かったのでビックリ。とはいえ暗中模索ですすめることにはならなかったので、ブログ記事などの情報提供者の方々には感謝！！