VSCodeデバッガを使ってCloud FunctionsとFirestoreの連携テスト(TypeScript)


# 目標

VSCodeのデバッガを使って、**breakpointを打ってデバッグしながら**下記2種類のテストを行う。

 - Firestoreのセキュリティルールのテスト
 - Cloud Functionsの、Firestoreをトリガーとする関数の動作テスト

コードはすべてTypeScriptで書く。


# 各種バージョン

 - firebase-tools 8.1.1
 - VSCode 1.44.2
 - npm 6.14.4
 - node 13.8.0
 - jest 25.4.0
 - ts-jest 25.4.0

nodeのバージョンはCloud Functionsのサーバーで使用されるものに合わせて`8.15.0`などにしてもいいかもしれません。その際は`nodenv`などを使いましょう
ここではnodeバージョンのズレは気にせずいくことにします。

↓Cloud Functionsのnodeのバージョンについて↓(リンク先は英語。日本語版もありますが、日本語版は更新が遅いことがあるので)
https://firebase.google.com/docs/functions/manage-functions#set_nodejs_version
https://cloud.google.com/functions/docs/concepts/exec#runtimes

# 前提

Firebase CLI がインストール済みで、
Firebaseプロジェクトは作成済みで、
`firebase init`なども済んでいるとします。

この記事では、その状態から、テストをどう実行していくかを説明していきます。

テストツールはJestを使います。

なお、この記事内で言う「Cloud Functions」は全て「Cloud Functions for Firebase」を指します。

#準備


ts-jestの公式リファレンスを参考にしていきます。
https://github.com/kulshekhar/ts-jest
https://kulshekhar.github.io/ts-jest/user/install

## 必要なパッケージのインストール


プロジェクトディレクトリ内で、下記コマンドを実行します。

```
npm init -y
npm i -D jest @types/jest typescript ts-jest
```

`npm init`で、`package.json`を作成します。`npm`に関する設定がここに書き込まれます。
その際いろいろ聞かれることがありますが、`-y`オプションをつけるとすべて`yes`と答えたことになり、聞かれなくなります。

`npm i`は`npm install`の略です。`-D`または`--save-dev`をつけると「これは開発時に使うパッケージであり、プロダクションには含めない」という意味になります。

プロダクション側でtypescriptを使っている場合は、これだけ-Dを外してインストールしてください。既にインストールされているなら不要。


### `functions/`にまとめてインストールすることで容量節約?

