Feedforce Developer Blog

フィードフォース開発者ブログ

Firestore エミュレーターを使ったテスト同士の競合が起きないようにしていい感じにテストできるようにした話

こんにちは、エンジニアの id:len_prog です。

私が所属している EC Booster チームでは、「カイゼンカード」機能の開発に Firebase を採用しています。
その中でも特に Cloud Functions for Firebase と Cloud Firestore をメインで使用しており、これらの採用により短い開発期間で機能をリリースすることができました 🎉

しかし、Firebase を採用したことで苦労したことが全く無かったわけではありません。
特に、テスト周りはインターネット上にもあまり情報が多くない状況で、色々ハマりながら開発をしてきました。

そこで、今回の記事では、いくつかあったハマりごとの中でも特に厄介だったものについて対策を書いていきます。

Firestore Emulator のプロジェクト共有時のデータ競合

Firebase Local Emulator Suite を使って Firestore に接続するテストを書いていた際に、
テストを単体で実行した場合には通るのに、他のテストと並列に実行した場合のみドキュメントの状態が予期せぬものになりテストが落ちてしまうことに悩まされました。

調査の結果、これは、接続先プロジェクトがすべてのテストで同じになってしまっているのが原因ということが分かりました。

この状態で同じドキュメントを書き換えるテストが並列で走ってしまった場合、実行タイミングによってはドキュメントが予期せぬ状態になってしまいます。
また、テスト結果が不安定だとテストが信用できず、実装を保証するものになりません。

このままでは役に立つテストが書けないと思い試行錯誤した結果、テストごとに違うプロジェクトの Firestore に接続することでそれぞれのテストが独立した状態で実行でき、結果としてデータ競合が防げることが分かりました。

以下、サンプルアプリケーションを用いてこの方法について書いていきます。

サンプルアプリケーションの概要

今回は、サンプルとして簡易的な RPG を開発することを想定します。
ゲームに登場するキャラクターは、以下のような構造のドキュメントを持つ characters コレクションで管理されています。

{
  name: string;
  level: number;
  job: string;
}

また、このゲームでは以下の行動のみが可能と仮定します(これだけじゃゲームとして成り立たないと思いますが、簡単のためということでお許しください)

  • キャラクターは、レベルアップすることができる
  • キャラクターは、転職することができる
    • 転職すると、キャラクターのレベルが1に戻る

なお、アプリケーション上においてキャラクターのレベルアップは、characterLevelUpUseCase、キャラクターの転職は characterJobChangeUseCase という関数を呼ぶことで行えることとします。

ここからは、実際にこれら2つの関数のテストコードが競合する様子を見ていきます。

データ競合発生時の構成

f:id:len_prog:20210624165133p:plain:w500

characterJobChangeUseCasecharacterLevelUpUseCasemy-game プロジェクトの Firestore を共有してしまっています。
この状態で両方の関数から同じドキュメントを書き換えてしまった場合、データ競合が発生する可能性があります。
この場合、実際のコードは以下のようになります。

// functions/src/usecases/characterJobChangeUseCase.spec.ts
import * as admin from "firebase-admin";
import { characterJobChangeUseCase } from "@/usecases/characterJobChangeUseCase";

admin.initializeApp({
  projectId: "my-game", // ここが問題
});

const charactersCollection = admin
  .firestore()
  .collection("characters");

describe(characterJobChangeUseCase, () => {
  const targetCharacterId = "target-character-id";

  beforeEach(async () => {
    await charactersCollection.doc(targetCharacterId).set({
        name: "アルス",
        level: 10,
        job: "すっぴん";
    });
  });

  afterEach(async () => {
    await charactersCollection.doc(targetCharacterId).delete();
  });

  it("キャラクターが転職した場合、レベルが1に戻ること", async () => {
    await characterJobChangeUseCase(targetCharacterId); // characterJobChangeUsecase#handle に渡された引数の ID を持つユーザーのレベルが1に戻る
    const jobChangedCharacter = (await charactersCollection.doc(targetCharacterId).get()).data();

    expect(jobChangedCharacter.level).toBe(1); // 実行タイミング次第では、1になるはずが11になってしまう!
  });
});
// functions/src/usecases/characterLevelUpUseCase.spec.ts
import * as admin from "firebase-admin";
import { characterLevelUpUseCase } from "@/usecases/characterLevelUpUseCase";

admin.initializeApp({
  projectId: "my-game", // ここが問題
});

const charactersCollection = admin
  .firestore()
  .collection("characters");

describe(characterLevelUpUseCase, () => {
  const targetCharacterId = "target-character-id";

  beforeEach(async () => {
    await charactersCollection.doc(targetCharacterId).set({
        name: "アルス",
        level: 10,
        job: "すっぴん";
    });
  });

  afterEach(async () => {
    await charactersCollection.doc(targetCharacterId).delete();
  });

  it("キャラクターがレベルアップした場合、レベルが1上がること", async () => {
    await characterLevelUpUseCase(targetCharacterId); // characterJobChangeUsecase#handle に渡された引数の ID を持つユーザーのレベルが1上がる
    const grownCharacter = (await charactersCollection.doc(targetCharacterId).get()).data();

    expect(grownCharacter.level).toBe(11); // 実行タイミング次第では、11になるはずが1に戻ってしまう!
  });
});

