初めに
Firestoreのセキュリティルールは、@firebase/rules-unit-testing
を使うことで効率的に書くことができるのですが、プロダクションコードに書いているちょっと複雑なQueryをテストするときは、色々工夫が必要なので、それについてまとめました。
Queryの部分を切り出す
まずは、Queryだけのテストができるようその部分が切り出せるようにします。
例えば、以下のようなReactで書かれたメッセージの一覧画面があったとします。
import { collection, getFirestore, query, where } from 'firebase/firestore';
import { useCollectionData } from 'react-firebase-hooks/firestore';
import { LoadingScreen } from './LoadingScreen';
export default function MessageList({ uid }: { uid: string }) {
const [messages] = useCollectionData(
query(
collection(getFirestore(), 'messages'),
where('isPublished', '==', true),
where('creatorId', '==', uid)
)
);
if (!messages) {
return <LoadingScreen />;
}
return (
<div>
{messages!.map((message) => (
<div key={message.id}>{message.content}</div>
))}
</div>
);
}
この状態だと、Queryの部分だけのテストがしづらいので、別ファイルにまずは分離します。
import { collection, getFirestore, query, where } from 'firebase/firestore';
export publishedMyMessagesQuery = (uid: string) =>
query(
collection(getFirestore(), 'messages'),
where('isPublished', '==', true),
where('creatorId', '==', uid)
)
);
import { useCollectionData } from 'react-firebase-hooks/firestore';
import { LoadingScreen } from './LoadingScreen';
import { publishedMyMessagesQuery } from 'lib/message';
export default function MessageList({ uid }: { uid: string }) {
const [messages] = useCollectionData(publishedMyMessagesQuery(uid);
if (!messages) {
return <LoadingScreen />;
}
return (
<div>
{messages!.map((message) => (
<div key={message.id}>{message.content}</div>
))}
</div>
);
}
Queryが分離されることで、コンポーネント自体をスッキリします。
後々、Messageに関わるQueryを同じファイルにまとめていくようにすると、テストが書きやすいだけでなく、セキュリティルールの見直しやインデックスの整理の時も同じファイルを見ればいいので、メリットが大きい構成です。
ここまでやれば、テストが書けそうなのですがそうもいかないのが今回メインで書きたいところです。
このQueryの中で、getFirestore
が呼ばれています。
これは、FirebaseのinitializeApp
を行った後しか利用できないものですので、テストの最初に実行する必要が出てきます。
それだけならまだ書けばいいという感じなのですが、テストなのでテストデータを用意しなくてはいけないので、セキュリティルールを突破できるAdmin SDKを利用したり、認証が必要なQueryだった場合は、SignInまで処理に書いていかなくてはいけません。
そこで、その煩雑な処理をうまく処理できる@firebase/rules-unit-testing
を使ってテストが書けるようにしていきます。
@firebase/rules-unit-testing
でFirestoreにつなぐ
先ほど説明した通り、getFirestore
をそのまま使うとダメなので、ここだけうまくモックできるように書き換えます。
通常、モジュールの一部を書き換えるときは、spyOn
を利用すると思いますが、Firebaseのモジュール構成だとそれでは動きません。
代わりに、importActualを使った方法でモックします。
import { vi } from 'vitest';
const getFirestoreMock = vi.fn();
vi.mock('firebase/firestore', async () => {
const firestore = await vi.importActual<object>('firebase/firestore');
return { ...firestore, getFirestore: getFirestoreMock };
});
どういうコードかというと、まずはfirebase/firestore
を全体としてモックします。
ただそれだと、全体的にモックされてしまうので、モックされない状態でのfirebase/firestore
をimportActualでimportします。その中でも、getFirestoreのみモック関数で上書きしてしまうというコードになります。
VitestだとimportActualですが、jestでもrequireActual
で似たような形で書けると思います。
あとは、このgetFirestoreMockを@firebase/rules-unit-testing
のfirestoreアクセスオブジェクトを渡せばいいだけです。
具体的なコードは以下のような形になります。
/* eslint-disable import/first */
import * as fs from 'fs';
import { vi } from 'vitest';
import * as firebase from '@firebase/rules-unit-testing';
const getFirestoreMock = vi.fn();
vi.mock('firebase/firestore', async () => {
const firestore = await vi.importActual<object>('firebase/firestore');
return { ...firestore, getFirestore: getFirestoreMock };
});
import { getDocs } from 'firebase/firestore';
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
describe('messageのquery', () => {
let testEnv: firebase.RulesTestEnvironment;
beforeAll(async () => {
testEnv = await firebase.initializeTestEnvironment({
projectId: 'project-test',
firestore: {
rules: fs.readFileSync('./firestore.rules', 'utf8'),
},
});
});
afterAll(async () => {
await testEnv.cleanup();
});
afterEach(async () => {
await testEnv.clearFirestore();
vi.clearAllMocks();
});
test('公開されたメッセージを取得する', async () => {
await testEnv.withSecurityRulesDisabled(async (context) => {
const adminDb = context.firestore();
await Promise.all([
adminDb.collection('messages').doc('MESSAGE_A').set({
content: 'テストだよA',
isPublished: true,
creatorId: 'user'}),
adminDb.collection('messages').doc('MESSAGE_B').set({
content: 'テストだよB',
isPublished: false,
creatorId: 'user'}),
adminDb.collection('messages').doc('MESSAGE_B').set({
content: 'テストだよC',
isPublished: true,
creatorId: 'other'})
]);
});
const db = testEnv.authenticatedContext('user').firestore();
getFirestoreMock.mockReturnValue(db);
const snapshot = await getDocs(publishedMyMessagesQuery('user'));
expect(snapshot.size).toBe(1);
});
});
SignInを書いたり、テストデータを触りたいためにAdmin SDKを使ってたらゾッとします(最初は書いてました・・・)。
シンプルなQueryだとテストもいらないかと思いますが、条件によってQueryの形が変わる時などは、この部分だけでテストができると安心感があります。
ただ、1つだけ注意は、これはエミュレーターを使ったテストになるのでindexが足りないなど例外が出ません!
それは実際のFirestoreに繋いでチェックする必要があります。
最後に
今回の記事は、FirestoreのQueryのテストに関するものでしたが、importActual
は、spyOn
で悩んでた人たちには一回試してみるといいかもしれません!
最後にちょっと宣伝ですが、今回のようなFirebaseを使ったプリケーションのテストについて本を書きましたので、気になった方は読んでいただけると嬉しいです!
BOOTHでも販売を開始しました!
こちらでは在庫がある限りは紙での購入が可能です!