17
17

More than 3 years have passed since last update.

Firebase + TypeScriptで、楽をするためのテスト自動化のススメ

Last updated at Posted at 2020-12-10

この記事はFirebase Advent Calendar 2020 11日目の記事です。

TL;DR

  • FirebaseのFirestore,Functionsの動作検証にはEmulatorを活用するのが良い
  • Emulatorを活用しても手動での検証は面倒くさいので、楽をするためにテスト自動化を取り入れた方が良い
  • Firestoreのセキュリティルールテストは公式で紹介されているやり方一択。
  • FunctionsのテストもEmulatorを活用して可能だが、テストコードが複雑になり、実行時間も長くなりやすい。
    • 設計コストの考慮が必要だが、ドメインロジックとFunctionsが関わるコードを分割し、よりテストをやりやすくする方法もある。

はじめに

最近Firebaseを活用した開発に参加する機会が出てきたのですが、お手軽にバックエンド環境が構築できてしまうので、本当に便利ですね。サーバ運用に関する面倒な管理雑務をほとんどGoogleさんに丸投げでき、プロダクトの開発に集中できるのが凄く良いです。

ただ、プロダクトの規模が大きくなったり、複雑化してくると、手動での動作確認・テストが面倒になり自動化をしたくなってきます。

公式ドキュメントを漁ったりネット上の情報をググったりしながら、TypeScriptでのFirestore、Cloud Functionsの自動テストのやり方を色々試してみたので、今回はその結果をまとめてみたいと思います。

テスト自動化を目指す動機

テスト自動化の環境を整えるのは正直手間です。専用のパッケージを導入したり、プロダクトコードと別にテストコードをわざわざ書いたり…。
自動化に興味はあるけど、手間をかけただけのリターンは得られるのかと、導入に二の足を踏んでいる方もいると思います。

そこで、本題を書く前に、私が自動テストを導入したことで解消された、手動テスト時の面倒臭さについて書いてみます。
同じようなことで辛さを感じていたら、是非テスト自動化に手を付けてみましょう。

Firebaseサーバにデプロイして動作確認する時の辛さ

セキュリティルールファイルやFunctionsの関数を作成し、実際の動作をテストしたくなった段階で都度firebase deployコマンドでFirebaseサーバにデプロイし、Firebase Consoleやクライアントアプリ経由で動作を確認をする方法は、一番基本的な方法であり、ただ動かすだけであれば一番ラクな方法です。
しかし、1つ致命的な問題が存在します。それは、デプロイにかかる時間です。
例えば、Functionsは簡単な関数一つの更新でも約1~2分かかります。当然、デプロイ対象のコードの規模が大きくなるほどデプロイ時間は増加します。Firebaseサーバの状態にもよりますが、下手をすれば10分を超えます

何分もかけてデプロイが完了したあと、いざ動かしてみたらちょっとしたミスによるバグが見つかり、手元で修正した後、また何分もかけてデプロイをした後、動作確認をして…というのは、Functionsを使っていれば誰でも経験する苦行だと思います。

小規模なプロダクトだろうといちいちデプロイするのが億劫になり、開発モチベーションが著しく低下します。
私は一度体験して嫌になり、現在Firebaseサーバへのデプロイは、ローカル環境で十分に動作確認をした後、実際のFirebaseで動作させたいときのみ行うことにしています。

Emulatorコンソールを活用して動作確認する時の辛さ

ローカル環境でFirebaseの動作確認をする場合は、Firebase Local Emulator Suiteを活用することになります。名前の通り、ローカル環境でFirebaseのエミュレータを使えるようになる開発ツールです。
Firebase Local Emulator Suite の概要

上記ドキュメントに記載の通り、Firebaseの基本的な機能のほとんどをローカルで動かすことができます。
先程のデプロイ時間の問題についても、ローカル環境の所定のディレクトリに実行用のJSファイルが有れば動作するので、動作確認時に待ちが必要なのはTypeScriptのコンパイルのみとなります。

しかし、この方法にも弱点があります。クライアントアプリのバックエンドとして使う分には問題はないのですが、例えばFirestoreの更新をトリガーに動作するFunctionsを単独で動作確認したい場合、Firestoreへのデータ登録は以下のようなコンソール画面から手で行う必要があります。

firebase-emulator.PNG

これが面倒臭い!

