2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FirestoreのQuery部分をいい感じにテストする方法

Posted at

初めに

Firestoreのセキュリティルールは、@firebase/rules-unit-testingを使うことで効率的に書くことができるのですが、プロダクションコードに書いているちょっと複雑なQueryをテストするときは、色々工夫が必要なので、それについてまとめました。

Queryの部分を切り出す

まずは、Queryだけのテストができるようその部分が切り出せるようにします。

例えば、以下のようなReactで書かれたメッセージの一覧画面があったとします。

MessageList.tsx
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の部分だけのテストがしづらいので、別ファイルにまずは分離します。

lib/message.ts
import { collection, getFirestore, query, where } from 'firebase/firestore';


export publishedMyMessagesQuery = (uid: string) =>
  query(
    collection(getFirestore(), 'messages'),
    where('isPublished', '==', true),
    where('creatorId', '==', uid)
  )
);
MessageList.tsx
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アクセスオブジェクトを渡せばいいだけです。
具体的なコードは以下のような形になります。

messages.test.ts

/* 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でも販売を開始しました!
こちらでは在庫がある限りは紙での購入が可能です!

2
1
0

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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?