🐕

FirestoreのセキュリティルールおよびCloud Functionsのテスト実装方法と自動化

2022/11/03に公開

FirebaseのCloud Functions上でFirestoreを扱う処理を実装しているときのテストコードの実装方法、またそれらの自動化について解説します。

解説すること

  • @firebase/rules-unit-testingを使ったセキュリティルールのユニットテストの書き方
  • Admin SDKを使ってFirestoreに読み書きする処理を書いているときのテストコードの書き方(Emulatorを利用します)
  • それらのテストコードをGitHub Actionsで自動化する方法

https://www.npmjs.com/package/@firebase/rules-unit-testing

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

解説しないこと

  • FirebaseやFirestoreそのもの、およびFirebaseのEmulatorについての基礎知識
  • firebase-functions-testの解説(※もしかしたら今後追記するかも)
  • セキュリティルールのテストカバレッジの検出方法(※参考文献を載せておきます)

@firebase/rules-unit-testingを使ったセキュリティルールのユニットテストの書き方

それではまずは@firebase/rules-unit-testingを使ったセキュリティルールのユニットテストの書き方について説明します。

セキュリティルールのテストの大まかな方針

セキュリティルールのテストって具体的に何をするべきなのかについてざっくり説明しておきます。

たとえば日記アプリを作っているとして、日記のDocumentは自分が作成したものしか読み取れないとします。その仕様を満たすためにセキュリティルールを書くわけです。

そのテストコードを書きたい場合、テストコード上では以下のことを実現できればテストできたといえます。

  1. Firestore上に自身のユーザーIDが紐付けられた日記Documentを保存しておく
  2. 自身のユーザーIDでログインした状態で、Firestoreにアクセスし、保存した日記Documentを取得しようとする
  3. 成功すればテストOK
  4. 他人のユーザーIDでログインした状態や、未認証の状態でFirestoreにアクセスし、保存した日記Documentを取得しようとする
  5. 失敗すればテストOK

こういったことをテストコード、本記事においてはjest@firebase/rules-unit-testingを使って実装する方法を解説していきます。

テストコードからEmulatorに接続する共通処理

テストコードからFirestoreにドキュメントの書き込みや読み取りを行う場合、実際のFirebase Projectをテスト用に用意してCredentialsを読み取らせて使うこともできますが、Emulatorが充実している今ではそちらを使ったほうが取り回しがよいです。そこで、テストコードからEmulatorにつなぐ方法をまずは説明していきます。

tests/firebaseUtils.ts
import {
  initializeTestEnvironment as _initializeTestEnvironment,
  RulesTestEnvironment,
} from '@firebase/rules-unit-testing';
import { readFileSync } from 'fs';

let testEnv: RulesTestEnvironment;

export const initializeTestEnvironment = async (projectId: string) => {
  process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
  testEnv = await _initializeTestEnvironment({
    projectId,
    firestore: {
      rules: readFileSync('firestore.rules', 'utf8'),
    },
  });
};

export const getTestEnv = () => testEnv;

initializeTestEnvironment関数を実行することで、Emulatorに接続したうえで、Firestoreが動作するテスト環境を用意することができます。そのため、たとえばローカルでテストコードを実行するときはEmulatorを別途立ち上げておく必要がありますし、CI上でもEmulatorの起動が必要です。

テストコードに共通処理を組み込む

次に、テストコードから先程実装した共通処理を呼び出せるようにします。

いろいろな方法がありますが、たとえばjestを使っているとして、以下のように共通関数を実装します。

tests/setUp.ts
import { getTestEnv, initializeTestEnvironment } from '../firebaseUtils';

export const TEST_PROJECT = 'demo-project-for-testing'

export const setUpTest = () => {
  beforeAll(async () => {
    await initializeTestEnvironment(TEST_PROJECT);
  });

  afterAll(async () => {
    await getTestEnv().cleanup();
  });

  afterEach(async () => {
    await getTestEnv().clearFirestore();
  });
};

beforeAllでinitializeすることで、一度しかinitializeTestEnvironmentが呼び出されないようにします。

これで、各テストコードの冒頭でsetUpTest関数を呼び出せば、テスト用のFirestoreに接続できる準備が整います。

テストコードでセキュリティルールのテストを書く

準備が整ったので、実際にテストコードを書いていきます。

まずはit.todo()関数を使って大まかなテスト項目を書き出しつつ、テスト用のFirestore環境を準備する部分を先に実装した状態を示します。余談ですがit.todo()を使ってあらかじめテスト項目を書き出しておくことで、テストの実装につい集中して項目をまるっと忘れてしまうことを防ぐやりかたが個人的には好きです。

import { setUpTest } from '../../functions/src/tests/utils/setUpTest';
import { getTestEnv } from '../../functions/src/tests/firebaseUtils';