例えば、Twitterのようなアプリで、ユーザAのfollowサブコレクションにユーザBが追加されたとき、ユーザBのfollowerサブコレクションにユーザAの情報を入れる、というような関数があったとします。

Firestoreは入れようと思えばどんなデータも入れられてしまうため、折角TypeScriptで書くなら専用の型を用意し、想定外のデータが入ってきていないかをチェックしたいところです。User型であれば以下のようなinterfaceを作るとしましょう。

interface User {
   id: string;
   name: string;
   age: number;
}

で、上記のような要件を満たす関数を作ると、以下のようになると思います。

functions
  .region("asia-northeast1")
  .firestore.document("users/{userId}/follows/{followUserId}")
  .onCreate(async (snap, context) => {
    const data = snap.data();
    const followUser: User = {
      id: context.params.followUserId,
      name: data.name,
      age: data.age,
    };

   return admin.
        .firestore()
        .collection("users")
        .doc(context.params.followUserId)
        .collection("followers")
        .doc(followUser.id)
        .set(followUser);
});

さて、エミュレータのコンソールを使って上記の関数の動作確認をするにはどうすれば良いでしょう?

まず、Firestoreのコンソールにて、ユーザAのデータをusersコレクションに作ります。次に、ユーザBの情報も作ります。関数のトリガーはusers/followsに何らかのデータが作成されることなので、ユーザAのドキュメントにサブコレクションfollowsを作り、そこにまたユーザBの情報を入れます。もちろん、IDは最初に作ったユーザBのものと同一にしなければいけませんし、name,ageフィールドも入れなければ型エラーが起きて関数実行は終了します。この例では簡略化してますが、ユーザの型となれば実際はもっとフィールドが多くなるでしょう。エミュレータコンソールにはデータコピー機能やインポート機能などはないので、GUIをポチポチしながら全て手入力でtypo無しで入力しなければいけません。
さて、やっとの思いで入力し、動作も問題が無いと確認しました。え? やっぱりfollow,followerはサブコレクションでなくarrayでidだけ保持する仕様にしたい? では、プロダクトコードを書き直し、既存のデータを全て削除してもう一度ユーザAを入力して…。

はい、やってられませんね。

もちろん、この程度のシンプルな要件であればクライアントアプリ経由の動作チェックで十分ではあります。が、クライアント含めてもっと複雑な要件の実装が必要になったとき、私だったら切り分けのためにCloud functionsの関数単独で動作確認をしたいです。

そうだ、自動化しよう

これまでに書いたような動作確認時の面倒臭さを解消するのが、Emulatorを活用したテストの自動化です。

まず、実行環境はEmulatorになるため、デプロイ時間の長さに悩まされることはありません。そして、テストは専用のコードをもとに自動的に実行されるため、いちいち手でテスト用のデータを入力する必要もありません。テストコード内で一度定義すれば、後はテスト用ツールが何度でも同じ動作テストを自動的に実行してくれます。
検証内容の変更が必要になっても、多くの場合はテストコードを少し書き直して再度テストを実行させるだけで済みます。コードの変遷はGitで辿れるため、いつどのような検証を行ったかについて、人間の記憶力という極めて信頼できないものに頼る必要はありません。
今現在のプロダクトコードが必要な要件を満たしているか確認したければ、それを検証したテストコードを実行すれば一瞬で客観的なテスト結果が分かります。
自動テストの実行自体を忘れてしまうことが不安であれば、(この記事では扱いませんが)CI環境を整備して任意のタイミングで自動的に自動テストが実行されるようにすることもできます。

いずれも手動テストでは実現できない、自動テストだからこ可能な検証工程の効率化です。
テスト自動化は、これらの効率化により楽をするために取り入れるものなのです。

また、テストコードを用意して開発することは、他にも以下のような副次的メリットが得られます。

  • テストしやすいコードになることを意識するようになり、コードが自然と疎結合・高凝集になりやすい
  • リファクタリング・仕様変更が必要になっても、テストコードによる保護があるため、既存機能を破壊してしまう心配が減る
  • テストコードが整理して書かれていれば、動く(=信頼できる)テスト仕様書となり、他人(数カ月後の自分を含む)のコード読解の助けとなる

もちろん、テストコードを書くにも工数がかかるため、全てのコードの動作確認に取り入れるのは現実的ではありません。しかし、上記のように様々なメリットが得られるため、プロダクト内での重要機能や検証が多く必要になる箇所など、優先順位をつけて積極的に導入していきたいところです。

実践