こちらのCode Lab(https://google.dev/pathways/firebase-emulators )では、テストに必要なパッケージも`functions`用のパッケージも全て`functions/`内の`node_modules/`にインストールしています。

その方が`node_modules/`が一つですみ、容量が100〜200MBほど節約できます。

が、しかし、それはやっぱり構造上おかしい気がするので、今回はプロジェクトディレクトリ自体にも`node_modules/`を持ち、`functions/node_modules/`とは分けることにします。

用が済んだらそれぞれの`node_modules/`は消してもいいです。`npm i`のコマンドでいつでも復活できます。


(そもそも`node_modules/`重すぎるだろ…)




## Jestの設定

Jest自体の設定はいくつかの書き方ができます。

私が参考にした記事では引き続き`package.json`内に`"jest"`という項目を作って書き込んでいるものが多かったですが、

今回はts-jestがうまくやってくれる仕組みがあるのでそれを利用します。


### `jest.config.js`の作成


こちらのコマンドを実行します。

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

すると`jest.config.js`というファイルが作成されます。


### jestの動作確認

この段階で動作確認してみましょう。

プロジェクトディレクトリに`firebase_test/`というディレクトリを作り、`hoge.test.ts`というファイルを作ります。

```typescript:firebase_test/hoge.test.ts
// TODO: このファイルは動作確認が済んだら消す
describe("check if jest works", () => {
  test("typescript works", async () => {
    let x: number = 1;
    let y: number = 2;
    let result: boolean = true;
    expect(x < y).toBe(result);
  });
});
```

型指定を使うことで、このままではJavaScriptとして解釈不可能なコードにしてみました。TypeScriptが動作していることを確認するためです。

テストコードを書いたら、次のコマンドを実行します。

```
npx jest
```

下記のような出力が出ればOK

```
 PASS  firebase_test/hoge.test.ts
  check if jest works
    ✓ typescript works (2ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.704s, estimated 2s
Ran all test suites.
```


### (optional)テストファイルの指定

Jestが探すテストコードは`jest.config.js`(または`package.json`の`jest`項目など)の中で`testMatch`という項目で[glob](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AD%E3%83%96)を使って指定できます。(**正規表現ではない**ので注意！)

何も指定しなかった場合のデフォルトはこうです。

```**/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x)```

読み取りにくいですが、`/firebase_test/hoge.test.ts`というパスはこれにマッチするので、見つけてもらえます。`**.test.ts`という名前なら大丈夫です。

例えばもし`firebase_test/`以下にある`**.test.ts`または`**.test.js`というファイルを対象にしたい場合は、`jest.config.js`に`testMatch`という項目を追加して下記のようにするといいでしょう。


```javascript:jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  testMatch: ["**/firebase_test/*.test.[jt]s"],
};
```

その他`Jest`の設定項目のリファレンスはこちら↓です。

https://jestjs.io/docs/ja/configuration



## VSCodeの設定


次は、今のテストコードを、VSCodeからブレイクポイントを打ちながら実行できるようにします。

### `.vscode/launch.json`を作成し改造

`.vscode/launch.json`を作成します。

VSCodeの左のバーからデバッグボタン(再生ボタンと虫が重なったマーク)を押し、上部の歯車ボタンを押せば作成できます。

次に、下記リンク先に従って、`launch.json`の中身を次のようにします。デフォルトの中身は消して構いません(必要な設定がある人は残してくださいね)。
https://jestjs.io/docs/ja/troubleshooting.html#vs-code%E3%81%A7%E3%83%87%E3%83%90%E3%83%83%E3%82%B0%E3%81%99%E3%82%8B

```json:launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Jest Tests",
      "type": "node",
      "request": "launch",
      "runtimeArgs": [
        "--inspect-brk",
        "${workspaceRoot}/node_modules/.bin/jest",
        "--runInBand"
      ],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "port": 9229
    }
  ]
}
```

### 実行

先程作った`firebase_test/hoge.test.ts`に適当にブレイクポイントを打って、

VSCodeのデバッグメニューから、今作った設定`Debug Jest Tests`を選び、実行します。

ブレイクポイントで一時停止しながらテストが実行され、結果が出力されればOKです！

一時停止時の変数の中身は、左部の`Closure`タブに隠れてます。





# Firebaseエミュレータでテスト

それではいよいよ、Firebaseエミュレーターを使っていきます。準備は終わって本番です。

## 必要なパッケージをインストール

まずはfirebaseのテストに必要なパッケージをインストールします。

```
npm i -D @firebase/testing
```

なお、参考にする記事によっては`firebase setup:emulators:firestore`という工程がある場合がありますが、2020年5月1日時点での最新バージョン(firebase-tools 8.1.1)では不要なはずです。(参考: https://firebase.google.com/docs/rules/emulator-setup )



## テストに関係するソースコード


### Firestore Security Rules

ログインしていたら全ての動作を許可、そうでなかったら全て不許可のガバガバルールです。あくまで動作確認用。

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}
```




### Cloud Functions

`items`コレクションに何かが追加されたら、`yeah:"yeah"`というフィールドを追加します。

型指定をわざわざ書いて、TypeScriptであることを明確にしています。

```typescript:functions/src/index.ts
import * as functions from "firebase-functions";

export const addYeah = functions.firestore
  .document("items/{itemId}")
  .onCreate((snapshot, context) => {
    let x: string = "yeah";
    let flag: boolean = true;
    snapshot.ref.set({ yeah: x }, { merge: flag });
  });
```

### テストコード

テストケースを記述しています。この記事の本題ではないので流し読みでいいです。

```typescript:firebase_test/firebase.test.ts
const fs = require("fs");
import * as firebase from "@firebase/testing";

// セキュリティルールに関するテストグループ
describe("security rule tests", () => {
  beforeEach(async () => {
    // 毎テスト前に、セキュリティルールを読み込みます
    await firebase.loadFirestoreRules({
      // functionsと連携しない場合、projectIdは何でもいい
      projectId: "my-test-project",
      rules: fs.readFileSync("firestore.rules", "utf8"),
    });
  });

  afterEach(async () => {
    // 毎テスト後に、テストで使用したクライアントアプリを削除します。
    await Promise.all(firebase.apps().map((app) => app.delete()));
  });

  test("can not get if not logged in", async () => {
    const db: firebase.firestore.Firestore = firebase
      .initializeTestApp({
        projectId: "my-test-project",
      })
      .firestore();
    const doc = db.collection("subjects").doc("math");
    await firebase.assertFails(doc.get());
  });

  test("can get if logged in", async () => {
    const db: firebase.firestore.Firestore = firebase
      .initializeTestApp({
        projectId: "my-test-project",
        auth: { uid: "test_user", email: "test_user@example.com" },
      })
      .firestore();
    const doc = db.collection("subjects").doc("english");
    await firebase.assertSucceeds(doc.get());
  });
});

// functionsに関するテストグループ
describe("functions tests", () => {
  beforeEach(async () => {
    // 毎テスト前に、セキュリティルールを読み込みます
    await firebase.loadFirestoreRules({
      // functionsと連携する場合、実際のprojectIdを入れる
      projectId: "step-by-step-4c04c",
      rules: fs.readFileSync("firestore.rules", "utf8"),
    });
    // 毎テスト前に、データベースを空にします。
    await firebase.clearFirestoreData({ projectId: "step-by-step-4c04c" });
  });

  afterEach(async () => {
    // 毎テスト後に、テストで使用したクライアントアプリを削除します。
    await Promise.all(firebase.apps().map((app) => app.delete()));
  });

  test("add yeah field", async (done) => {
    const db: firebase.firestore.Firestore = firebase
      .initializeTestApp({
        projectId: "step-by-step-4c04c",
        auth: { uid: "test_user", email: "test_user@example.com" },
      })
      .firestore();
    const doc = db.collection("items").doc("foobar");
    let result1: firebase.firestore.DocumentSnapshot = await doc.get();
    // はじめは該当データが存在しないことを確認
    expect(result1.exists).toBe(false);
    doc.set({ fuga: "fuga" });
    doc.onSnapshot((snapshot) => {
      let data2: firebase.firestore.DocumentData = snapshot.data();
      console.log("data changed!!");
      console.log(data2);
      // functionsが正常に動作したら、done()を実行してテストを完了する
      if (data2["yeah"] && data2["yeah"] === "yeah") {
        done();
      }
    });
  });
});
```


## テストコードのデバッグの場合

### breakpointを打つ

`firebase_test/firebase.test.ts`上で適当にbreakpointを打ちましょう。

### preLaunchTaskを設定し、自動で`functions/src/index.ts`をコンパイル

`functions`のエミュレータを起動する前に、TypeScriptで書いた`functions/src/index.ts`をコンパイルする必要があります。

手動で、`cd functions/` `npm run build` `cd ../` としても良いです。`functions/package.json`に、このコマンドで`tsc`が走ってコンパイルされるように設定されています。

しかし、毎回やるのは面倒なので自動化しましょう。

こちら↓の記事などを参考に、
https://qiita.com/TsuyoshiUshio@github/items/9879ea04cdd606982a65

`.vscode/launch.json`の`Debug Jest Tests`に`preLaunchTask`を追加して、デバッグ実行時に`functions/src/index.ts`がコンパイルされるようにします。

```json:.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug Jest Tests",
      "type": "node",
      "request": "launch",
      "runtimeArgs": [
        "--inspect-brk",
        "${workspaceRoot}/node_modules/.bin/jest",
        "--runInBand"
      ],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "port": 9229, ← このコンマと
      "preLaunchTask": "npm: build - functions"  ←この行を追加
    }
  ]
}
```

これで、`Debug Jest Tests`を実行すると毎回まず先に`npm: build - functions`とかいう名前の`task`が呼ばれます。


でもそんなものはまだ存在しないので作成しましょう。

手動で作ってもいいですし、VSCodeに大半を任せてもいいです。

任せる場合は、コマンドパレットを開いて`task`と入力し、
`Tasks: Configure Default Build Task`
`npm: build - functions`
を選びます。

<img width="823" alt="スクリーンショット 2020-05-01 18.15.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/079cf6a2-91fc-4295-046d-4fffb937a76c.png">

すると`.vscode/tasks.json`が作成されます。中身はこうなってるはず。

```json:.vscode/tasks.json
{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "npm",
      "script": "build",
      "path": "functions/",
      "group": {
        "kind": "build",
        "isDefault": true
      },
      "problemMatcher": [],
      "label": "npm: build - functions",
      "detail": "tsc"
    }
  ]
}
```

ざっくり言うと「`functions/`に移動して`npm run build`を実行する」という内容になっています。


`label`の部分が、このタスクの名前になります。先程`.vscode/launch.json`から指定した`npm: build - functions`になっていますね。もし名前が違ったら、合わせておきましょう。





### エミュレータの実行

はじめてエミュレータを実行する場合のみ、エミュレータを実行する前に、
`cd functions/` `npm run build`を実行するか、
先程作った`Debug Jest Tests`を実行して、TypeScriptのコンパイルを実行しておきます。

でないと`functions/lib/index.js`が存在しないため、エラーになってしまいます。

二回目以降はこのファイルが存在するので問題ないはずです。コンパイルによってこのファイルが更新された場合はエミュレータに自動で反映されます。


そうしたら、次のコマンドで、エミュレータを実行します。

```
firebase emulators:start
```

### デバッグ実行！

VSCodeの左部のバーからデバッグを選択し、`Debug Jest Tests`を実行します。

先程打ったbreakpointでテストの実行が一時停止するはずです！

これで、テストコードのデバッグができますね！


## `functions/src/index.ts`のデバッグの場合

### breakpointを打つ

`functions/src/index.ts`上で適当にbreakpointを打ちましょう。


### `.vscode/launch.json`に項目を追加


次にこちら↓の記事を参考に、`/.vscode/launch.json`に次の設定を追加します
https://qiita.com/u4da3/items/9c2d13eee43c9652e7d1

`Attach`という名前にしていますが、別になんでもいいです。


```json:.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
   ...
    {
      "name": "Attach",
      "type": "node",
      "request": "attach",
      "processId": "${command:PickProcess}",
      "outFiles": ["${workspaceRoot}/functions/lib/**/*.js"]
    }
  ]
}

