【Prismaとテストシリーズ】jest-prismaでテストケースごとに隔離されたトランザクションで快適にテストできるか試してみた
こんにちは。AWS事業本部モダンアプリケーションコンサルティング部に所属している今泉(@bun76235104)です。
PrismaはTypeScriptでも利用できるORMの一つです。
私もよく利用させていただくのですが、DBが絡むコードのテストの時には以下のようなことを気にしませんか?
- Docker(コンテナ)を使ってモックではなく実際のDBでテストの挙動を確認したい
- ただ普通にPrismaの処理を呼び出すだけでは、当然テストケースごとにデータが増えていく
beforeEach
やafterEach
でテストデータを消す運用は厳しい- そもそも途中でテストがこけると、手動で
prisma migrate reset
などを実行しないといけない
- 私はただテストケースごとのレコード作成や削除を他のテストケースで気にしたくないだけなのに
RSpecをご存知の方には伝わると思うのですが、use_transactional_fixtures
的な機能を使いたいというお話です。
今回はjest-prismaというライブラリを検証してみました。
さっそくまとめ
- jest-prismaでお手軽にテストケースごとに隔離されたトランザクションでテストができる
- 嬉しいポイント
- 他のテストケースで作成・更新したデータは他のテストケースに影響しない
- テストケースの順番やデータの整合性を考える必要はない
- テストが途中で失敗しても「残ってしまったデータを消す」という作業が必要ない
パフォーマンス面での問題などは未検証ですが、とてもお手軽にやりたいことが実現できたので私ももっと使っていきたいと思います。
まずはjestとPrismanのセットアップ
Prismaのスキーマファイルの準備とDBマイグレーション
まずは、Quickstart with TypeScript & SQLite | Prisma Docsの手順に従ってPrismaのセットアップを行います。
今回schema.prisma
は以下のように準備しています。
// This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } datasource db { provider = "mysql" url = env("DATABASE_URL") } model User { id Int @id @default(autoincrement()) email String @unique name String? @@map("users") }
次にDBのマイグレーションを忘れずに実行しておきます。
npx prisma migrate dev
Prismaを利用する関数・クラスを準備
以下のようにPrismaのClientを生成する関数を用意します。
import { PrismaClient } from "@prisma/client"; export const getPrismaClient = (): PrismaClient => { const prisma = new PrismaClient(); return prisma; }
次にいわゆるレイヤードアーキテクチャの永続層に該当しそうなクラスを用意します。
※ PrismaClientを外部から注入しているのが地味にテストをしやすくするポイントとなります。
import { PrismaClient, User } from "@prisma/client"; interface UserCreateInput { name: string; email: string; } export class UsersRepository { private readonly client: PrismaClient; constructor(client: PrismaClient) { this.client = client } async getAllUsers(): Promise<User[]> { return await this.client.user.findMany(); } async createUser(user: UserCreateInput): Promise<User> { return await this.client.user.create({ data: user, }); } }
次に以下のような形でテストを準備してみます。
import { getPrismaClient } from "../../dbClient"; import { UsersRepository } from "./users.repository"; const userRepository = new UsersRepository(getPrismaClient()); describe("UsersRepository", () => { describe("createUser", () => { // Prismaでデータを生成するテストを記載 it("should return a user", async () => { const userInput = { name: "test", email: "test@example.com", }; const result = await userRepository.createUser(userInput) expect(result.email).toEqual(userInput.email); }); }); });
こちらを初回で実行すると、以下のようにテストが成功します。
❯ npx jest PASS src/users/users.repository.spec.ts UsersRepository createUser ✓ should return a user (54 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.124 s Ran all test suites.
しかし、現状だと素直にDBを呼び出して素直にデータを作成しているためusersテーブルには以下のようにデータが残っています。
{ "id": 1, "email": "test@example.com", "name": "test" }
そのため、何も考えずにもう一度テストを実行すると以下のように失敗してしまいます。
❯ npx jest FAIL src/users/users.repository.spec.ts UsersRepository createUser ✕ should return a user (39 ms) ● UsersRepository › createUser › should return a user PrismaClientKnownRequestError: Invalid `this.client.user.create()` invocation in /Users/imanau/workspace/prisma_test/src/users/users.repository.ts:20:35 17 } 18 19 async createUser(user: UserCreateInput): Promise<User> { → 20 return await this.client.user.create( Unique constraint failed on the constraint: `users_email_key`
そこでjest-prismaを導入してみる
GitHubのリポジトリの手順でライブラリをインストールします。
npm i @quramy/jest-prisma -D
また、jest.config.js
とtsconfig.json
に設定を追記します。
module.exports = { preset: "ts-jest", testEnvironment: "@quramy/jest-prisma/environment", // ここを追記 };
{ "compilerOptions": { "types": ["@types/jest", "@quramy/jest-prisma"], } }
次にテストファイル側でもjestPrisma.clientをUsersRepositoryに注入するように変更します。
// import { getPrismaClient } from "../../dbClient"; import { UsersRepository } from "./users.repository"; // jestPrisma.clientを注入する const userRepository = new UsersRepository(jestPrisma.client); describe("UsersRepository", () => { describe("createUser", () => { it("should return a user", async () => { const userInput = { name: "test", email: "test@example.com", }; const result = await userRepository.createUser(userInput) expect(result.email).toEqual(userInput.email); }); }); });
テストを実行する前に残存データを消しておきます。
npx prisma migrate reset
これで準備が完了です。
jest-prismaを使ってテストを実行してみる
テストを何度も実行してみる
テストを実行してみます。
❯ npx jest PASS src/users/users.repository.spec.ts UsersRepository createUser ✓ should return a user (26 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 1.41 s Ran all test suites.
成功しましたので、続け様にもう一度実行します。
❯ npx jest PASS src/users/users.repository.spec.ts UsersRepository createUser ✓ should return a user (24 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 0.806 s, estimated 2 s Ran all test suites.
成功しました!
npx prisma studio
でusersテーブルの中身をテスト後に確認しても、当然データは残っていませんでした。
異なるテストケースで重複するレコードを作成しても問題ないことを確認
次に本来あまり意味がありませんが、テストケースを跨いでユニーク制約のemail
が同じユーザーを作るようにしてみました。
// import { getPrismaClient } from "../../dbClient"; import { UsersRepository } from "./users.repository"; const userRepository = new UsersRepository(jestPrisma.client); describe("UsersRepository", () => { describe("createUser", () => { it("should return a user", async () => { const userInput = { name: "test", email: "test@example.com", }; const result = await userRepository.createUser(userInput) expect(result.email).toEqual(userInput.email); }); // 追加したテストケース it("should return a user2", async () => { const userInput = { name: "test", // ↑上のテストケースと同じemailでユーザーを作成 // データの後処理をせずにPrismaClientを使っていたらエラーがでる部分 email: "test@example.com", }; const result = await userRepository.createUser(userInput) expect(result.email).toEqual(userInput.email); }); }); });
テストを実行してみますが、当然問題ありません。
jest-prismaのREADMEに書いてあるようにテストケースごとに隔離されたトランザクションで実行してくれているようです。
Jest environment for Prisma integrated testing. You can run each test case in isolated transaction which is rolled back automatically.
そのため、以下のようにユーザー情報更新のテストを追加しても当然問題なく動作します。
describe("UsersRepository", () => { describe("createUser", () => { it("should return a user", async () => { const userInput = { name: "test", email: "test@example.com", }; const result = await userRepository.createUser(userInput); expect(result.email).toEqual(userInput.email); }); }); // 追加した部分 describe("updateUser", () => { it("updates user name", async () => { // arrange const testUser = await userRepository.createUser({ name: "test", email: "test@example.com", }); const userInput = { id: testUser.id, name: "changed", }; // act const result = await userRepository.updateuser(userInput); // assert expect(result.name).toEqual(userInput.name); }); }); });
当然以下のようにupdateのテストケースたちで利用するデータをbeforeEachで生成するようにしても、各テストケースでエラーがでることもありません。
describe("updateUser", () => { let testUser: User; beforeEach(async () => { testUser = await userRepository.createUser({ name: "test", email: "test@example.com", }); }); it("updates user name", async () => { // arrange const userInput = { id: testUser.id, name: "changed", }; // act const result = await userRepository.updateuser(userInput); // assert expect(result.name).toEqual(userInput.name); }); // 追加したテスト it("updates user name2", async () => { // この時点では当然変更されていない expect(testUser.name).toEqual("test") // arrange const userInput = { id: testUser.id, name: "changed2", }; // act const result = await userRepository.updateuser(userInput); // assert // ここではちゃんと変更されている expect(result.name).toEqual(userInput.name); }); });
さいごに
とても気軽にDBが絡むテストの環境を整えることができました。
今回利用したソースコードは以下のリポジトリに配置していますので、気になったかたはご覧ください。
次回以降も快適なテスト生活のために検証したいことがあるので、また記事を書いてみたいと思います。
以上、最後までご覧いただきありがとうございました。
今泉でした。