FirebaseプロジェクトでMochaを使ったUnitテストを実行できるようにする

# 概要

FirebaseプロジェクトでMochaを使ったUnitテストを実行できるようにしてみます。
Unitテストは、Firebaseの機能を使うケースもあれば使うケースも当然できるようにします。

# Firebase上にプロジェクトを作成

Firebaseのプロジェクトのテストなので、まずはFirebaseにプロジェクトを作ります。

## プロジェクトを追加

Firebaseコンソールにログインしてプロジェクトを追加します。

<img width="684" alt="qiita1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/a70c6693-9a59-8029-4622-7b4a0ea730c6.png">

## プロジェクト名を入力

プロジェクト名は作りたいサービスの名前にすればいいのですが、ここでは "my-project" というプロジェクトを作成していきます。

<img width="735" alt="qiita2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/a52ad2a2-e0ae-01a8-8a67-cfe0b3161227.png">

## Googleアナリティクスを使うかどうかを選択

本記事ではGoogleアナリティクスを使用するかどうかによって、何かが変わるというわけではないので好きな方を選びましょう。どちらを選んだからといって、この先の手順に違いがでてくることはありません。
ここでは”今は必要ない”を選択しました。

<img width="846" alt="qiita3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/7564e1f0-7c91-cd8b-07c7-abeb1ddc0e49.png">

## プロジェクトの作成を待機

プロジェクトが作成されるまで少し時間がかかるので気長に待ちましょう。

<img width="392" alt="qiita4.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/23776a78-a31d-a967-7f23-471e46a620ab.png">

## Firestoreを有効にするためDatabaseを選択

Firestoreのテストを行いたいので、Firestoreを有効にします。これが有効になっていなければFirestoreのテストはできません。

<img width="414" alt="qiita5.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/785440e8-c897-dfdd-0746-eb42aeebc882.png">

データベースの作成ボタンを押します。

<img width="901" alt="qiita6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/88b31b43-56e1-42b4-5237-989cc3a6bb81.png">

ロックモードのまま次へボタンを押します。ロックモードか、テストモードを選択できますが、あとで変更してしまうのでデフォルトで選択されているロックモードのままにします。

<img width="687" alt="qiita7.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/113736a6-b997-a79b-3e54-a508f19e9fa8.png">

CloudFirestoreのロケーションを近いリージョンにします。ここではasia-northeast1(東京リージョン)にしました。

<img width="686" alt="qiita8.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64294/e1c381a6-35b3-04a5-8df3-1d5f184ce510.png">

# 任意のディレクトリーに移動してプロジェクトを作成

ここでは/path/to/projectsというディレクトリーにFirebase上のプロジェクトと同じ名前のディレクトリーを作成します。

```
$ cd /path/to/projects
$ mkdir my-project
```

# 作成したディレクトリー上でFirebaseプロジェクトを初期化

Firebaseにデプロイ可能なプロジェクトを作成します。

```
$ cd /path/to/projects/my-project
$ firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /path/to/projects/my-project

Before we get started, keep in mind:

  * You are initializing in an existing Firebase project directory

? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. (Press <space> to select, <a> to toggle all, <i> to i
nvert selection)
❯◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
```

上記のようにFirebaseで使う機能の選択を求められるので、カーソルキーでカーソルを移動して、スペースキーで必要な機能を選択していきます。
例えば、FirestoreとFunctionsを選択した場合は以下の通りです。

```
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. 
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◉ Firestore: Deploy rules and create indexes for Firestore
❯◉ Functions: Configure and deploy Cloud Functions
 ◯ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
```