前置きが長くなりましたが、ここからが本題です。
Emulatorを活用した自動テストの方法について紹介していきます。

環境構築

Firebase Local Emulator Suite + TypesScriptで自動テストを行うための環境を作っていきます。

手っ取り早く構築したい人は、以下のリポジトリをCloneしてください。この後紹介するテストコードも実物が入ってます。
Dockerで動かすこと前提に設定してるので、その点だけご注意ください。
https://github.com/iridon0920/firebase-test-sample

Docker

今回使用するDockerfileは以下のとおりです。

Dockerfile
FROM node:12.19.1-stretch

RUN apt-get update -y

RUN apt-get install -y openjdk-8-jre

RUN npm install -g  --no-optional firebase-tools

TypeScriptでコードを書くので当然ベースイメージはNode.jsです。バージョンは記事作成時点でFirebaseがサポートしているものを指定しています。
JREをインストールしていますが、これはEmulatorを動かすためにJava実行環境が必要なためです。Javaのバージョンについては、下記のページにある通り1.8以上が必要です。
Local Emulator Suite のインストール、構成、統合  |  Firebase
また、firebaseコマンドを使いたいため、npmでfirebase-toolsをグローバルインストールしています。

Docker Composeについては以下の通りとなっています。

docker-compose.yml
version: "3"

services:
  node:
    build: ./
    volumes:
      - ./:/app:cached
      - ~/.bashrc:/root/.bashrc
      - ~/.ssh:/root/.ssh
      - ~/.gitconfig:/root/.gitconfig
    ports:
      - 4000:4000 # firebase emulators suite
      - 8080:8080 # firestore emulator
      - 5001:5001 # functions emulator
    working_dir: /app
    tty: true

Emulator用にポートをいくつか公開しています。ホスト側からブラウザでEmulatorのコンソールにアクセスするのに必要です。

また、本題とは関係ないですが、volumesで.bashrc.gitconfigファイル、.sshディレクトリをマウントしています。VSCodeのRemote Containterを使ってコンテナ内に入って開発する場合、これらのマウントを設定しておくと、ターミナルをホスト環境と同じ感覚で操作できたり、コンテナ内から直接Gitでリモートリポジトリとやりとりできたりと、かなり便利なのでおすすめです。

Firebase

Docker Compose起動後、コンテナ内に入って/appディレクトリに移動。以下のコマンドでfirebaseへログインします。

firebase login --no-localhost

--no-localhostオプションを付けることで、トークンを利用したログインが可能です。これにより、ログインのためだけにコンテナで専用ポートを公開する必要がなくなります。

ログインができたら、firebase initコマンドを実行し、プロジェクトを初期化してください。
この記事の内容を実行するには、FirestoreとFunctionsがあれば良いです。
使用する言語を聞かれたら、TypeScriptを選択してください。
その他、特別な設定は必要ありません。

Firebase Emulator

無事Firebaseのプロジェクトが初期化できたら、以下のコマンドでEmulatorのインストールを行いましょう。

firebase init emulators

対話型で色々聞かれますが、全てデフォルトで問題ありません。
この記事ではFirestoreとFunctionsのエミュレータのみ利用するため、それ以外のインストールは不要です。

インストールが完了すると、firebase.jsonにEmulatorの設定項目が追加されています。
Docker環境で利用する場合、ホストと通信するために以下のようにそれぞれのEmulatorごとにhostの指定が必要です。

firebase.json
{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  },
  "functions": {
    "predeploy": [
      "npm --prefix \"$RESOURCE_DIR\" run lint",
      "npm --prefix \"$RESOURCE_DIR\" run build"
    ]
  },
  "emulators": {
    "functions": {
      "port": 5001,
      "host": "0.0.0.0"
    },
    "firestore": {
      "port": 8080,
      "host": "0.0.0.0"
    },
    "ui": {
      "enabled": true,
      "host": "0.0.0.0"
    }
  }
}

Firestoreテスト用パッケージ

上記の手順まで進むと、functionsディレクトリが生成されているはずなので、そこに移動しテストに必要なnpmパッケージをインストールします。

npm install -D @firebase/rules-unit-testing

Firestoreのテストに使用するパッケージです。ひとまず、これだけあれば問題ありません。

Jest

この記事では、テスト用フレームワークにはJestを使用します。Jestでなければ駄目ということはないので、他に慣れているものがあればそれを使っても構いません。
以下のコマンドでインストールします。