// 補足:firestoreの型定義を使えて、かつcompatを使わない方法がこの内部ライブラリからimportするしかなさそうなのだが、それでいいのかちょっと自信ない
import { FirebaseFirestore } from '@firebase/firestore-types'

setUpTest();

const userId = 'test-userId'

describe('ログアウトしている場合', () => {
  let db: FirebaseFirestore;

  beforeEach(async () => {
    db = getTestEnv().unauthenticatedContext().firestore();
    await getTestEnv().withSecurityRulesDisabled(async context => {
      const adminDB = context.firestore();
      // TODO: テスト用のプリセットデータを用意する
    });
  });

  it.todo('作成できない');
  it.todo('読み込みできない');
  it.todo('更新できない');
  it.todo('削除できない');
});

describe('認証している場合', () => {
  let db: FirebaseFirestore;

  beforeEach(async () => {
    db = getTestEnv().authenticatedContext(userId).firestore();
    await getTestEnv().withSecurityRulesDisabled(async context => {
      const adminDB = context.firestore();
      // TODO: テスト用のプリセットデータを用意する
    });
  });

  it.todo('作成できる');
  it.todo('自分のは読み込みできる');
  it.todo('他人のは読み込みできない');
  it.todo('更新できない');
  it.todo('削除できない');
});

まずポイントとなるのは以下の部分です。

db = getTestEnv().unauthenticatedContext().firestore();
db = getTestEnv().authenticatedContext(userId).firestore();

これらは共通処理として実装したgetTestEnv()を呼び出し、そこからそれぞれRulesTestContextと呼ばれるものを生成して、Firestoreを読み書きできるインスタンスを取得しています。

RulesTestContextは特定のユーザーIDで認証済みとして扱えるものと、未認証として扱えるもの、あとは一切のセキュリティルールを無効にしたものの3パターン用意できます。unauthenticatedContextが未認証、authenticatedContextが指定したユーザーIDでログインした状態で扱えるものです。

この手順で生成したdbによって、テストケースを書いていきます。

また、読み取りのテストや更新のテストを書きたい場合、あらかじめプリセットでFirestoreにデータを書き込んでおく必要があります。その方法は直接Admin SDKをEmulatorにつないで書き込む方法もありそうですが、以下のように一切のセキュリティルールを無効にしたRulesTestContextで実現する方法もあります。

    await getTestEnv().withSecurityRulesDisabled(async context => {
      const adminDB = context.firestore();
      // TODO: テスト用のプリセットデータを用意する
      // 例
      await adminDB.collection('awesomeCollection').doc('myDoc').set({ authorUserId: userId })
      await adminDB.collection('awesomeCollection').doc('othersDoc').set({ authorUserId: 'HOGE' })
    });

例えば上記の例では、authorUserIduserIdを指定したドキュメントを生成することで、自身がauthorUserIdとして設定されているドキュメントしか読み取れない、というテストを書く準備が整います。

さて、ここまででテストケースを書く準備が整ったので、最後に個々のテストケースを書いていきます。
例として2ケースだけサンプルテストコードを示します。

  it('自分のは読み込みできる', async () => {
    await assertSucceeds(db.collection('awesomeCollection').doc('myDoc').get());
  });
  it('他人のは読み込みできない', async () => {
    await assertFails(db.collection('awesomeCollection').doc('othersDoc').get());
  });

assertSucceeds関数とassertFails関数を使うことで、特定のDB操作に成功すること、または失敗することをアサートできます。
今回の例では、dbgetTestEnv().authenticatedContext(userId).firestore()によって特定のユーザーIDとして認証された状態で生成したものとしてサンプルテストコードを書きました。

これまでの話を統合すると以下のように実装すれば、自身がauthorUserIdとして設定されているドキュメントしか読み取れない、というテストを書けるということです。

describe('認証している場合', () => {
  let db: FirebaseFirestore;

  beforeEach(async () => {
    db = getTestEnv().authenticatedContext(userId).firestore();
    await getTestEnv().withSecurityRulesDisabled(async context => {
      const adminDB = context.firestore();
      await adminDB.collection('awesomeCollection').doc('myDoc').set({ authorUserId: userId })
      await adminDB.collection('awesomeCollection').doc('othersDoc').set({ authorUserId: 'HOGE' })
    });
  });

  it.todo('作成できる');
  it('自分のは読み込みできる', async () => {
    await assertSucceeds(db.collection('awesomeCollection').doc('myDoc').get());
  });
  it('他人のは読み込みできない', async () => {
    await assertFails(db.collection('awesomeCollection').doc('othersDoc').get());
  });
  it.todo('更新できない');
  it.todo('削除できない');
});

