Firebase Emulatorを使ってCypress

# 初めに
Javascript(Typescript)を使ったFirebaseのアプリでe2eテストをやるときは、Cypressを使うのが定番だと思いますが、Cypressがバージョンアップして色々できそうだったので、改めてどうやって設定するかをまとめてみます。
ちなみに、CIで使いたいので実際のFirebaseプロジェクトではなく、Emulatorを使った繋ぎ込みを想定しています。

# とりあえず対象のアプリを作成

実案件でいろんなフレームワークを使っていますが、今回は新しめのNuxt3で作ってみたいと思います。
シンプルなチャットアプリでやってみます。

機能や実装は以下の通り。

- Firebase Authenticationでログイン/ログアウトができる
- Firestoreでメッセージを送信できて、メッセージが時系列で見える

だいぶシンプルですね。

## アプリの作成とFirebaseの設定

Cypressに繋ぎ込むところが本質なのでこの辺はサクッと終わらせます。

とは言っても前提条件になる部分は揃えたいので、２点だけ具体的なところを書いていきます。

Nuxt3なので、まずは設定ファイルです。

```typescript:nuxt.config.ts
const envSet = require("./env.ts");
export default defineNuxtConfig({
  ssr: false,
  typescript: {
    strict: true,
  },
  runtimeConfig: {
    public: {
      ...envSet,
    },
  },
  modules: ["@nuxtjs/tailwindcss", "@vueuse/nuxt", "nuxt-icon"],
});
```

`.env.ts`は、Firebaseのconfigを入れるところで後に出てくる初期化の設定を入れています。
tailwindcssとかnuxt-iconは見た目を良くしたくて入れているだけなので気にしないでください！

次にFirebaseの初期化です。
Nuxt3でFirebaseを読み込む方法はいくつかあると思いますが、今回はPluginで実行する方法にしました。
他には、全てのcomponentのベースになる`app.vue`で初期化する方法もあると思います。

```diff_typescript:plugins/firebase.client.ts
import { initializeApp } from "firebase/app";

export default defineNuxtPlugin((nuxtApp) => {
  const config = nuxtApp.$config;
  initializeApp({
    apiKey: config.firebaseApiKey,
    authDomain: config.firebaseAuthDomain,
    databaseURL: config.firebaseDatabaseURL,
    projectId: config.firebaseProjectId,
    storageBucket: config.firebaseStorageBucket,
    appId: config.firebaseAppId,
  });
});
```

これでFirebaseプロジェクトに繋げることができるのですが、最初に説明した通りEmulatorに繋げたいので、Emluatorに繋げるための設定を追加します。

今回は、そこだけに集中したいので直接書きますが、実際のプロジェクトでは、環境変数などでテストの時だけEmulatorに繋ぐように切り替えれるようにしましょう。

```diff_typescript:plugins/firebase.client.ts
import { initializeApp } from "firebase/app";
+import { connectAuthEmulator, getAuth } from "firebase/auth";
+import {
+ connectFirestoreEmulator,
+ initializeFirestore,
+} from "firebase/firestore";

export default defineNuxtPlugin((nuxtApp) => {
  const config = nuxtApp.$config;
　　　　/// ここのprojectIdはEmulatorのprojectIdと一致している必要があります。
  initializeApp({
    apiKey: config.firebaseApiKey,
    authDomain: config.firebaseAuthDomain,
    databaseURL: config.firebaseDatabaseURL,
    projectId: config.firebaseProjectId,
    storageBucket: config.firebaseStorageBucket,
    appId: config.firebaseAppId,
  });
+ /// この下の部分を環境変数で無視できるようにすると切り替えが可能。
+ const db = initializeFirestore(getApp(), {
+   experimentalForceLongPolling: true,
+ });
+ connectFirestoreEmulator(db, "localhost", 8080);
+ const auth = getAuth();
+ connectAuthEmulator(auth, "http://localhost:9099");
});
```

こんな感じです。
ここで先に種明かしをするのですが、`experimentalForceLongPolling`が重要な設定となってきます。
これは、強制的にFirestoreを1リクエスト１レスポンスにする設定で、Cypressでアプリケーションを動かす時、通常Firestoreで利用されるWebChannelの動きはうまく動きません。特にリアルタイム同期の部分が動かないので一見動くように見えるので、はまりがちなところです！
絶対に忘れないようにしてください！