npm install -D jest ts-jest @types/jest

インストールが完了したら、以下のコマンドでjestを初期化します。

npx jest --init

色々対話型で聞かれますが、全てデフォルトで良いです。
jest.config.jsが生成されたら、以下の箇所のみデフォルトから書き換えてください。

jest.config.js
  testMatch: [
    "**/__tests__/**/*.test.ts?(x)",
  ],

TypeScriptの設定

tsconfig.jsonに以下のようにesModuleInterop項目を追加してください。
これが無いとimportが上手くいきません。

tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "outDir": "lib",
    "sourceMap": true,
    "strict": true,
    "target": "es2017",
    "esModuleInterop": true
  },
  "compileOnSave": true,
  "include": [
    "src"
  ]
}

この記事の内容を実行するのに必要な環境構築の設定は、これで完了です。

要件

テスト対象のプロダクトコードの要件は以下の通りとします。

  • FIrestoreのusersコレクションにデータが作られたら、以下の関数を実行する
    • 作成されたユーザデータの中から、非公開情報をread_private_usersコレクションへ保存する
    • 作成されたユーザデータの中から、公開情報をread_public_usersコレクションへ保存する
  • Firestoreのそれぞれのセキュリティルールは以下の通りとする
    • usersコレクションの特定のユーザデータへの書き込みは、ユーザ本人のみ可能
    • read_private_usersコレクションの特定のユーザデータの読み込みは、ユーザ本人のみ可能
    • read_public_usersコレクションの全てのデータは、認証済ユーザであれば誰でも閲覧可能

いわゆる、CQRSの考え方に基づいています。
このように設計することで、ユーザデータの書き込み先を統一する一方で、読み出し可能なフィールドはユーザによって制限したいという要件を、シンプルなコードで実現できます。

CQRSについては、以下の記事が分かりやすくておすすめです。
CQRSとイベントソーシングの使用法、または「CRUDに何か問題でも?」

実装

上記の要件を満たすために書いたプロダクトコードは以下のとおりです。

Firestoreセキュリティルール

コレクションごとにアクセス可能な条件を書いていきます。

firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow write: if request.auth.uid == userId;
    }
    match /read_private_users/{userId} {
      allow read: if request.auth.uid == userId;
    }
    match /read_public_users/{document=**} {
      allow read: if request.auth.uid != null;
    }
  }
}

Functions

コード内で想定外のデータが入ってくるのを防ぐため、まずinterfaceを定義します。

export interface User {
  id: string;
  name: string;
  email: string;
  address: string;
  likeCount: number;
}

export interface ReadPublicUser {
  id: string;
  name: string;
  likeCount: number;
}

export interface ReadPrivateUser {
  id: string;
  email: string;
  address: string;
}

上記のinterfaceを活用しつつ、要件を満たすFunctionsコードを書きます。

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { ReadPrivateUser, ReadPublicUser, User } from "../dto/user";

export const onCreateUser = functions
  .region("asia-northeast1")
  .firestore.document("users/{uid}")
  .onCreate(async (snap, context) => {
    const data = snap.data();
    const user: User = {
      id: context.params.uid,
      name: data.name,
      email: data.email,
      address: data.address,
      likeCount: data.likeCount,
    };

    return admin
      .firestore()
      .runTransaction(async (transaction) => {
        const privateUser: ReadPrivateUser = {
          id: user.id,
          email: user.email,
          address: user.address,
        };
        await transaction.set(
          admin.firestore().collection("read_private_users").doc(user.id),
          privateUser
        );

        const publicUser: ReadPublicUser = {
          id: user.id,
          name: user.name,
          likeCount: user.likeCount,
        };
        await transaction.set(
          admin.firestore().collection("read_public_users").doc(user.id),
          publicUser
        );
      })
      .catch((error: any) => {
        console.error(error);
        throw error;
      });
  });

このコードをindex.tsで読み込めば、Functionsの関数として実行する準備は整います。

テストコードの実装

テスト対象のプロダクトコードの用意はできました。
では、これらのコードが実際に要件を満たしているのかを確認するためのテストコードを書いていきます。

テストコードファイルのパスについて

この例では、以下のようにfunctions/srcディレクトリの下に__tests__ディレクトリを作成し、その中にテストコード用ファイルを作成していきます。
srcディレクトリと同階層にテスト用ディレクトリを作成するのがよくある形と思いますが、そのようにすると、VSCodeでテスト関連のメソッド見つからないという警告が発生する(実行自体は問題ない)ため、このような形にしています。

