Firestore をTDDできるようにする【セキュリティールール・単体テスト編】

# Firestoreにおけるテストの概要
普段の業務では、TDDでの開発をベースにしているため、個人開発でよく利用するFirestoreを使った開発でもテストをきっちり書けるようになりたいと思い、今回まとめてみました。

Firestoreに関連するテスト対象は大きく２つあります。

- **Firestore Security Rules単体テスト　← 本記事🚀🚀🚀**
- **Firestore 更新に伴い起動するCloud Functions の単体テスト** ← COMMING SOON...

本記事では、前者の「**Firestore Security Rules単体テスト**」をご紹介します。

この記事で紹介したサンプルコード全体は、以下のGitHubリポジトリにアップロードしてあります。
全体を俯瞰したい場合や、細かい確認をされたい方はこちらをご覧ください。

https://github.com/kentny/firebase-testing



# 前提
Firebaseの基本的な設定は済んでいる状態とします。
また、今回は　**TypeScript での実装** を想定しています。
tsconfig.json など、話の本筋から逸れる設定については、上記のGitHubリポジトリを直接参照してください🙇‍♂️

# 今回扱うサンプルアプリの概要
今回は、Twitterを模したアプリをサンプルアプリとして作っていると想定しましょう。

## Firestoreデータ構造
Firestoreのデータ構造は以下のようなシンプルな形を想定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181439/1fae5117-a897-df17-2be3-4e8a821c7312.png)


### `users/{userId}`
ユーザ情報です。

### `users/{userId}/tweets/{tweetId}`
特定ユーザのツイートした内容を格納する領域です。
ユーザページにアクセスしたら、そのユーザのツイートを全て見たりするのに使うイメージです。

### `tweets/{tweetId}`
全ユーザのツイートを格納する領域です。
ツイッターのタイムラインを表示するのに使うイメージです。
**Cloud Functions を使って`users/{userId}/tweets/{tweetId}` にデータの追加や削除、変更があったらその内容を自動反映するようにします。**

## 読み書き権限の想定仕様
各ドキュメントの読み書きの権限は以下の仕様としましょう。

### `users/{userId}`
- READ:   全てのユーザに許可
- CREATE: 認証済みユーザのみ許可
- UPDATE: 認証済みユーザのみ許可
- DELETE: 誰にも許可しない

### `users/{userId}/tweets/{tweetId}`
- READ:   全てのユーザに許可
- CREATE: 認証済みユーザのみ許可
- UPDATE: 認証済みユーザのみ許可
- DELETE: 認証済みユーザのみ許可

### `tweets/{tweetId}`
- READ:   全てのユーザに許可
- CREATE: 誰にも許可しない
- UPDATE: 誰にも許可しない
- DELETE: 誰にも許可しない

`tweets/{tweetId}`のデータは、全てCloudFunctionsによって、`users/{userId}/tweets/{tweetId}`よりコピーされるので、セキュリティールール上は、書き込みや削除は許可しないようにします。
（CloudFunctionsでの操作はAdmin権限となるので、セキュリティールールで弾かれません。）

# Firestore Security Rules単体テスト
Firestoreにはセキュリティールールというものがあります。
これによって、各階層のドキュメントデータへの、読み書き制限を加えることができます。
間違った実装によって、予期せずデータが削除されたり、編集されてしまうのを防ぐためにもセキュリティールールをテストすることは重要です。

こちらの内容が詳しいです。

https://firebase.google.com/docs/firestore/security/test-rules-emulator?hl=ja

## 環境構築

### 階層構造
階層構造に決まりはありませんが、今回は以下のような構造にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/181439/1b1e7463-917f-d2a8-a791-665911bbfb96.png)


**firestore.rules** というファイルがありますが、ここの中でFirestoreセキュリティールールを設定することができます。
**users_rules.test.ts**, **tweets_rules.test.ts** はとりあえず空っぽのファイルです。

### 必要なパッケージのインストール
今回必要なパッケージがいくつか必要なので、以下のコマンドを実行します。
ProjectRoot にて実行してください。
```
$ npm install --save-dev @firebase/rules-unit-testing @types/jest jest ts-jest
```
Firebaseの公式チュートリアルでは、chaiをテストフレームワークとして使っていますが、JS系ではJestの方がメジャーだと思いますので、今回はJestを使ってみます。

### `package.json` にショートカットスクリプトを追加
`package.json` に以下のスクリプトを追加します。
```
  "scripts": {
    "test:firestore": "firebase emulators:exec 'jest ./test/firestore'"
  },
```

この時点でのコードはこちら。

https://github.com/kentny/firebase-testing/tree/d3d1cd0b117a2dbea461abf4f012bf21ba37a468

## Security Ruleのテストを書いていく
先ほどの、権限想定仕様に基づいてテストを書いていきましょう。

まずは、 `users_rules.test.ts`　をいじります。
以下のコードを書きましょう。

```ts
// users_rules.test.ts

import fs from 'fs';
import * as testing from '@firebase/rules-unit-testing';

const projectId = 'test-project';

describe('Testing users (users/{userId}) security rule', () => {
    let testEnv: testing.RulesTestEnvironment;
    let authenticatedUser:  testing.RulesTestContext;
    let unauthenticatedUser:  testing.RulesTestContext;

    beforeAll(async () => {
        testEnv = await testing.initializeTestEnvironment({
            projectId: projectId,
            firestore: {
                rules: fs.readFileSync("firestore.rules", "utf8"),
                host: 'localhost',  // ← firebase emulators:exec からテストする場合は不要だが、一応入れておく。
                port: 8080,         // ← firebase emulators:exec からテストする場合は不要だが、一応入れておく。
            }
        });
    });
});
```

ここでは、テストをするために `testEnv` に対して初期設定をしています。
`testEnv`はセキュリティールールのテストのために使います。
**設定している`projectId`は実際のProjectIDである必要はなく、適当な文字列で大丈夫です。**

### Jestを直接実行したい場合の注意点
コメントに書いた通り、`host`, `port` の指定については `package.json`に定義したスクリプトである `firebase emulators:exec`からJestを起動するなら不要です。
ですが、IDEの機能などで直接Jestを実行したりしたい場合は、

```
$ firestore emulators:start --only firestore
```
このコマンドでEmulatorを事前に立ち上げておいて、そのあとにJestを実行すれば、ローカルで起動したEmulatorを使ってテストが実行されます。


### 実際にテストを書く
では、以下の権限仕様をテストするコードを書いてみます。

```
users/{userId}
- READ: 全てのユーザに許可
- CREATE: 認証済みユーザのみ許可
- UPDATE: 認証済みユーザのみ許可
- DELETE: 誰にも許可しない
```

TDDでの開発を想定するので、当然実装よりもテストを先に書いてみましょう！

さて、READ/UPDATE/DELETEの動作については、Firestoreに初期Userデータがないとテストができません。
なので、以下のようにしてそのデータを用意します。
```ts 
    beforeEach(async () => {
        // 初期データを用意
        await testEnv.withSecurityRulesDisabled(context => {
            const firestoreWithoutRule = context.firestore()
            return firestoreWithoutRule
                .collection('users')
                .doc(testUserId)
                .set({ name: 'initial user name' })
        });

        // テストに使う、認証済みユーザと認証していないユーザを作成する
        authenticatedUser = testEnv.authenticatedContext(testUserId);
        unauthenticatedUser = testEnv.unauthenticatedContext();
    })
```
`withSecurityRulesDisabled()`という関数がありますが、これはテスト用の関数で、この関数の中のコールバック内では、**例外的にFirestoreのセキュリティールールが無視されます。**
なので、**好きなように初期データを書き込んだりすることができる**わけです。

これで初期データが用意できましたので、テスト本体を続けて以下のように書きました。

```ts
    // - READ: 全てのユーザに許可
    it('Unauthenticated user can READ.', async () => {
        // 読み込み動作
        const readUser = unauthenticatedUser.firestore()
            .collection('users')
            .doc('Test-User')
            .get()

        // 成功することの確認
        await testing.assertSucceeds(readUser);
    });

    // - CREATE: 認証済みユーザのみ許可
    it('Only authenticated user can CREATE.', async () => {
        // 認証済みユーザによる新規作成動作
        const createByAuthenticatedUser = authenticatedUser.firestore()
            .collection('users')
            .add({ name: "authenticated user name" })

        // 成功することの確認
        await testing.assertSucceeds(createByAuthenticatedUser);


        // 認証していないユーザによる新規作成動作
        const createByUnauthenticatedUser = unauthenticatedUser.firestore()
            .collection('users')
            .add({ name: "unauthenticated user name" })

        // 失敗することの確認
        await testing.assertFails(createByUnauthenticatedUser);
    });

    // - UPDATE: 認証済みユーザのみ許可
    it('Only authenticated user can UPDATE.', async () => {
        // 認証済みユーザによる更新動作
        const updateByAuthenticatedUser = authenticatedUser.firestore()
            .collection('users')
            .doc(testUserId)
            .update({ name: "authenticated user name" })

        // 成功することの確認
        await testing.assertSucceeds(updateByAuthenticatedUser);


        // 認証していないユーザによる更新動作
        const updateByUnauthenticatedUser = unauthenticatedUser.firestore()
            .collection('users')
            .doc(testUserId)
            .update({ name: "authenticated user name" })

        // 失敗することの確認
        await testing.assertFails(updateByUnauthenticatedUser);
    });

    // - DELETE: 誰にも許可しない
    it('Nobody can DELETE.', async () => {
        // 認証済みユーザによる削除動作
        const deleteByAuthenticatedUser = authenticatedUser.firestore()
            .collection('users')
            .doc(testUserId)
            .delete()

        // 失敗することの確認
        await testing.assertFails(deleteByAuthenticatedUser);


        // 認証していないユーザによる削除動作
        const deleteByUnauthenticatedUser = unauthenticatedUser.firestore()
            .collection('users')
            .doc(testUserId)
            .delete()

        // 失敗することの確認
        await testing.assertFails(deleteByUnauthenticatedUser);
    });
```

以上でテストコードが書けました。
実行するには、以下を実行すればOKです。
```
$ npm run test:firestore
```

まだセキュリティールールを実装していませんから、当然テストは失敗します。

この記事ではセキュリティールールの書き方は本筋ではないので省略しますが、セキュリティールールを書いて、テストがパスする状態になったのが以下のコミットです。

https://github.com/kentny/firebase-testing/tree/4c2bb189c60345c2768e8e594c9c57d72922a8e5

### 他のセキュリティールールのテストと実装もする
他のセキュリティールールの仕様を満たすために、テストと実装が必要ですが、それは省略します。
興味のある方は、以下のコミットで実装済みですのでご覧ください。

https://github.com/kentny/firebase-testing/tree/ab31ab6caf08e033a1697ba29c3d8c3afaa4af67

# 次回予告
**Firestore 更新に伴い起動するCloud Functions の単体テスト** ← COMMING SOON...
完成次第、ツイッターでお知らせします。
ぜひフォローお願いします🙇‍♂️
https://twitter.com/kenny_J_7

# 英語版も書いてます
Mediumで英語版も書いてます。

https://medium.com/@kenny_J_7/develop-your-firestore-with-tdd-unit-testing-security-rules-afefb0d772c4