# Cypressをインストール

準備ができたところで、早速Cypressをインストールしていきます。
まずは普通にnpmを入れます。

```sh
npm install -D cypress
#  もしくは
yarn add -D cypress
```

次に色々設定を自動でしてくれるので、早速cypressを起動します。


```sh
npx cypress open
```

"Welcome to Cypress!"と表示されると思うので、そのWindowにある"E2E Testing"をクリックします。
次は、"Configuration Files"と表示されるので、そのまま"Continue"を押します。
すると、以下の４つファイルが追加されると思います。

```
cypress.config.js
cypress/fixtures/example.json
cypress/support/commands.js
cypress/support/e2e.js
```

その後、ブラウザーを選んで"Start E2E Testing in Chrome"でさらにもう一つ選んだブラウザが開かれます。
次に"Create new empty spec"を押して、ベースのテストを作ってもらいます。
ファイル名(ここでは"message-submit.cy.js"としました)を決めて進めるととりあえずのテストが作成され、一旦実行されます。

ここで一旦全部Closeします。

jsのまま進めることも悪くないですが、コードもTypescriptで書いているので、Typescriptにしておきましょう。

```
cypress.config.js -> cypress.config.ts
cypress/fixtures/example.json -> 削除
cypress/support/commands.js -> commands.ts
cypress/support/e2e.js -> e2e.ts
cypress/e2e/message-submit.cy.js -> message-submit.cy.ts
```

example.jsonは使わないので消しておきます。
Nuxt3でTypescriptが入ってると思いますが、Cypressとして動かせるよう、npmで追加します。

```sh
npm install -D typescript
#  もしくは
yarn add -D typescript
```

typescriptでcypressが読み込めるように以下のtsconfigファイルをcypressフォルダ配下に追加します。

```tsconfig.json
{
    "compilerOptions": {
      "target": "es5",
      "lib": ["es5", "dom"],
      "types": ["cypress", "node"]
    },
    "include": ["**/*.ts"]
  }
```

cypress.config.tsがそのままだとtypeエラーが出ていると思うので、importの方法だけ直します。

```diff_typescript:cypress.config.ts
- const { defineConcifg } = require("cypress");
+ import { defineConfig } from "cypress";
```

# テストを追加していく

では、実際にEmulatorを立ち上げ、Nuxtをdevモードで立ち上げてCypressで起動させてみましょう。
まずは、アクセスするベースURLを追加しておきます。

```diff_typescript:cypress.config.ts
  e2e: {
+   baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
```

先ほど作ったTestのファイルのvisitの部分を書き換えます。

```diff_typescript:message-submit.cy.ts
describe('empty spec', () => {
  it('passes', () => {
-   cy.visit('https://example.cypress.io')
+   cy.visit('/')
  })
})
```

変更すると、まだログインできてないので、ログイン画面に移動した状態で終わると思います。
このままだと、そもそもログインユーザーがない状態になるので、事前にユーザー登録できるような仕組みを作ります。

cypress-firebaseというnpmを使えば色々とできるのですが、設定がそこそこ難しいのでここでは自力で作っていきます。

まずは、事前に作っていくために、管理者権限でFirebaseにアクセスできるfirebase-adminを追加します。

```sh
npm install -D firebase-admin
#  もしくは
yarn add -D firebase-admin
```

Cypressでコマンドを追加するには、その名の通りのcommandとtaskがあるんですが、
commandはCypressのテストコードを関数化するために使うものなので、ここではTaskに追加していきます。

```diff_typescript:cypress.config.ts
import { defineConfig } from "cypress";
+import { initializeApp } from "firebase-admin/app";

module.exports = defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
    setupNodeEvents(on, config) {
+     process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";
+     process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099";
+     initializeApp({
+       projectId: "cypress-chat-test",
+     });
+     on('task', {
+       createUser: async ({ email, password, uid }) => {
+         const auth = getAuth()
+         return auth.createUser({ email, password, uid, emailVerified: true })
+       },
      })
    },
  },
});
```

ここで、initializeAppのところですが、projectIdはEmulatorが起動しているprojectIdを指定してください。
Emulator起動時にprojectIdを指定している場合はそのIDとなり、何もしてない場合は、`.firebaserc`に書いているidが採用されます。