ts.config.jsがsrc以下をコンパイルする設定になっており、最終的なファイルにプロダクトとは無関係のコードが入ってしまいますが、ひとまず大きな問題は起きないのでそのままにしています。

├── jest.config.js
├── package-lock.json
├── package.json
├── src
│   ├── __tests__
│   ├── dto
│   ├── functions
│   └── index.ts
└── tsconfig.json

Firestoreのテスト

概要

Firestoreに設定するセキュリティルールが意図したものであるかどうかを確認するためのテストです。
システムのセキュリティに直結する箇所であり、手作業で細かく確認するのは難しいうえ、セキュリティルールの記述はシステムの規模が大きくなるほど複雑化しやすいため、できるだけ書いておいた方が安全でしょう。

テストコード

rule.test.ts
import * as firebase from "@firebase/rules-unit-testing";
import { TokenOptions } from "@firebase/rules-unit-testing/dist/src/api";
import { readFileSync } from "fs";

const projectId = "rule-test";

function getAuthFirestore(auth: TokenOptions) {
  return firebase.initializeTestApp({ projectId, auth }).firestore();
}

describe("firestore セキュリティルールテスト", () => {
  beforeAll(async () => {
    await firebase.loadFirestoreRules({
      projectId,
      rules: readFileSync("../firestore.rules", "utf8"),
    });
  });

  afterEach(async () => {
    await firebase.clearFirestoreData({ projectId });
  });

  afterAll(async () => {
    await Promise.all(firebase.apps().map((app) => app.delete()));
  });

  describe("ユーザ読み書きテスト", () => {
    test("usersのデータは、ユーザ本人のみ書き込み可能", async () => {
      const firestore = getAuthFirestore({ uid: "user" });
      const ref = firestore.collection("users").doc("user");
      await firebase.assertSucceeds(ref.set({ name: "サンプル" }));

      const otherUserRef = firestore.collection("users").doc("userB");
      await firebase.assertFails(otherUserRef.set({ name: "サンプル" }));
    });

    test("read_private_usersのデータは、ユーザ本人のみ読み込み可能", async () => {
      const firestore = getAuthFirestore({ uid: "user" });
      const ref = firestore.collection("read_private_users").doc("user");
      await firebase.assertSucceeds(ref.get());

      const otherUserRef = firestore
        .collection("read_private_users")
        .doc("userB");
      await firebase.assertFails(otherUserRef.get());
    });

    test("read_public_usersのデータは、認証済ユーザは誰でも閲覧可能", async () => {
      const firestore = getAuthFirestore({ uid: "user" });
      const ref = firestore.collection("read_public_users").doc("user");
      await firebase.assertSucceeds(ref.get());

      const otherUserRef = firestore
        .collection("read_public_users")
        .doc("userB");
      await firebase.assertSucceeds(otherUserRef.get());
    });
  });
});

実行結果

セキュリティルールテスト.PNG

解説

公式ドキュメントに記載してある方法に則った、オーソドックスなテストの書き方をしています。
Cloud Firestore セキュリティ ルールをテストする  |  Firebase

