DBMS に依存するロジックのテストを書く時、主に2つの手法があると思います。
- Repository 層などを mock する
- Service 層のテストをする時は、その下位の Repository 層を mock して、DBMS に依存しない形にしてからテストする
- レイヤードなアプリケーションで適用できる手法
- テスト実行時も DBMS を裏で動かして、それを使う
- 本番と同じスキーマを持つ DBMS に対して、実際に insert したり select してテストする
- DBMS は
docker-compose up
とかで事前に立ち上げておく
双方にそれぞれ良さがあって、プロダクトによってどっちでやるか変わってくると思います。
この記事では 2 の手法を Prisma でどうやるかについて紹介します。
- 前提
- 実際のテストコードの例
- テストヘルパーを作る
- jest worker ごとに database を分ける
- seed を固定する方法を用意しておく
- DB を truncate する方法を用意しておく
- おまけ: 自転車置き場の議論のコーナー
- あわせて読みたい
- 追記 (2022-12-07)
前提
- 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 もちゃんと扱えます。これ使えば事足りそうな気がします。
他にも node_modules/.prisma/client/index.d.ts
を元に TypeScript Compiler API を使って自動生成するアプローチもあるようです。
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:mizdra はPrismaService
に 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
を使うと、describe
やit
のスコープ内で作成したレコードが、そのスコープから抜けた時に自動で削除されるようになります (技術的にはスコープに入った時に transaction を貼って、抜けた時に rollback してます)。これで他のテストケースのレコードが残ることはほぼ無くなるので、clearDatabase
の代わりになるかもしれません。
とはいえどうしても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
型を返すので、それに合った値を返そうとか
- まずそのメソッドを叩いた時にどんな Service/Repository 層の API が呼び出されるかを調べる必要がある
- 依存関係を調べるのも手間ですし、(ダミーの値を返すだけで良いとはいえ) 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 と呼ぶそうです。
テストデータをランダム生成する vs ランダム生成しない
- 記事で述べたように、テストデータをランダムにするのはメリットとデメリットがある
- Pros: テストの可読性を保ちながら、効率的に問題を検出できる
- Cons: flaky tests の温床になる
- Cons: 乱数次第ではバグが検出されず、そのまま本番にリリースされてしまう可能性がある
- CI で運悪く pass するケースのテストデータでしか検証されず、バグに気づけないとか
- Cons: Jest のスナップショットテストと相性が悪い
- ランダムなデータだと毎回差分ができてしまう
- id:mizdra としては、個々の問題は個別に対応すれば気にはならないのではと思っている
- seed を固定する方法を用意しておくとか
- スナップショットテストする時はテストデータを固定すれば良い
- 運悪く pass してしまうのはどうしようもないけど…そもそも固定方式でテストデータを網羅的に書くのも難しいので、どっこいどっこいな気もする
- 皆さんがどうしているのか知りたい!
あわせて読みたい
追記 (2022-12-07)
id:Quramy さんが jest-prisma と prisma-fabbrica についての記事を公開されてました。こちらもあわせて読んでみると良いかと思います。