他のテストケースの書き方は蛇足になるので割愛しますが、基本的にはこれまで説明した内容の組み合わせでセキュリティルールのテストが書けます。

Admin SDKを使ってFirestoreに読み書きする処理を書いているときのテストコードの書き方

それでは続いて、Admin SDKを使ってFirestoreに読み書きする処理を書いているときのテストコードの書き方について説明します。こちらはセキュリティルールのテストに比べるとシンプルな話です。

下ごしらえとしてプロダクトコードを外からFirestoreを受け取るように実装する

まず、Firestoreに読み書きする処理を実装するときに、そもそも関数として切り出した上で、firestoreのインスタンスを外から受け取るように実装しておきます。

たとえば以下の要領です。

import { firestore } from 'firebase-admin';
import Firestore = firestore.Firestore;

type Props = {
  firestore: Firestore;
  userId: string;
};

export const somehowAwesomeProcess: (props: Props) => Promise<void> = async props => {
  // トランザクションを貼る
  await props.firestore.runTransaction(async _ => {
    // props.firestoreを使ってコレクションやドキュメントの操作を行う
  });
};

この関数をCloud Functionとして実装した関数そのものから呼び出すようにしつつ、そちらでFirestore型のインスタンスを生成して渡すようにします。たとえば以下のように実装します。

export default (firebase: Firebase) => {
  return functions
    .region('asia-northeast1')
    .firestore.document('hogeDocument/{documentId}')
    .onCreate(async snap => {
      const hoge = snap.data() as Hoge;
      await somehowAwesomeProcess({
        firestore: firebase.firestore,
        userId: hoge.userId,
      });
    });
};

こうすることで関数単位でテストコードが書けるようになります(※firebase-functions-testを使えば個々のCloud Functionごとにテストが書けた記憶があるのですが、まだ調べきれていないので説明を割愛します。また、そうだとしてもFirestoreを取り扱う処理を個別の関数を切り出すことに悪い理屈はないかなと思います)。

テストコードでEmulatorに接続できるようにする

下ごしらえが済んだところで、テストコードからEmulatorに接続できるようにするために、専用のクラスを実装します。

tests/emulatorFirebase.ts
import * as admin from 'firebase-admin';
import { TEST_PROJECT } from './setUp'

export class EmulatorFirebase {
  firestore: admin.firestore.Firestore;

  constructor() {
    if (!admin.apps.length) {
      process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
      admin.initializeApp({
        projectId: TEST_PROJECT,
      });
    }
    this.firestore = admin.firestore();
  }
}

process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080'を実行した上で、initializeAppを実行するとEmulatorに接続した状態でAdmin SDKをセットアップできます。

テストコードを書く

ここまで準備が済めばテストコードの実装はいたってシンプルで、関数にEmulatorFirebaseインスタンスから取り出したFirestoreインスタンスを渡して実行したり、それを使ってアサートするだけです。

const firebase = new EmulatorFirebase();

// 中略

    it('関数の実行が成功し、正常にデータの書き込みが終わっている', async () => {
      await somehowAwesomeProcess({
        firestore: firebase.firestore,
        userId,
      });
      const createdDocument = await firebase.firestore
        .collection('hogeCollection')
        .where('user.id', '==', userId)
        .get();
      expect(rooms.docs.length).toBe(1);
    });

テストコードをGitHub Actionsで自動化する方法

最後に、GitHub Actionsで作成したテストコードを自動化する方法を示します。

name: Firebase Functions Test

on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    strategy:
      matrix:
        node-version: [14.X]

    steps:
      - name: checkout
        uses: actions/checkout@v3

      - name: setup Node.js v14
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'yarn'

      - name: install packages
        run: yarn install --frozen-lockfile

      - name: install packages(Functions)
        run: yarn install --frozen-lockfile
        working-directory: ./functions

      - name: start Emulator
        run: yarn emulators:start:test &

      - name: wait start Emulator
        run: sleep 10

      - name: execute test codes
        run: yarn test

ポイントはEmulatorをバックグラウンドで実行するために、末尾に&をつけて実行しているところです。
たとえば以下の記事で紹介されているTipsです。

https://fleetdm.com/engineering/tips-for-github-actions-usability

yarn emulators:start:testコマンドは、以下のようなコマンドを実行します。

    "emulators:start:test": "firebase emulators:start -P default --only firestore,functions"

以上で、Firebase Cloud Functions上で動いているFirestore関連の処理のテストコードを書いたり、セキュリティルールのユニットテストを書く方法、およびそれらをGitHub Actionsで自動化する方法の解説を終わります。

参考文献

Testable Firebase
すごいよかったので本記事が参考になったりユースケース刺さるなーって方はぜひ買ってください(ただの布教)。セキュリティルールのテストカバレッジ測定方法なども書いてあります。

マナリンク Tech Blog

Discussion