エミュレータを起動したあとにjestで対象のテストコードを実行することで、テストが可能です。

  • ProjectIdについて
    • 後述するfunctionsのテストと異なり、firestoreのテストの場合は任意の値で問題ありません。
    • initializeTestApp()など、いくつかのメソッドでProjectIdが必要となりますが、これらは一つのテスト内で同一のものである必要があります。
  • initializeTestApp()について
    • 任意の認証情報でfirestoreにアクセスできるオブジェクトを返すメソッド
    • 第二引数に認証情報オプションをセットでき、例えば{uid: "user"}と入れると、uidの値がuserのユーザとしてfirestore()にアクセスするようになります。
    • いちいち書くと長くなるため、上記のテストコード内ではgetAuthFirestore()メソッドに切り出しています。
  • assertメソッド
    • assertSucceeds()は、引数に渡したfirestoreのメソッドが成功することを確認します。
    • assertFails()は、引数に渡したfirestoreのメソッドが失敗することを確認します。
  • 各テストの解説
    • usersコレクションのテスト
      • uidがuserのユーザとして、usresコレクションのIDがuserのドキュメントに書き込みを行い、成功することを確認しています。
      • 一方でIDがuserB(自身のuidとは異なるID)のドキュメントに書き込みを行っても、失敗することを確認しています。
      • 上記のテスト結果より、usersコレクションの個別ドキュメントは本人のみが書き込み可能であることを確認しています。
    • read_private_usersコレクションのテスト
      • uidがuserのユーザとして、read_private_usersコレクションのIDがuserのドキュメントに読み込みを行い、成功することを確認しています。
      • 一方でIDがuserB(自身のuidとは異なるID)のドキュメントに読み込みを行っても、失敗することを確認しています。
      • 上記のテスト結果より、read_private_usersコレクションの個別ドキュメントは本人のみが読み込み可能であることを確認しています。
    • read_public_usrsコレクションのテスト
      • uidがuserのユーザとして、read_public_usersコレクションのIDがuserのドキュメントに読み込みを行い、成功することを確認しています。
      • 一方でIDがuserB(自身のuidとは異なるID)のドキュメントに読み込みを行っても、成功することを確認しています。
      • 上記のテスト結果より、read_private_usersコレクションの各ドキュメントは誰でも読み込み可能であることを確認しています。
  • テスト後の後片付け
    • afterEach()内のclearFirestoreData({ projectId })でエミュレータのfirestoreデータを初期化したり、afterAll()内のPromise.all(firebase.apps().map((app) => app.delete()))でアプリの初期化を行っています。

Functionsのテスト パターン1 (Emulatorを活用)

概要

Functionsが意図した通りに実行されるかどうかを確認するためのテストです。
上述したとおり、ちょっとした動作確認をするだけでも手間がかかるため、テストコードから実行することで楽ができます。
パターン1とあるように、この記事ではもう1パターン別のやり方もご紹介します。
この方法は、主に以下の記事やGoogle公式のcodelabの内容を参考にさせて頂いています。

テストコード

emulator.test.ts
import * as firebase from "@firebase/rules-unit-testing";
import { ReadPublicUser, ReadPrivateUser, User } from "../../dto/user";

process.env.FIRESTORE_EMULATOR_HOST = "localhost:8080";

const projectId = "playground-f8a4d";
const firestore = firebase.initializeAdminApp({ projectId }).firestore();
let unsubscribe: () => void;

describe("ユーザ書き込み", () => {
  beforeEach(async () => {
    await firebase.clearFirestoreData({ projectId });
  });

  afterAll(async () => {
    await Promise.all(firebase.apps().map((app) => app.delete()));
  });

  afterEach(async () => {
    if (unsubscribe) {
      unsubscribe();
    }
  });

  test("ユーザ作成時、公開用/非公開用の読み込み用ユーザが作成される", async () => {
    const user: User = {
      id: "test",
      name: "テストユーザ",
      email: "test@example.com",
      address: "Aichi Nagoya",
      likeCount: 10,
    };

    await firestore.collection("users").doc(user.id).set(user);

    await new Promise((resolve) => {
      unsubscribe = firestore
        .collection("read_private_users")
        .doc(user.id)
        .onSnapshot((snap) => {
          if (snap.exists) {
            const expectPrivateUser: ReadPrivateUser = {
              id: user.id,
              email: user.email,
              address: user.address,
            };
            expect(snap.data()).toEqual(expectPrivateUser);
            resolve();
          }
        });
    });
    unsubscribe();

    await new Promise((resolve) => {
      unsubscribe = firestore
        .collection("read_public_users")
        .doc(user.id)
        .onSnapshot((snap) => {
          if (snap.exists) {
            const expectPublicUser: ReadPublicUser = {
              id: user.id,
              name: user.name,
              likeCount: user.likeCount,
            };
            expect(snap.data()).toEqual(expectPublicUser);
            resolve();
          }
        });
    });
  });
});

実行結果

エミュレータテスト.PNG

解説