```

### エミュレータの実行

そしてエミュレータを、次のオプションを付けて起動し直します。

`firebase emulators:start --inspect-functions`

これにより、エミュレータ上で動作する`functions`がbreakpointを受け付けるようになります。

### デバッグ実行！

そしたら、VSCodeのデバッグメニューから、今作った「Attach」を選んで実行します。

するとエミュレータを起動しているコンソールに

```
Debugger attached.
```

と表示されます。

この状態で、別のコンソールから次のコマンドを実行します。

VSCodeではターミナルウインドウを複数同時に開けるのでうまく使いましょう。

```
npx jest
```

としてテストコードを起動すると、`functions/src/index.ts`上のbreakpointで止まります。

これをすると、テストコードの方はタイムアウトしますがそれでいいです。

これで`functions`もデバッグできますね！


# 残る仕事

一回の実行で`firebase_test/hoge.test.ts`と`functions/src/index.ts`の両方のbreakpointを機能させる方法はわかりませんでした。

`.vscode/launch.json`に作った2つの設定を両方実行すると

```
Starting inspector on 127.0.0.1:9229 failed: address already in use
```

というエラーが出てしまいます。かと言って`.vscode/launch.json`上でポート番号を変えると動かない…。

わかる方がいらっしゃいましたら是非教えて下さい！！！！