FirestoreのセキュリティルールおよびCloud Functionsのテスト実装方法と自動化
FirebaseのCloud Functions上でFirestoreを扱う処理を実装しているときのテストコードの実装方法、またそれらの自動化について解説します。
解説すること
- @firebase/rules-unit-testingを使ったセキュリティルールのユニットテストの書き方
- Admin SDKを使ってFirestoreに読み書きする処理を書いているときのテストコードの書き方(Emulatorを利用します)
- それらのテストコードをGitHub Actionsで自動化する方法
解説しないこと
- FirebaseやFirestoreそのもの、およびFirebaseのEmulatorについての基礎知識
- firebase-functions-testの解説(※もしかしたら今後追記するかも)
- セキュリティルールのテストカバレッジの検出方法(※参考文献を載せておきます)
@firebase/rules-unit-testing
を使ったセキュリティルールのユニットテストの書き方
それではまずは@firebase/rules-unit-testing
を使ったセキュリティルールのユニットテストの書き方について説明します。
セキュリティルールのテストの大まかな方針
セキュリティルールのテストって具体的に何をするべきなのかについてざっくり説明しておきます。
たとえば日記アプリを作っているとして、日記のDocumentは自分が作成したものしか読み取れないとします。その仕様を満たすためにセキュリティルールを書くわけです。
そのテストコードを書きたい場合、テストコード上では以下のことを実現できればテストできたといえます。
- Firestore上に自身のユーザーIDが紐付けられた日記Documentを保存しておく
- 自身のユーザーIDでログインした状態で、Firestoreにアクセスし、保存した日記Documentを取得しようとする
- 成功すればテストOK
- 他人のユーザーIDでログインした状態や、未認証の状態でFirestoreにアクセスし、保存した日記Documentを取得しようとする
- 失敗すればテストOK
こういったことをテストコード、本記事においてはjest
や@firebase/rules-unit-testing
を使って実装する方法を解説していきます。
テストコードからEmulatorに接続する共通処理
テストコードからFirestoreにドキュメントの書き込みや読み取りを行う場合、実際のFirebase Projectをテスト用に用意してCredentialsを読み取らせて使うこともできますが、Emulatorが充実している今ではそちらを使ったほうが取り回しがよいです。そこで、テストコードからEmulatorにつなぐ方法をまずは説明していきます。
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
を使っているとして、以下のように共通関数を実装します。
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' })
});
例えば上記の例では、authorUserId
にuserId
を指定したドキュメントを生成することで、自身が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操作に成功すること、または失敗することをアサートできます。
今回の例では、db
はgetTestEnv().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に接続できるようにするために、専用のクラスを実装します。
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です。
yarn emulators:start:test
コマンドは、以下のようなコマンドを実行します。
"emulators:start:test": "firebase emulators:start -P default --only firestore,functions"
以上で、Firebase Cloud Functions上で動いているFirestore関連の処理のテストコードを書いたり、セキュリティルールのユニットテストを書く方法、およびそれらをGitHub Actionsで自動化する方法の解説を終わります。
参考文献
Testable Firebase
すごいよかったので本記事が参考になったりユースケース刺さるなーって方はぜひ買ってください(ただの布教)。セキュリティルールのテストカバレッジ測定方法なども書いてあります。
オンライン家庭教師マナリンクを運営するスタートアップNoSchoolのテックブログです。 manalink.jp/ 創業以来年次200%前後で売上成長しつつ、技術面・組織面での課題に日々向き合っています。 カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion