mizdra's blog

ぽよぐらみんぐ

Prisma で本物のDBMSを使って自動テストを書く

DBMS に依存するロジックのテストを書く時、主に2つの手法があると思います。

  1. Repository 層などを mock する
    • Service 層のテストをする時は、その下位の Repository 層を mock して、DBMS に依存しない形にしてからテストする
    • レイヤードなアプリケーションで適用できる手法
  2. テスト実行時も DBMS を裏で動かして、それを使う
    • 本番と同じスキーマを持つ DBMS に対して、実際に insert したり select してテストする
    • DBMS は docker-compose upとかで事前に立ち上げておく

双方にそれぞれ良さがあって、プロダクトによってどっちでやるか変わってくると思います。

この記事では 2 の手法を Prisma でどうやるかについて紹介します。

前提

  • ORM には Prisma を使う
  • 言語は TypeScript
  • テスティングフレームワークは Jest
  • DBMS は MySQL

実際のテストコードの例

何はともあれ、まず実際のテストコードから。

// src/post/post.service.ts
import { UserHelper } from '../user/user.helper';
import { PostHelper } from './post.helper';
import { PostService } from './post.service';
import { prisma } from '../prisma/prisma.helper';

const postService = new PostService(prisma);

describe('getDraftPostsByUser', () => {

  it('ドラフト記事のみを返す', async () => {
    // User、Post レコードを helper を使って作成する
    const user = await UserHelper.create();
    const posts = [
      await PostHelper.create({ authorId: user.id, published: true }),
      await PostHelper.create({ authorId: user.id, published: false }),
      await PostHelper.create({ authorId: user.id, published: false }),
    ];
    // テスト対象のコードを呼び出す
    const actual = await postService.getDraftPostsByUser({ user });
    // 事前に作ったレコードと、引いてきたレコードが一致すれば OK
    expect(actual).toStrictEqual([posts[1], posts[2]]);
  });
  it('指定されたユーザの記事のみ返す', async () => {
    const user1 = await UserHelper.create();
    const user2 = await UserHelper.create();

    const post1 = await PostHelper.create({ authorId: user1.id, published: false });
    await PostHelper.create({ authorId: user2.id, published: false });

    const actual = await postService.getDraftPostsByUser({ user: user1 });
    expect(actual).toStrictEqual([post1]);
  });
});

参考までに、テスト対象のコードは以下です:

// src/post/post.service.ts
import { PrismaService } from '../../prisma.service';
import { Post, Prisma, User } from '@prisma/client';

export class PostService {
  constructor(private prisma: PrismaService) {}

  /** あるユーザのドラフト記事一覧を取得する */
  async getDraftPostsByUser({ user }: { user: User }): Promise<Post[]> {
    return this.prisma.post.findMany({
      where: {
        published: false,
        authorId: user.id,
      },
    });
  }
}

以下いくつかのポイントについて解説します。

テストヘルパーを作る

毎回全 field を指定してレコードを作成するのは大変なので、ある程度自動で設定してくれるヘルパーを作ると良いです。

// 大変
const post = await prisma.post.create({
  data: { title: '記事1', content: '本文1', published: true },
});
// ヘルパー経由でサクッと作れると良い。title や content などは自動でランダムに埋める。
const post = await PostHelper.create();
// 一部 field を固定したければ、呼び出し側から渡せるように。
const user = await UserHelper.create({ title: '面白い記事', published: false });

これは faker.js を使うと簡単に作れます。

// src/post/post.helper.ts
import { Post } from '@prisma/client';
import { faker } from '@faker-js/faker';
import { UserHelper } from '../user/user.helper';
import { prisma } from '../prisma/prisma.helper';

/**
* @file Post feature のテストヘルパー。
*/

export const PostHelper = {
  /** 記事を作成する。 */
  async create(args?: Partial<Post>): Promise<Post> {
    // authorId がなかったら、userHelper でその場で作る。
    // いちいち author を作らなくても記事を作れるようにする配慮です。
    const authorId = args?.authorId ?? (await UserHelper.create()).id;

    return await prisma.post.create({
      data: {
        title: faker.random.words(),
        content: faker.random.words(),
        authorId,
        ...args,
      },
    });
  },
};

createPostではなく、PostHelper.createのように namespace の下に定義しておくと、他のモジュールから補完をバチバチに効かせて利用できて便利です。VSCode でPostH まで type すると、PostHelperが補完候補に出てきて、選択すると import { PostHelper } from './post.helper'; を自動挿入してくれます。

あとはいちいちconst postHelper = new PostHelper(prisma);しなくてもヘルパーが使えるように、prisma.helper.ts側でシングルトンな PrismaClient を用意して、それを使い回すようにしてます。シングルトン使うのやんちゃな感じはしますが…まあテストなので良いかなと思ってやってます。

別解: ヘルパーを自動生成する

最近登場した @quramy/prisma-fabbrica を使うと、schema.prismaからヘルパーを自動生成してくれます。default field の生成方法も手動で決められますし、relation もちゃんと扱えます。これ使えば事足りそうな気がします。

github.com

他にも node_modules/.prisma/client/index.d.ts を元に TypeScript Compiler API を使って自動生成するアプローチもあるようです。

zenn.dev

jest worker ごとに database を分ける

Jest はデフォルトで 複数のプロセス (Jest の用語では worker) を立ち上げて、並列でテストを実行します。便利な機能ですが、並列に DB アクセスがあると競合して、テストが落ちてしまう恐れがあります。

そこで id:mizdra は worker ごとに database を分けることで、並列でテストが走っても競合が起きないようにしています。JEST_WORKER_ID で、そのテストを実行している jest worker の ID が取れるので、これを database の名前に利用します。

// jest.setup.ts
// Jest の設定の setupFilesAfterEnv から参照されているファイル

import { execSync } from 'child_process';
import { TEST_DATABASE_URL } from './src/prisma.helper';

// TEST_DATABASE_URL には 'mysql://user:password@localhost:3306/db' のような文字列が入っている想定。
process.env.DATABASE_URL = `${TEST_DATABASE_URL}-test-${process.env.JEST_WORKER_ID}`;

beforeAll(() => {
  // テストを実行する前に、前のテストで insert されたレコードを削除しつつ、スキーマも最新のものに更新する。
  execSync('npx prisma migrate reset --force --skip-seed', {
    // 公式ドキュメントでは process.env を継承することになってるけど、
    // 何故か実行時に process.env を書き換えて追加した環境変数は継承してくれないっぽい (おそらく Node.js のバグ)。
    // - https://nodejs.org/api/child_process.html#child_processexecsynccommand-options
    // 仕方がないので、明示的に上書きしたものを渡してる
    env: {
      ...process.env,
    },
  });
});

また、サンプルコードにも書きましたが、テストの実行前には毎回 DB リセットを挟むと良いです。child_process.execSyns('npx prisma migrate reset --skip-seed')が楽…なのでそうやってますが、プロセスの起動や Node.js のランタイムの初期化分のコストが掛かり、ちょっと遅くて微妙だなあと思っています。@prisma/clientからprisma.migrateReset()みたいな API が生えてればよいのですが… 他の方法があったら教えて下さい。

seed を固定する方法を用意しておく

faker.js を使ってテストデータをランダムにするのは、テストの網羅性という観点では良いですが、確率的に失敗するテストの原因になるという視点ではイマイチです。CI でテストが落ちても、ローカルでその状況を再現できず、デバッグが困難になりがちです。

そこで faker.js の seed を固定する方法を用意しておいて、同じ seed でテストを走らせれば、同じ結果になるようにすると良いです。

// jest.setup.ts
import { faker } from '@faker-js/faker';

// faker.js の seed を表示・固定する
const seed = process.env.FAKER_SEED ? faker.seed(+process.env.FAKER_SEED) : faker.seed();
console.log(`faker's seed: ${seed}`);
$ npm run test
PASS  src/user.service.spec.ts
  ● Console

    console.log
      faker's seed: 4574908056596453

      at Object.<anonymous> (jest.setup.ts:7:9)

PASS  src/post.service.spec.ts
  ● Console

    console.log
      faker's seed: 8259967698580163

      at Object.<anonymous> (jest.setup.ts:7:9)

FAKER_SEED=xxx npm run test で seed を固定できる:

$ FAKER_SEED=4122141554878194 npm run test
PASS  src/user.service.spec.ts
  ● Console

    console.log
      faker's seed: 4122141554878194

      at Object.<anonymous> (jest.setup.ts:7:9)

PASS  src/post.service.spec.ts
  ● Console

    console.log
      faker's seed: 4122141554878194

      at Object.<anonymous> (jest.setup.ts:7:9)

DB を truncate する方法を用意しておく

サンプルコードでは出てきませんでしたが、あるテーブルのレコードを全部引いてくる系の API のテストをしたくなるような時があると思います (PostService#getAllPostsなど)。その場合、既存のレコードがあるとテストが落ちてしまうので、事前にレコードを全部削除しておく必要があります。

そこで id:mizdraPrismaServiceに truncate するメソッドを実装してます。

// src/prisma/prisma.helper.ts
import { Prisma } from '@prisma/client';
import { PrismaService } from './prisma.service.ts';

export class TestablePrismaService extends PrismaService {
  // ...
  async clearDatabase(): Promise<void> {
    // prisma クライアントに生えているモデル一覧
    const modelNames = Prisma.dmmf.datamodel.models.map((model) => model.name) as Prisma.ModelName[];

    await this.$queryRaw`SET FOREIGN_KEY_CHECKS=0`;

    for (const modelName of modelNames) {
      await this.$queryRawUnsafe(`TRUNCATE TABLE ${modelName}`);
    }

    await this.$queryRaw`SET FOREIGN_KEY_CHECKS=1`;
  }
}

export const prisma = new TestablePrismaService(); // テスト用のシングルトン

外部キー制約により削除ができないことがあるので、一時的に外部キー制約を無効化しているのがポイントです。

別解: そもそも他のテストケースのレコードが残らないようにする

@quramy/jest-prisma を使うと、describeitのスコープ内で作成したレコードが、そのスコープから抜けた時に自動で削除されるようになります (技術的にはスコープに入った時に transaction を貼って、抜けた時に rollback してます)。これで他のテストケースのレコードが残ることはほぼ無くなるので、clearDatabaseの代わりになるかもしれません。

github.com

とはいえどうしてもclearDatabaseしないといけない状況はありそう。

おまけ: 自転車置き場の議論のコーナー

mock を使ったテスト vs mock を使わないテスト

  • どっちが世の中的に主流なんですかね
  • id:mizdra 的には基本 mock を使わない書き方のほうが好き
    • mock すれば DB のことは意識しなくて済む
    • しかしテスト対象の API が他のどんな API に依存しているのか、それらからどんな値を返すのが相応しいかを考えないといけない
    • 例えば PostGraphQLResolver のとあるメソッドのテストを mock を使って書きたければ...
      • まずそのメソッドを叩いた時にどんな Service/Repository 層の API が呼び出されるかを調べる必要がある
        • PostRepository#getPostByID が呼び出されるのでそれを mock しようとか
        • PostService#getPostByUser が呼び出されるのでそれを mock しようとか
        • あるいは prisma.post.findUnique が呼び出されるのでそれを mock しようとか
      • mock するもの決まったので mock しようとなっても、どういう返り値を返すのが相応しいかを考える必要がある
        • PostService#getPostByID だったら Prisma.Post 型を返すので、それに合った値を返そうとか
    • 依存関係を調べるのも手間ですし、(ダミーの値を返すだけで良いとはいえ) mock の実装を書くのも少し手間
    • Service/Repository 層のバグが、上位の層に影響して、上位の層のテストが落ちるということもよくあると思うけど、mock を使うとそうしたバグも検出できなくなってしまう
      • 本当は外部キー制約に違反するけど、mock でそれが覆い隠されてしまうとか
  • 逆にどういう時 id:mizdra は mock を使うかというと...
    • 上位の層から作っていきたい時
      • 下位層はインタフェースだけ用意しておいて、実装は空にしておく
      • テストでは下位層を mock して、上位層のテストをする
      • 何らかの事情でトップダウンで開発したい時に便利な手法
    • テスト環境では上手く動かないものの実装をすり替えたい時
      • 外部 API に接続するところとか、認証とか
      • 無理やり mock してテストできるようにするパターン
    • 下位層でテストしていることを、上位層でテストしたくない時
      • PostService#getNewest10PostsというメソッドがPostRepository#getPosts({ limit: 10, order: 'desc' })を呼び出していると仮定
      • PostService#getNewest10Postsのテストは以下を検証するだけにする
        • PostRepository#getPostsを1回呼び出している
        • PostRepository#getPostsに適切な引数を渡している
      • それ以外の振る舞いはPostRepository#getPostsのユニットテストでテストされているのでやらなくて良い、と割り切る
  • id:mizdra の社内でも mock 使わないテストが主流だった
  • 皆さんがどうしているのか知りたい!

追記 (2024-02-23)

mock などを駆使し、テスト対象を他のコンポーネントから切り離してからテストする手法を Solitary Tests、切り離さずテストする手法を Sociable Tests と呼ぶそうです。

martinfowler.com

テストデータをランダム生成する vs ランダム生成しない

  • 記事で述べたように、テストデータをランダムにするのはメリットとデメリットがある
    • Pros: テストの可読性を保ちながら、効率的に問題を検出できる
    • Cons: flaky tests の温床になる
    • Cons: 乱数次第ではバグが検出されず、そのまま本番にリリースされてしまう可能性がある
      • CI で運悪く pass するケースのテストデータでしか検証されず、バグに気づけないとか
    • Cons: Jest のスナップショットテストと相性が悪い
      • ランダムなデータだと毎回差分ができてしまう
  • id:mizdra としては、個々の問題は個別に対応すれば気にはならないのではと思っている
    • seed を固定する方法を用意しておくとか
    • スナップショットテストする時はテストデータを固定すれば良い
    • 運悪く pass してしまうのはどうしようもないけど…そもそも固定方式でテストデータを網羅的に書くのも難しいので、どっこいどっこいな気もする
  • 皆さんがどうしているのか知りたい!

あわせて読みたい

www.mizdra.net

追記 (2022-12-07)

id:Quramy さんが jest-prisma と prisma-fabbrica についての記事を公開されてました。こちらもあわせて読んでみると良いかと思います。

quramy.medium.com

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.