👈

prisma-mockを救いたい

2023/03/06に公開

皆さんPrismaは活用していますか?
\ハーイ/
そうですね、もちろん大いに活用していることかと思われます。

そんな便利ライブラリPrismaですがテストを書こうとなるとちょっと困ったことになります。
今回はprisma-mockの紹介とPRを投げた話について書いてみようと思います。

Prismaでテストをしようと思うときに困ること

  1. データを用意するのが面倒くさい
  2. Prismaが返すデータをMockImplementationしなければいけない
  3. ローカルでテスト用のDBを建てるのが面倒くさい
  4. DBの実機にデータを投げてテストをすると時間がかかる
    ですね

データを用意するのが面倒くさい

@quramy/prisma-fabbrica@faker-js/faker を使って頑張りましょう……
https://github.com/Quramy/prisma-fabbrica
https://github.com/faker-js/faker

Prismaが返すデータをMockImplementationしなければいけない

一応公式曰く

import { PrismaClient } from '@prisma/client'
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'

import prisma from './client'

jest.mock('./client', () => ({
  __esModule: true,
  default: mockDeep<PrismaClient>(),
}))

beforeEach(() => {
  mockReset(prismaMock)
})

export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>

のようにPrismaのインスタンスに対してMock作ってねという事らしいですが
複数のテーブルが絡んだテストを書こうとか、何件かレコードがある状態のテストを書こうとかするといちいち mockImplementation やら mockImplementationOnce やらで返り値を書いて~とかやらないといけなくなります。(なんか他にいい方法あったりするのだろうか?)

ローカルでテスト用のDBを建てるのが面倒くさい

それはそう

DBの実機にデータを投げてテストをすると時間がかかる

それはそう

prisma-mock

ということで234も解決してくれる、そんなライブラリが prisma-mock です
prisma-mockの特徴としては、 インメモリでPrismaの挙動をシミュレーションする です。
この時点でわざわざ返り値を mockImplementation で実装しなくてもいいし、インメモリなのでテスト実行時間が短くて済みます。あら素敵✨

実際これをどう使うかというと、以下の様にJestの準備段階でPrismaのインスタンスをprisma-mockのインスタンスに差し替えるだけで済みます。お手軽!!

import createPrismaMock from "prisma-mock"

let client

beforeEach(() => {
  client = createPrismaMock()
})

あとはテストデータを何とか用意して戻り値を確認すればテストの出来上がりです。すばらしい✌💪
自分はこれを実際のコードにContextとして引き回してDIしています。

複合Unique制約を使ったconnectがエラーになる

こんな素晴らしいライブラリですが、実はまだ開発中でして一部の機能が実装されていなかったりします。
自分のケースでは

  • 接続先のレコードが既に存在している
  • 新しいレコードを作成する際に複合Unique Indexを介して既存レコードに接続したい
    という複合条件の際にコケてしまいました。
    具体的には以下のようなスキーマがあり、Petレコードに複合Indexのname_ownerIdを介してToyを接続したい場合に発生します
model Pet {
  id      Int    @id @default(autoincrement())
  name    String
  owner   User   @relation(fields: [ownerId], references: [id])
  ownerId Int
  has     Toy[]

  @@unique([name, ownerId])
}

model Toy {
  id      Int    @id @default(autoincrement())
  name    String
  owner   Pet?   @relation(fields: [ownerId], references: [id])
  ownerId Int
}

実行側のコードとしては以下の様になります。

    const client = await createPrismaClient({})
    const user = await client.user.create({
      data: {
        role: "USER",
        name: "Bob",
        uniqueField: "user",
        pets: {
          create: {
            name: "John",
          },
        },
      },
    })
    const toy = await client.toy.create({
      data: {
        name: "Ball",
        owner: {
          connect: {
            name_ownerId: {
              name: "John",
              ownerId: 1,
            },
          },
        },
      },
    })

これを実行すると以下の部分でエラーが発生してしまいます。

src/index.ts
              connections.forEach((connect, idx) => {
                const keyToMatch = Object.keys(connect)[0]

                const keyToGet = field.relationToFields[0]
                const targetKey = field.relationFromFields[0]
                if (keyToGet && targetKey) {
                  let connectionValue = connect[keyToGet]
                  if (keyToMatch !== keyToGet) {
                    const valueToMatch = connect[keyToMatch]
                    const matchingRow = data[getCamelCase(field.type)].find(
                      (row) => {
                        return row[keyToMatch] === valueToMatch
                      }
                    )
                    if (!matchingRow) {
                      throwUnkownError(
                        "An operation failed because it depends on one or more records that were required but not found. {cause}"
                      )
                    }
                    connectionValue = matchingRow[keyToGet]

原因としてはconnectに指定されたname_ownerIdをそのままカラム名としてルックアップ仕様としてしまっている為なので、これを以下の様に修正します。

src/index.ts
              connections.forEach((connect, idx) => {
                const keyToMatch = Object.keys(connect)[0]

                const keyToGet = field.relationToFields[0]
                const targetKey = field.relationFromFields[0]
                if (keyToGet && targetKey) {
                  let connectionValue = connect[keyToGet]
                  if (keyToMatch !== keyToGet) {
                    const valueToMatch = connect[keyToMatch]
                    let matchingRow = data[getCamelCase(field.type)].find(
                      (row) => {
                        return row[keyToMatch] === valueToMatch
                      }
                    )
                    if (!matchingRow) {
                      const refModel = datamodel.models.find(
                        (model) =>
                          getCamelCase(field.type) === getCamelCase(model.name)
                      )
                      const uniqueIndexes = refModel.uniqueIndexes.map(
                        (index) => {
                          return {
                            ...index,
                            key: index.name ?? index.fields.join("_"),
                          }
                        }
                      )
                      const indexKey = uniqueIndexes.find(
                        (index) => index.key === keyToMatch
                      )
                      matchingRow = data[getCamelCase(field.type)].find(
                        (row) => {
                          const target = Object.fromEntries(
                            Object.entries(row).filter(
                              (row) =>
                                indexKey?.fields.includes(row[0]) ?? false
                            )
                          )
                          return shallowCompare(target, valueToMatch)
                        }
                      )
                      if (!matchingRow) {
                        throwUnkownError(
                          "An operation failed because it depends on one or more records that were required but not found. {cause}"
                        )
                      }
                    }
                    connectionValue = matchingRow[keyToGet]

やっていることとしては単純にルックアップが失敗した後にインデックスを拾ってきて名前が一致したらそれで拾ってくるだけですね

この修正はPRを出しておきました(めちゃくちゃざっくり書いたので通るだろうか…)

おわりに

まだまだ開発途中ではあるのですがこれが十分使えるようになるとPrismaのテストがめちゃくちゃ楽になるので今後も注視していきたいですね。

Discussion