エミュレータを起動したあとにjestで対象のテストコードを実行することで、テストが可能です。

  • ProjectIdについて
    • firestoreのテストと異なり、ProjectIdは実プロジェクトに割り振られたものを使う必要があります。
    • 実プロジェクトとProjectIdが異なる場合、Emulatorでfunctionsが動作しません。
  • Promiseについて
    • テストコード中では、新たにPromiseオブジェクトを生成し、その中で値の検証を行っています。これは、テスト対象functionsの実行が完了したことを、テストコード側で検知する方法が無いためです。
    • awaitでPromiseが解決するのを待ち、Promise内ではテスト対象のfirestoreのコレクションのデータ変更を待ち、変更を検知したら、データの内容が期待しているものであるかを検証し、Promiseを解決するというのが、各テストコードの基本的な流れです
    • awaitとPromiseを使わないと、例えばawait firestore.collection("users").doc(user.id).set(user)で書き込みの完了を待っていても、裏側で実行されるfunctionsの実行完了までは待たないため、そのまま最後までテストコードが進んでしまいます。
  • テスト内容の解説
    • usersコレクションに書き込んだ後、read_private_usersコレクションと、read_public_usersコレクションにそれぞれ想定通りのフィールド内容でデータが作成されているかを確認しています。
  • テスト後の後片付け
    • firestoreのテスト同様afterEach()afterAll()でfirestoreデータ、アプリの初期化を行っています。またunsubscribe()により、firestoreの変更検知待ちを終わらせています。

課題点

わざわざPromiseを使った非同期処理を使うなど、テストコードがやや複雑になってしまっています。また、Emulatorの処理を待つ分テスト実行時間も長くかかりがちです。
これらはEmulatorを使ったテストをする以上避けられないデメリットです。

とはいえ、もっとシンプルなコードで、実行時間も短くテストを行いたいという気持ちもあります。例えば、TDDのようなテストコードを頻繁に変更・実行しながら開発する手法を使う場合、テストコードは直感的に変更できるようになっていて欲しいですし、レスポンスは早ければ早いほど良いです。

次の項では、よりシンプルで高速なテストを実現するための方法として、別のテストパターンをご紹介します。

Functionsのテスト パターン2 (Emulatorを使わない)

概要

今回のFunctionsを構成するロジックは、大きく2つに分けることができます。1つ目は、usersの内容から、非公開情報用のデータモデルと、公開情報用のデータモデルを適切に生成するロジック。2つ目は、usersコレクションへのデータ作成をトリガーに、read_public_usersコレクションとread_private_usersコレクションへ書き込みを行うロジックです。
前者については、ビジネスロジックやドメインロジック、後者についてはユースケースのロジックと言えるでしょう。

2つのロジックのうち、複雑化しやすく、様々な角度から検証する必要があるのは、前者のドメインロジックです。そこで、この項ではドメインロジックのテストコードをより書きやすい形にする手法をご紹介します。

プロダクトコードをテストしやすい形にする

現状のプロダクトコードを注目すると、ドメインロジックとユースケースの処理が1つのコード内に混在してしまっています。これは、オブジェクト指向の原則の1つである単一責任の原則に反した状態です。
パターン1でテストを行うのに複雑なコード(Emulatorの利用を前提にしたコード)が必要になってしまっているのは、それが原因です。ドメインロジックがfunctionのトリガー発火検知やfirestoreへの書き込みという明らかにレイヤの異なる処理の存在を前提にしている=依存しているのです。
異なるレイヤへの依存が増えるほどテストが書きづらくなるのは、どのような環境であれ変わりません。

よりシンプルなテストコードにするためには、まずプロダクトコードを改善する必要があります。
以下にその例を記述します。

プロダクトコードのリファクタリング

onCreateUser.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { UserFactory } from "../factory/userFactory";

export const onCreateUser = functions
  .region("asia-northeast1")
  .firestore.document("users/{uid}")
  .onCreate(async (snap, context) => {
    const data = snap.data();
    const uid = context.params.uid;
    const factory = new UserFactory({
      id: uid,
      name: data.name,
      email: data.email,
      address: data.address,
      likeCount: data.likeCount,
    });

    return admin
      .firestore()
      .runTransaction(async (transaction) => {
        await transaction.set(
          admin.firestore().collection("read_private_users").doc(uid),
          factory.createPrivateUser()
        );

        await transaction.set(
          admin.firestore().collection("read_public_users").doc(uid),
          factory.createPublicUser()
        );
      })
      .catch((error: any) => {
        console.error(error);
        throw error;
      });
  });
userFactory.ts
import { WriteUser, ReadPublicUser, ReadPrivateUser } from "../dto/user";

export class UserFactory {
  private user: WriteUser;

  constructor(writeUser: WriteUser) {
    this.user = writeUser;
  }

  createPublicUser(): ReadPublicUser {
    const publicUser: ReadPublicUser = {
      id: this.user.id,
      name: this.user.name,
      likeCount: this.user.likeCount,
    };
    return publicUser;
  }

