【Prismaとテストシリーズ】jest-prismaでテストケースごとに隔離されたトランザクションで快適にテストできるか試してみた

「前のテストケースで追加されたレコードのせいで他のテストが失敗した」、「自分でレコードのクリーンアップをするのは厳しい」、「いい感じにテストケースごとにDBのトランザクションはる方法がないかな」というモチベーションで試してみました。Rspecで表現すると `use_transactional_fixtures` に該当すると思います。
2023.09.28

こんにちは。AWS事業本部モダンアプリケーションコンサルティング部に所属している今泉(@bun76235104)です。

PrismaはTypeScriptでも利用できるORMの一つです。

私もよく利用させていただくのですが、DBが絡むコードのテストの時には以下のようなことを気にしませんか?

  • Docker(コンテナ)を使ってモックではなく実際のDBでテストの挙動を確認したい
  • ただ普通にPrismaの処理を呼び出すだけでは、当然テストケースごとにデータが増えていく
    • beforeEachafterEach でテストデータを消す運用は厳しい
    • そもそも途中でテストがこけると、手動で prisma migrate reset などを実行しないといけない
  • 私はただテストケースごとのレコード作成や削除を他のテストケースで気にしたくないだけなのに

RSpecをご存知の方には伝わると思うのですが、use_transactional_fixtures的な機能を使いたいというお話です。

今回はjest-prismaというライブラリを検証してみました。

さっそくまとめ

  • jest-prismaでお手軽にテストケースごとに隔離されたトランザクションでテストができる
  • 嬉しいポイント
    • 他のテストケースで作成・更新したデータは他のテストケースに影響しない
    • テストケースの順番やデータの整合性を考える必要はない
    • テストが途中で失敗しても「残ってしまったデータを消す」という作業が必要ない

パフォーマンス面での問題などは未検証ですが、とてもお手軽にやりたいことが実現できたので私ももっと使っていきたいと思います。

まずはjestとPrismanのセットアップ

Prismaのスキーマファイルの準備とDBマイグレーション

まずは、Quickstart with TypeScript & SQLite | Prisma Docsの手順に従ってPrismaのセットアップを行います。

今回schema.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を生成する関数を用意します。

dbClient.ts

import { PrismaClient } from "@prisma/client";

export const getPrismaClient = (): PrismaClient => {
    const prisma = new PrismaClient();
    return prisma;
}

次にいわゆるレイヤードアーキテクチャの永続層に該当しそうなクラスを用意します。

※ PrismaClientを外部から注入しているのが地味にテストをしやすくするポイントとなります。

src/users/users.repository.ts

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,
    });
  }
}

次に以下のような形でテストを準備してみます。

src/users/users.repository.spec.ts

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.jstsconfig.jsonに設定を追記します。

jest.config.js

module.exports = {
  preset: "ts-jest",
  testEnvironment: "@quramy/jest-prisma/environment", // ここを追記
};

tsconfig.json

{
  "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が同じユーザーを作るようにしてみました。

src/users/users.repository.spec.ts

// 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が絡むテストの環境を整えることができました。

今回利用したソースコードは以下のリポジトリに配置していますので、気になったかたはご覧ください。

次回以降も快適なテスト生活のために検証したいことがあるので、また記事を書いてみたいと思います。

以上、最後までご覧いただきありがとうございました。

今泉でした。