SQLite3 + Prisma の環境に Vitest で Unit Test を書く


スタック

  • SQLite3 : DB のテストをしたいが環境を用意するのは面倒なのでファイルベースで動くこれを使う。
  • Prisma : Node.js の ORM ライブラリを使って Unit test をした場合どうなるか確認する。 yarn prisma studio で DB の確認ができるのありがたい。
  • Vitest : Jest 互換の API で書ける unit test framework 。実行が早い。今回はモックを使わず実際にデータを投入するテストを行う。

初期設定

TypeScript のスクリプトを実行できる環境を作っていく。実行には ts-node ではなく esbuild-register を使ってより早く実行できるようにする。
まずはテストを含めず、単純にチュートリアルレベルのことを実行できるようにする。

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgres

$ mkdir prisma-sqlite3
$ cd prisma-sqlite3
$ yarn init -y
$ yarn add -D prisma typescript esbuild esbuild-register @types/node
$ touch index.ts

package.json の scripts は下記のように設定する。

package.json
  "scripts": {
    "start": "node -r esbuild-register index.ts",
  },

tsconfig.json も設定する。チュートリアルと同じ。

tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "esModuleInterop": true
  }
}

prisma の初期設定コマンドを実行する。 prisma ディレクトリが作成され、配下に設定ファイルが配置される。

$ yarn prisma init

生成された設定ファイルを SQLite3 用に書き換える。SQLite3 のデータファイルのパスを定義しているが、実際に作る必要はない。

.env
DATABASE_URL="file:./dev.db"
prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  // SQLite では @db.VarChar(255) はサポートされていないので外す
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
}

model Profile {
  id     Int     @id @default(autoincrement())
  bio    String?
  user   User    @relation(fields: [userId], references: [id])
  userId Int     @unique
}

model User {
  id      Int      @id @default(autoincrement())
  email   String   @unique
  name    String?
  posts   Post[]
  profile Profile?
}

scheme の定義が完了したら下記を実行して  DB にテーブルを作成する。

$ yarn prisma migrate dev --name init

yarn prisma studio を実行するとブラウザで作成したテーブルを確認できる。

実行確認

データを扱う Prisma クライアントライブラリを入れてスクリプトを記述する。

$ yarn add @prisma/client
index.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  await prisma.user.create({
    data: {
      name: "Alice",
      email: "[email protected]",
      posts: {
        create: { title: "Hello World" },
      },
      profile: {
        create: { bio: "I like turtles" },
      },
    },
  });

  const allUsers = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  });
  console.dir(allUsers, { depth: null });
}

main()
  .catch((e) => {
    throw e;
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

ひとまず実行してみる。yarn start でデータ投入結果を表示できる。

テストを書く

ここからが本題。DB の unit test を定義していく。カバレッジも取得するので c8 も導入する。

$ yarn add -D vitest c8
$ touch index.test.ts

テストを書く前に package.json にテスト実行用のスクリプトを記述しておく。今回は DB に実際に投入するのでテスト実行前にテーブルをリセットする prisma migrate reset -f を実行する。

package.json
  "scripts": {
    "start": "node -r esbuild-register index.ts",
    "test": "vitest run",
    "coverage": "vitest run --coverage",
    "db:reset": "prisma migrate reset -f",
    "db:test": "yarn db:reset && yarn test",
    "db:test:coverage": "yarn db:reset && yarn coverage"
  },

index.test.ts テストを書いていく。この際、ひとまず愚直でもいいので最初の 1 回を通す。関数化などはその後に行う。

index.test.ts
import { test, expect } from "vitest";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

test("User does not exist in the initial DB", async () => {
  const users = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  });
  expect(users).toEqual([]);
});

テストを記述して実行。失敗する場合はテーブルの状態を確認する。

$ yarn db:test

成功したら関数として切り出していく。

index.test.ts
export async function findAllUser() {
  const users = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  });

  return users;
}