次にデフォルトのプロジェクトの選択を求められます。my-projectをカーソルキーで選択して、Enterキーを押しましょう。
ここで[don't setup a default project]や、[create a new project]を選択するとセットアップを中断します。

```
=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: 
  [don't setup a default project] 
❯ my-project-f62b6 (my-project) 
  [create a new project] 
```

あとは何度かか入力を求められますが、Enterキーで進めてください。
途中で選択肢を誤った場合は前に戻れませんが、CTRL+Cで中断できるので最初(firebase initコマンドの実行)からやりなおしてください。
JavaScriptを使うか、TypeScriptを使うかを聞かれたところで、TypeScriptを選択し、後はEnterを押して進めましょう。

以下のような表示になります。

```
? Which Firebase CLI features do you want to set up for this folder? Press Space to select features, then Enter to confirm your choices. Firestore: Deploy rules and create indexes for Firest
ore, Functions: Configure and deploy Cloud Functions

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: my-project (my-project)
i  Using project my-project (my-project)

=== Firestore Setup

Firestore Security Rules allow you to define how and when to allow
requests. You can keep these rules in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore Rules? firestore.rules

Firestore indexes allow you to perform complex queries while
maintaining performance that scales with the size of the result
set. You can keep index definitions in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore indexes? firestore.indexes.json

=== Functions Setup

A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use TSLint to catch probable bugs and enforce style? Yes
✔  Wrote functions/package.json
✔  Wrote functions/tslint.json
✔  Wrote functions/tsconfig.json
✔  Wrote functions/src/index.ts
✔  Wrote functions/.gitignore
? Do you want to install dependencies with npm now? Yes

> protobufjs@6.8.8 postinstall /path/to/projects/my-project/functions/node_modules/protobufjs
> node scripts/postinstall

npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN @firebase/database@0.4.12 requires a peer of @firebase/app@0.x but none is installed. You must install peer dependencies yourself.
npm WARN @firebase/database@0.4.12 requires a peer of @firebase/app-types@0.x but none is installed. You must install peer dependencies yourself.
npm WARN @firebase/database-types@0.4.2 requires a peer of @firebase/app-types@0.x but none is installed. You must install peer dependencies yourself.

added 254 packages from 180 contributors and audited 698 packages in 8.383s
found 0 vulnerabilities


i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

✔  Firebase initialization complete!
```

# テストやコンパイルを行うためのパッケージをインストール

TypeScriptで作成されたプログラムのコンパイルや、テストの実行のためのパッケージをnpmでインストールします。

```
$ cd /path/to/projects/my-project/functions
$ npm install --save-dev typescript filesystem mocha mocha-typescript prettier source-map-support ts-node chai @types/chai @types/mocha istanbul remap-istanbul postinstall-build @firebase/testing typemoq
```

# package.json にテストプログラムのソースの格納場所を記述

"directories"エントリーを追加して、{"test": "test"}を値に記述します。

```json:package.json
{
  "name": "functions",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
   〜省略〜
  }
  〜省略〜
}
```

# tsconfig.json の書き換え

TypeScriptのコンパイラがtest用のプログラムもコンパイルしてくれるようにする設定や、不要なディレクトリーの除外を行います。

```json:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": false,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "experimentalDecorators": true,
    "target": "es2017"
  },
  "exclude": [
    "node_modules"
  ],
  "compileOnSave": true,
  "include": [
    "src",
    "test"
  ]
}
```

# テスト用のプログラムを格納するディレクトリーを作成

テスト用のプログラムを格納するディレクトリーを作成します。

```
$ cd /path/to/projects/my-project/functions
$ mkdir test
```

# テスト用ディレクトリー内にテストプログラムを格納します。

ここでは、mytest.test.tsというファイルの名前を作ったとします。
RubyのRSpecとかの経験があればすぐにかけるようになるのではないでしょうか。

```json:test/mytest.test.ts
import * as chai from 'chai';

describe('はじめてののテスト', () => {

    it('とある場合', () => {
        console.log("Hi, This is my first test!");
        chai.assert.ok;
    })
});
```

# コンパイル

*.tsファイルをコンパイルします。tsconfig.jsonに書いたとおり、srcディレクトリーとtestディレクトリーのプログラムをコンパイルし、libディレクトリーに結果を格納します。

```
$ cd /path/to/projects/my-project/functions
$ ./node_modules/typescript/bin/tsc
```

libディレクトリーにコンパイルされた*.jsファイルが格納されます。

# テストを実行

コンパイルされた*.jsファイルを指定してテストを実行します。
mochaコマンドで、テストを実行すると以下のような表示になります。 passingと表示されれば、すべてのテストが成功したことになります。

```
$ cd /path/to/projects/my-project/functions
$ ./node_modules/mocha/bin/mocha ./lib/test/mytest.test.js


  はじめてののテスト
Hi, This is my first test!
    ✓ とある場合


  1 passing (6ms)
```

一つでも失敗すると、failingが表示されます。

```:mytest.test.js
$ ./node_modules/mocha/bin/mocha ./lib/test/mytest.test.js


  はじめてののテスト
Hi, This is my first test!
    1) とある場合


  0 passing (7ms)
  1 failing

  1) はじめてののテスト
       とある場合:
     Error: the function {
  "stackTraceLimit": Infinity
} was thrown, throw an Error :)
      at processImmediate (internal/timers.js:443:21)
```

# Firebaseの機能のテスト

https://github.com/firebase/quickstart-nodejs にあるテストコードを使ってUnitテストができるようにしていきます。
テストが実行できるようになったら、テストコードをいろいろ修正して、目的のテストができるように修正していきましょう。

## mochaを実行するためのオプション設定

テスト実行時にmochaのオプションを毎回つけるのは面倒なので、毎回指定するオプションをファイルに書きます。
mochaはtest/mocha.optsというファイルがあれば実行時に自動的に読み込みます。
mocha.optsファイルに書かれたオプションを指定しなければテストが通りません。

```:test/mocha.opts
--ui mocha-typescript
--require source-map-support/register
--recursive test
```

## firestore.rulesを書き換える

実行のために一旦サンプル的に使用するルールファイルを持ってきます。

https://github.com/firebase/quickstart-nodejs/tree/master/firestore-emulator/typescript-quickstart からもってきました。

```:firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read;
      allow create: if request.auth.uid == userId && request.resource.data.createdAt == request.time;
    }
    match /rooms/{roomId} {
      allow read;
      // If you create a room, you must set yourself as the owner.
      allow create: if request.resource.data.owner == request.auth.uid;
      // Only the room owner is allowed to modify it.
      allow update: if resource.data.owner == request.auth.uid;
    }
  }
}
```

## Firebaseのテストコードを作成

https://github.com/firebase/quickstart-nodejs/blob/master/firestore-emulator/typescript-quickstart/test/test.ts を参考に作成したものです。

projectIdの値と、rulesで読み込むfirestore.rulesの位置、authedAppの引数の型をanyに修正しただけです。
先ほどのテストコードはRubyのRSpecがかければすぐ書けるようになりそうでしたが、こちらはJavaのJUnitみたいな書き方をしています。@testデコレーターがJavaの@Testアノテーションと一致するのでイメージしやすいのではないでしょうか。
どちらの書き方でもよいのでテストを書きやすい方を選んで書いてみてください。
実践で使う場合は、どちらかに統一した方が学習コストが抑えられてよいと思います！。

```typescript:test/firebase.test.ts
/// <reference path='../node_modules/mocha-typescript/globals.d.ts' />
import * as firebase from "@firebase/testing";
import * as fs from "fs";

/*
 * ============
 *    Setup
 * ============
 */
const projectId = "my-project";
const coverageUrl = `http://localhost:8080/emulator/v1/projects/${projectId}:ruleCoverage.html`;

const rules = fs.readFileSync("../firestore.rules", "utf8");

/**
 * Creates a new app with authentication data matching the input.
 *
 * @param {object} auth the object to use for authentication (typically {uid: some-uid})
 * @return {object} the app.
 */
function authedApp(auth: any) {
  return firebase
    .initializeTestApp({ projectId, auth })
    .firestore();
}

/*
 * ============
 *  Test Cases
 * ============
 */
before(async () => {
  await firebase.loadFirestoreRules({ projectId, rules });
});

beforeEach(async () => {
  // Clear the database between tests
  await firebase.clearFirestoreData({ projectId });
});

after(async () => {
  await Promise.all(firebase.apps().map(app => app.delete()));
  console.log(`View rule coverage information at ${coverageUrl}\n`);
});

@suite
class MyApp {
  @test
  async "require users to log in before creating a profile"() {
    const db = authedApp(null);
    const profile = db.collection("users").doc("alice");
    await firebase.assertFails(profile.set({ birthday: "January 1" }));
  }

  @test
  async "should enforce the createdAt date in user profiles"() {
    const db = authedApp({ uid: "alice" });
    const profile = db.collection("users").doc("alice");
    await firebase.assertFails(profile.set({ birthday: "January 1" }));
    await firebase.assertSucceeds(
      profile.set({
        birthday: "January 1",
        createdAt: firebase.firestore.FieldValue.serverTimestamp()
      })
    );
  }

  @test
  async "should only let users create their own profile"() {
    const db = authedApp({ uid: "alice" });
    await firebase.assertSucceeds(
      db
        .collection("users")
        .doc("alice")
        .set({
          birthday: "January 1",
          createdAt: firebase.firestore.FieldValue.serverTimestamp()
        })
    );
    await firebase.assertFails(
      db
        .collection("users")
        .doc("bob")
        .set({
          birthday: "January 1",
          createdAt: firebase.firestore.FieldValue.serverTimestamp()
        })
    );
  }

  @test
  async "should let anyone read any profile"() {
    const db = authedApp(null);
    const profile = db.collection("users").doc("alice");
    await firebase.assertSucceeds(profile.get());
  }

  @test
  async "should let anyone create a room"() {
    const db = authedApp({ uid: "alice" });
    const room = db.collection("rooms").doc("firebase");
    await firebase.assertSucceeds(
      room.set({
        owner: "alice",
        topic: "All Things Firebase"
      })
    );
  }

  @test
  async "should force people to name themselves as room owner when creating a room"() {
    const db = authedApp({ uid: "alice" });
    const room = db.collection("rooms").doc("firebase");
    await firebase.assertFails(
      room.set({
        owner: "scott",
        topic: "Firebase Rocks!"
      })
    );
  }

  @test
  async "should not let one user steal a room from another user"() {
    const alice = authedApp({ uid: "alice" });
    const bob = authedApp({ uid: "bob" });

    await firebase.assertSucceeds(
      bob
        .collection("rooms")
        .doc("snow")
        .set({
          owner: "bob",
          topic: "All Things Snowboarding"
        })
    );

    await firebase.assertFails(
      alice
        .collection("rooms")
        .doc("snow")
        .set({
          owner: "alice",
          topic: "skiing > snowboarding"
        })
    );
  }
}
```

## コンパイル

先述したコマンドと同じコマンドでコンパイルします。

```bash
$ cd /path/to/projects/my-project/functions
$ ./node_modules/typescript/bin/tsc
```

libディレクトリーにコンパイルされた*.jsファイルが格納されます。

## Firebaseエミュレーターのセットアップ

Firebaseエミュレーターのセットアップをしてなければセットアップを行います。
Jarファイルをダウンロードしているので、Executable Jar なんですね。

```bash
$ cd /path/to/projects/my-project/functions
$ firebase setup:emulators:firestore
i  firestore: downloading cloud-firestore-emulator-v1.6.2.jar...
Progress: =======================================> (100% of 58MB
```

## Javaのインストール

FirebaseエミュレーターはJavaで動くのでJavaをインストールしておきましょう。
Javaは[こちら](https://www.oracle.com/technetwork/java/javase/downloads/index.html)からダウンロードしてセットアップしましょう。

mac OSであれば```brew cask install oracle-jdk```でインストールすることも可能です。

## Firebaseエミュレーターを実行

テストを実行する前に、Firebaseエミュレーターを実行しておかなければテストは実行できません。
Javaがインストールされていなければ起動できないので、Javaは入れておきましょう。
次のコマンドで実行しておきましょう。

```
$ firebase serve --only firestore
i  firestore: Emulator logging to firestore-debug.log
✔  firestore: Emulator started at http://localhost:8080
```

エミュレーターは、CTRL+Cでシャットダウンすることができます。

## テストを実行

先述したコマンドと同じようにテストを実施します。

```bash
$ cd /path/to/projects/my-project/functions
$ ./node_modules/mocha/bin/mocha ./lib/test/firebase.test.js


  MyApp
    ✓ require users to log in before creating a profile (91ms)
    ✓ should enforce the createdAt date in user profiles (89ms)
    ✓ should only let users create their own profile (79ms)
    ✓ should let anyone read any profile (53ms)
    ✓ should let anyone create a room (41ms)
    ✓ should force people to name themselves as room owner when creating a room (51ms)
    ✓ should not let one user steal a room from another user (79ms)

View rule coverage information at http://localhost:8080/emulator/v1/projects/my-project:ruleCoverage.html


  7 passing (825ms)

```

以下のようなエラーが発生した方はFirebaseエミュレーターが起動されているか確認してください。
あるいは、あなたがアンチウィルスソフトにESETを使用しているのなら、Webアクセス保護が有効になっていると内部でエミュレーターと通信できないためにエラーが発生してしまっています。一時的にWebアクセス保護をOFFにするか、HTTPプロトコルで使用するポートから8080を除いて保存し、Firebaseエミュレーターを再起動して、テストを実行してみてください。
※なぜESETだけ書いてあるのかといえば、私がESETを使用しているからです。Kasperskyでは気にすることはなさそうです。

```bash
$ ./node_modules/mocha/bin/mocha ./lib/test/firebase.test.js


  1) "before all" hook in "{root}"
View rule coverage information at http://localhost:8080/emulator/v1/projects/my-project:ruleCoverage.html


  0 passing (11ms)
  1 failing

  1) "before all" hook in "{root}":
     Error: 2 UNKNOWN: Stream removed
      at Object.exports.createStatusError (node_modules/grpc/src/common.js:91:15)
      at Object.onReceiveStatus (node_modules/grpc/src/client_interceptors.js:1204:28)
      at InterceptingListener._callNext (node_modules/grpc/src/client_interceptors.js:568:42)
      at InterceptingListener.onReceiveStatus (node_modules/grpc/src/client_interceptors.js:618:8)
      at callback (node_modules/grpc/src/client_interceptors.js:845:24)

```

# 終わりに

Firebaseを使用したテストは、ローカルエミュレーターを使用したセキュリティルールのテストに終始しましたが、Firebaseの機能を使ったテストには変わりはありません。
あとは、ここから実施したいテストを書いていきましょう。
テストコードの詳しい説明はしませんでしたが、テストコード中にキーワードがたくさん含まれていると思うので、そのキーワードを使用してGoogle検索をすればきっとすぐに理解できるのではないでしょうか。