これで事前にユーザーを作れるようになりました。
事前にユーザーを作れるようにすると、テスト開始時にEmulatorのデータがクリアされた状態にした方がいいので、その仕組みも導入します。
このコマンドは、commandに追加します。

```typescript:cypress/support/commands.ts
/// <reference types="cypress" />

Cypress.Commands.add('teardown', () => {
  cy.exec(
    `curl -v -X DELETE "http://localhost:8080/emulator/v1/projects/cypress-chat-test/databases/(default)/documents"`
  );
  cy.exec(`curl -v -X DELETE "http://localhost:9099/emulator/v1/projects/cypress-chat-test/accounts"`);
});

declare namespace Cypress {
  interface Chainable {
    teardown(): Cypress.Chainable<void>;
  }
}
```

curlコマンドを使ってますが、要はhttpリクエストでEmulatorの中身は消すことができます。
`declare`の部分はTypescriptとして、Cypressの下に関数を追加されるようにするための記述です。

ここでさらにテストを簡単に書くための設定を１つ追加します。

```diff_typescript:cypress.config.ts
module.exports = defineConfig({
  e2e: {
+   experimentalStudio: true,
    baseUrl: "http://localhost:3000",
```

では、実際にテストコードを書き換えてみましょう。

```diff_typescript:message-submit.cy.ts
describe("empty spec", () => {
+ afterEach(() => {
+    cy.teardown()
+ })
  it("passes", () => {
+   cy.task("createUser", {
+     email: "taro@example.test",
+     password: "test1234",
+     uid: "USERUID",
+   });
    cy.visit("/");
  });
});
```

ここまでは、先ほどと同じ結果になるのですが、テストケースの名前の列の一番右あたりにマウスでフォーカスを当てると"Add Commantds to Test"という表示されるボタンが出てくると思いますので、それを実行します。

![スクリーンショット 2022-10-22 23.44.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341912/08ee8853-bdd7-df19-f8bb-d116a8900fb5.png)

すると、再度テストが実行されて、同じように止まります。
が、少し表示が変わっていると思います。
この状態で、実際の画面を触ってログインをしてみます。

![スクリーンショット 2022-10-22 23.49.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341912/996dfe55-fc94-fd5f-0f1e-5d3e7c481f05.png)

画面を見てもらうとわかるんですが、操作した内容が全部記録されます。
さらに、左側の一番下にある"Save Commands"を押すと、開いているテストコードのファイルに操作した内容が全て記録されます。

そのままだと、まずい時もあるので、その後自分で少し編集してあげれば、自分で書くより格段に早くテストを書くことができます！

実は操作だけではなく、右クリックで画面を触るとAssertも追加できるようになっていて、GUIでテストが書けるようになっています。

ここまででチャットを操作するテストはかけちゃうのですが、事前にFirestoreにデータが入っているテストを普通のプロジェクトではよくあることなので、そのコマンドも追加してみます。

```diff_typescript:cypress.config.ts
import { getFirestore, Timestamp } from "firebase-admin/firestore";

module.exports = defineConfig({
// 略
      on('task', {
        createDoc: async ({
          path,
          data,
        }: {
          path: string
          data: { [key: string]: any }
        }) => {
          const db = getFirestore()
          Object.entries(data).map(([key, value]) => {
            if (typeof data !== 'object') {
              return
            }
            if (value['seconds']) {
              data[key] = Timestamp.fromMillis(
                value.seconds * 100 + value.nanoseconds / 1000000
              )
            }
          })
          return db.doc(path).set(data)
        },
      })
    },
  },
});
```

基本的には、createUserの時と同じようにfirebase-adminの関数を呼んでいるだけなんですが、１つ注意が！
Cypressのtaskの引数はあくまで値だけでメソッドまでは連携されないようになっています。
そのため、Firestoreの日時データであるTimestamp型を設定してあげても、Date型を指定しても、思った通りFirestoreにTimestamp型としては入らず、secondsとnanosecondsを持ったマップデータに変換されてしまいます。
なので、そこを解消するためのロジックを追加しています。
これでTimestamp型でデータを渡されても正しく実行されます！

少し違いますが、今回のコードは以下に載せています！

https://github.com/konti-kun/cypress-chat