test("User does not exist in the initial DB", async () => {
  const users = await findAllUser();
  expect(users).toEqual([]);
});

最終的に index.ts に移し、そこから読み込むようにする。後はこれを繰り返してテストパターンを増やしていく。

index.test.ts
import { findAllUser } from "./index";

test("User does not exist in the initial DB", async () => {
  const users = await findAllUser();
  expect(users).toEqual([]);
});

テストケースを増やす

index.ts にて基本的な CRUD パターンを実装する。後述しますが、 deleteUserById() は失敗します。

index.ts
import { PrismaClient, Prisma } from "@prisma/client";

const prisma = new PrismaClient();

export async function createUser(user: Prisma.UserCreateInput) {
  await prisma.user.create({ data: user });
}

export async function findAllUser() {
  const user = await prisma.user.findMany({
    include: {
      posts: true,
      profile: true,
    },
  });

  return user;
}

export async function findUserById(id: number) {
  const user = await prisma.user.findFirst({
    where: { id },
    include: {
      posts: true,
      profile: true,
    },
  });

  return user;
}

export async function updateUserById(id: number, user: Prisma.UserUpdateInput) {
  await prisma.user.update({
    where: { id },
    data: user,
  });
}

export async function deleteUserById(id: number) {
  await prisma.user.delete({ where: { id } });
}

export async function disconnectDB() {
  await prisma.$disconnect();
}

index.test.ts にもテストを書いてく。

index.test.ts
import { describe, expect, test, afterAll } from "vitest";
import { Prisma } from "@prisma/client";
import {
  createUser,
  deleteUserById,
  disconnectDB,
  findAllUser,
  findUserById,
  updateUserById,
} from "./index";

const demoUser: Prisma.UserCreateInput = {
  name: "Alice",
  email: "[email protected]",
  posts: {
    create: { title: "Hello World" },
  },
  profile: {
    create: { bio: "I like turtles" },
  },
};

afterAll(async () => {
  await disconnectDB();
});

test("User does not exist in the initial DB", async () => {
  const users = await findAllUser();
  expect(users).toEqual([]);
});

describe("CRUD user", async () => {
  test("create user", async () => {
    await createUser(demoUser);

    const user = await findUserById(1);
    expect(user?.name).toBe("Alice");
    expect(user?.email).toBe("[email protected]");
    expect(user?.posts[0].title).toBe("Hello World");
    expect(user?.posts[0].published).toBe(false);
    expect(user?.profile?.bio).toBe("I like turtles");
  });

  test("update user", async () => {
    await updateUserById(1, {
      name: "Emma",
      email: "[email protected]",
    });

    const user = await findUserById(1);
    expect(user?.name).toBe("Emma");
    expect(user?.email).toBe("[email protected]");
  });

  test("delete user", async () => {
    await deleteUserById(1);

    const user = await findUserById(1);
    expect(user).toBe(null);
  });
});

yarn db:test で実行。 delete user のテストが失敗する。調べると外部キー制約が原因で失敗している。

修正方針としては色々あるが、 Scheme を見直す方針を取ることにした。

prisma/schema.prisma
model Post {
-  author    User     @relation(fields: [authorId], references: [id])
+  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade)

}

model Profile {
-  user   User    @relation(fields: [userId], references: [id])
+  user   User    @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
}

更新した Scheme を DB に反映させる。 prisma/migrations/ 配下に DB 更新用 SQL が生成されることが確認できる。

$ yarn prisma migrate dev --name update

DB の更新が反映されたので yarn db:test でテストを実行すると delete user のテストも成功する。

まとめ

サンプルコードから外れて自力で unit test を書いた経験がほとんどないのでこれで合っているのか不安だがひとまずできた。

Prisma Client 側でテーブルを DROP する API が見当たらなかったのでテスト実行前に prisma migrate reset を実行するようにしましたが、これより良い方法があったら教えてください。