  createPrivateUser(): ReadPrivateUser {
    const privateUser: ReadPrivateUser = {
      id: this.user.id,
      email: this.user.email,
      address: this.user.address,
    };
    return privateUser;
  }
}

onCreateUserは、ユースケースのロジック、つまりはusersコレクションへのデータ作成をトリガーに、read_public_usersコレクションとread_private_usersコレクションへ書き込みを行うロジックを行うだけに留め、ドメインロジックであるusersの内容から、非公開情報用のデータモデルと、公開情報用のデータモデルを適切に生成することは、新たに作成したuserFactoryクラスに任せています。
userFactoryクラスは、非常にシンプルなTypeScriptコードであり、Firebaseへの依存は完全に切り離されています。

このようにプロダクトコードを変更したことにより、ドメインロジックを検証するテストコードは、以下のようにシンプルに書くことが可能です。

テストコード

userFactory.test.ts
import { ReadPrivateUser, ReadPublicUser, WriteUser } from "../../dto/user";
import { UserFactory } from "../../factory/userFactory";

describe("ユーザデータ生成", () => {
  const user: WriteUser = {
    id: "test",
    name: "テストユーザ",
    email: "test@example.com",
    address: "Aichi Nagoya",
    likeCount: 10,
  };
  const factory = new UserFactory(user);

  test("非公開ユーザデータの生成", () => {
    const expectPrivateUser: ReadPrivateUser = {
      id: user.id,
      email: user.email,
      address: user.address,
    };
    expect(factory.createPrivateUser()).toEqual(expectPrivateUser);
  });

  test("公開ユーザデータの生成", () => {
    const expectPublicUser: ReadPublicUser = {
      id: user.id,
      name: user.name,
      likeCount: user.likeCount,
    };
    expect(factory.createPublicUser()).toEqual(expectPublicUser);
  });
});

実行結果

オブジェクト単体のテスト.PNG

解説

ドメインロジックに対するテストコードはシンプルで読みやすくなり、テスト時間についても、Emulatorが関与しない純粋なTypeScriptコードのテストになったため、Emulatorを使ったテストに比べると、コンパイル含めたテスト時間が1/3程度になっています。

また、このテストの実行には、予めEmulatorを起動しておくことは必要ありません。Jestを実行するだけでドメインロジックのプロダクトコードを検証できます。その意味で、より扱いやすいテストコードになったと言えるでしょう。

課題点

  • Firabese Functionsの動作検証はできない
    • あくまで切り離したドメインロジックのコードのみを検証しているため、Firebase Functions(ユースケースのロジック)に関する検証はまったくできていません。
    • 例えば、ある事象が起きたとき目的のトリガーが発火されるかの検証や、Firestoreの目的のコレクションに書き込まれているかの検証など、Firebaseの機能が密接に関わるテストを行いたい場合は、やはりパターン1のようなEmulatorを利用したテストコードが必要です。
  • 全体的な設計をしっかり考える必要がある
    • パターン1では、1つのファイルに処理がまとまっていましたが、このパターンではドメインロジックを別ファイルに切り出したため、コードの場所が分散するという意味での複雑さは上がっています。
      • 特に多人数での開発の場合、全体としてどのようなレイヤ分けの設計にするかしっかり決めておかなければ、混乱が生じてしまうでしょう。
  • コードの記述量が増えてしまう。
    • これもコードの記述を分散させると必然的に発生するデメリットです。

まとめ

  • Firestoreのルールをテストする場合は、Emulatorを活用したテスト一択。
  • Cloud FunctionsもEmulatorで自動テストが可能だが、テストコードが複雑になったり、実行時間がかかりがちになる。
  • プロダクトコードでドメインロジックを別クラスに切り離すことで、それに対するテストはよりシンプルなコードと短い実行時間で実行できる。
    • しかし、Firebase自体が関わる箇所のテストができなかったり、設計を考えるコストやコード自体の記述量が増えてしまうことは留意が必要。

Firestoreのテストは記事で紹介したやり方一択と思われますが、Functionsについては、どの手法でテストを行うのが最善かはプロダクトの状況により千差万別になると思います。例えばパターン2では大袈裟すぎたりする場合は、パターン1だけに統一したほうが良いでしょう。
また、ここで紹介したテスト手法はあくまで一例です。他にも色々なやり方があると思います。

記事内容について、誤っている点や、疑問点等ありましたら、コメントをいただければ幸いです。

17
17
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
17