見ての通り、両方のテストが my-game プロジェクトの Firestore の、ID: target-character-id のドキュメントを更新してしまっています。
これらのテストコードを並列で実行した場合、キャラクターが転職したのにレベルが1に戻らないキャラクターがレベルアップしたはずなのになぜかレベル1に戻ってしまうなど予期せぬ状態になってしまい、 テストが落ちてしまう可能性があります。

この状態ではテストコードが信用できないので、テストごとに向き先プロジェクトを変えてこの問題を解決していきます。

データ競合解決後の構成

f:id:len_prog:20210705113933p:plain:w500

上図②③のようにテストごとに接続先プロジェクトを独立させることで、他のテストとの並列実行が原因のデータ競合を防ぐことができます。
具体的には、以下のように admin.initializeApp()の第一引数に他のテストと重複しないプロジェクトID を渡すようにします。

// functions/src/usecases/characterJobChangeUseCase.spec.ts

admin.initializeApp({
  projectId: "character-job-change-use-case-spec", //  図の②に対応
});

// functions/src/usecases/characterLevelUpUseCase.spec.ts

admin.initializeApp({
  projectId: "character-level-up-use-case-spec", // 図の③に対応
});

変更後のコードの全体像は以下のようになります。

// functions/src/usecases/characterJobChangeUseCase.spec.ts
import * as admin from "firebase-admin";
import { characterJobChangeUseCase } from "@/usecases/characterJobChangeUseCase";

admin.initializeApp({
  projectId: "character-job-change-use-case-spec", //  図の②に対応
});

// ここから下は構成変更前のコードと同じ

const charactersCollection = admin
  .firestore()
  .collection("characters");

describe(characterJobChangeUseCase, () => {
  const targetCharacterId = "target-character-id";

  beforeEach(async () => {
    await charactersCollection.doc(targetCharacterId).set({
        name: "アルス",
        level: 10,
        job: "すっぴん";
    });
  });

  afterEach(async () => {
    await charactersCollection.doc(targetCharacterId).delete();
  });

  it("キャラクターが転職した場合、レベルが1に戻ること", async () => {
    await characterJobChangeUseCase(targetCharacterId); // characterJobChangeUsecase#handle に渡された引数の ID を持つユーザーのレベルが1に戻る
    const jobChangedCharacter = (await charactersCollection.doc(targetCharacterId).get()).data();

    expect(jobChangedCharacter.level).toBe(1); // 転職するとレベルが1に戻ることを検証できるようになった
  });
});
// functions/src/usecases/characterLevelUpUseCase.spec.ts
import * as admin from "firebase-admin";
import { characterLevelUpUseCase } from "@/usecases/characterLevelUpUseCase";

admin.initializeApp({
  projectId: "character-level-up-use-case-spec", // 図の③に対応
});

// ここから下は構成変更前のコードと同じ

const charactersCollection = admin
  .firestore()
  .collection("characters");

describe(characterLevelUpUseCase, () => {
  const targetCharacterId = "target-character-id";

  beforeEach(async () => {
    await charactersCollection.doc(targetCharacterId).set({
        name: "アルス",
        level: 10,
        job: "すっぴん";
    });
  });

  afterEach(async () => {
    await charactersCollection.doc(targetCharacterId).delete();
  });

  it("キャラクターがレベルアップした場合、レベルが1上がること", async () => {
    await characterLevelUpUseCase(targetCharacterId); // characterJobChangeUsecase#handle に渡された引数の ID を持つユーザーのレベルが1上がる
    const grownCharacter = (await charactersCollection.doc(targetCharacterId).get()).data();

    expect(grownCharacter.level).toBe(11); // レベルアップした場合にレベルが1上がることを検証できるようになった
  });
});

このようにテストごとに向き先プロジェクトを変えることで、それぞれのテストで担保したいことをちゃんと担保できるようになります。

ちょっと微妙な点

上記の方法でテストごとに独立した環境の Firestore を操作できるようになり、データ競合を防げるようになりました。

しかし、この方法にはひとつだけ微妙な点があります。
問題の説明のために、先程掲載した競合解決後の構成図を再掲します。

f:id:len_prog:20210705113933p:plain:w500

上図①の接続先は、$ firebase use で指定したプロジェクトか、$ firebase emulators:start--projectを渡した場合にはそのプロジェクトになり、そのほかの方法で変えることは今のところできないようです。

そのため、プロジェクトをテストごとに分けた場合、上図②③のテスト中にテスト自体は動くものの、Firebase Emulator の UI からデータの内容を見ることはできなくなります。

一応、接続先を $ firebase use で指定しているものに切り替えるようコードを書き換えたりすればデバッグはできますが、 いちいち書き換えの手間が生じるので若干面倒です。

また、これは Firebase Enulator の UI で立ち上がっているすべてのプロジェクトの Firestore を見られるようになれば解決する問題ではあり、実際に firebase/firebase-tools-ui リポジトリに issue も立っていますが、すぐに対応が終わりそうには見えない状況なので、しばらくは不便な状況が続くことが予想されます。

所感

Firebase は便利ですが、当然ながら全くハマらずに開発できる銀の弾丸ではないですね。
しかし、基本的には便利でドキュメントもそれなりに読みやすく、個人的には使っていて満足感があります。

今後も日々の開発で得た Firebase や GCP 周りの TIPS を書いていけたらと思っておりますので、よろしくお願いいたします 🙏