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

# 初めに

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を使った方法でモックします。

```ts
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の形が変わる時などは、この部分だけでテストができると安心感があります。

ただ、１つだけ注意は、これはエミュレーターを使ったテストになるのでindexが足りないなど例外が出ません！
それは実際のFirestoreに繋いでチェックする必要があります。

# 最後に

今回の記事は、FirestoreのQueryのテストに関するものでしたが、`importActual`は、`spyOn`で悩んでた人たちには一回試してみるといいかもしれません！

最後にちょっと宣伝ですが、今回のようなFirebaseを使ったプリケーションのテストについて本を書きましたので、気になった方は読んでいただけると嬉しいです！

https://techbookfest.org/product/vs3BHXSD87tj80ijFY6Fpu?productVariantID=9m8QF8yRkweKgEPq6Zaesd

BOOTHでも販売を開始しました！
こちらでは在庫がある限りは紙での購入が可能です！

https://sg-firestarter.booth.pm/items/4182563