ユニットテスト
ユニットテストは、コードの小さな部分 (ユニット) を分離し、論理的に予測可能な動作についてテストすることを目的としています。一般的に、現実世界の動作をシミュレートするために、オブジェクトまたはサーバーの応答をモックすることが含まれます。ユニットテストの利点には、次のようなものがあります。
- コード内のバグを迅速に見つけて特定します。
- 特定のコードブロックが何をすべきかを示すことによって、コードの各モジュールのドキュメントを提供します。
- リファクタリングがうまくいったかどうかの役立つ指標。コードがリファクタリングされた後も、テストは引き続き合格するはずです。
Prisma ORM のコンテキストでは、これは一般的に Prisma Client を使用してデータベース呼び出しを行う関数をテストすることを意味します。
単一のテストは、関数ロジックがさまざまな入力 (null 値や空のリストなど) をどのように処理するかに焦点を当てる必要があります。
これは、テストとその環境を可能な限り軽量に保つために、外部サービスやデータベースなどの依存関係を可能な限り削除することを目指すべきであることを意味します。
注: こちらのブログ記事では、Prisma ORM を使用した Express プロジェクトでユニットテストを実装するための包括的なガイドを提供しています。このトピックを深く掘り下げたい場合は、ぜひ読んでみてください!
前提条件
このガイドは、JavaScript テストライブラリ Jest
と ts-jest
がプロジェクトにすでにセットアップされていることを前提としています。
Prisma Client のモック
ユニットテストを外部要因から分離するために、Prisma Client をモックすることができます。これは、テストの実行時にデータベースへの実際の呼び出しを行うことなく、スキーマ (型安全性) を使用できるという利点を得られることを意味します。
このガイドでは、Prisma Client をモックする 2 つのアプローチ、シングルトンインスタンスと依存性注入について説明します。どちらもユースケースに応じてメリットがあります。Prisma Client のモックを支援するために、jest-mock-extended
パッケージが使用されます。
npm install jest-mock-extended@2.0.4 --save-dev
このガイドの執筆時点では、jest-mock-extended
バージョン ^2.0.4
を使用しています。
シングルトン
次の手順では、シングルトンパターンを使用して Prisma Client をモックする方法を説明します。
-
プロジェクトのルートに
client.ts
というファイルを作成し、次のコードを追加します。これにより、Prisma Client インスタンスがインスタンス化されます。client.tsimport { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma -
次に、プロジェクトのルートに
singleton.ts
という名前のファイルを作成し、次を追加します。singleton.tsimport { 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>
シングルトンファイルは、Jest にデフォルトエクスポート (./client.ts
の Prisma Client インスタンス) をモックするように指示し、jest-mock-extended
の mockDeep
メソッドを使用して、Prisma Client で使用可能なオブジェクトとメソッドへのアクセスを有効にします。次に、各テストの実行前にモックされたインスタンスをリセットします。
次に、setupFilesAfterEnv
プロパティを jest.config.js
ファイルに singleton.ts
ファイルへのパスとともにに追加します。
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/singleton.ts'],
}
依存性注入
使用できるもう 1 つの人気のあるパターンは、依存性注入です。
-
context.ts
ファイルを作成し、次を追加します。context.tsimport { PrismaClient } from '@prisma/client'
import { mockDeep, DeepMockProxy } from 'jest-mock-extended'
export type Context = {
prisma: PrismaClient
}
export type MockContext = {
prisma: DeepMockProxy<PrismaClient>
}
export const createMockContext = (): MockContext => {
return {
prisma: mockDeep<PrismaClient>(),
}
}
Prisma Client のモックを通じて循環依存エラーが発生している場合は、tsconfig.json
に "strictNullChecks": true
を追加してみてください。
-
コンテキストを使用するには、テストファイルで次のようにします。
import { MockContext, Context, createMockContext } from '../context'
let mockCtx: MockContext
let ctx: Context
beforeEach(() => {
mockCtx = createMockContext()
ctx = mockCtx as unknown as Context
})
これにより、createMockContext
関数を介して各テストの実行前に新しいコンテキストが作成されます。この (mockCtx
) コンテキストは、Prisma Client へのモック呼び出しを行い、クエリを実行してテストするために使用されます。ctx
コンテキストは、テスト対象のシナリオクエリを実行するために使用されます。
ユニットテストの例
Prisma ORM のユニットテストの現実世界のユースケースは、サインアップフォームかもしれません。ユーザーがフォームに入力すると、関数が呼び出され、次に Prisma Client を使用してデータベースへの呼び出しが行われます。
以下の例はすべて、次のスキーマモデルを使用しています。
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
acceptTermsAndConditions Boolean
}
次のユニットテストは、次のプロセスをモックします。
- 新しいユーザーの作成
- ユーザー名の更新
- 規約に同意しない場合のユーザー作成の失敗
依存性注入パターンを使用する関数は、コンテキストが注入 (パラメータとして渡される) されますが、シングルトンパターンを使用する関数は、Prisma Client のシングルトンインスタンスを使用します。
import { Context } from './context'
interface CreateUser {
name: string
email: string
acceptTermsAndConditions: boolean
}
export async function createUser(user: CreateUser, ctx: Context) {
if (user.acceptTermsAndConditions) {
return await ctx.prisma.user.create({
data: user,
})
} else {
return new Error('User must accept terms!')
}
}
interface UpdateUser {
id: number
name: string
email: string
}
export async function updateUsername(user: UpdateUser, ctx: Context) {
return await ctx.prisma.user.update({
where: { id: user.id },
data: user,
})
}
import prisma from './client'
interface CreateUser {
name: string
email: string
acceptTermsAndConditions: boolean
}
export async function createUser(user: CreateUser) {
if (user.acceptTermsAndConditions) {
return await prisma.user.create({
data: user,
})
} else {
return new Error('User must accept terms!')
}
}
interface UpdateUser {
id: number
name: string
email: string
}
export async function updateUsername(user: UpdateUser) {
return await prisma.user.update({
where: { id: user.id },
data: user,
})
}
各方法論のテストはかなり似ていますが、違いはモックされた Prisma Client の使用方法です。
依存性注入の例では、コンテキストをテスト対象の関数に渡すだけでなく、モック実装を呼び出すためにも使用します。
シングルトンの例では、シングルトンクライアントインスタンスを使用してモック実装を呼び出します。
import { createUser, updateUsername } from '../functions-without-context'
import { prismaMock } from '../singleton'
test('should create new user ', async () => {
const user = {
id: 1,
name: 'Rich',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
}
prismaMock.user.create.mockResolvedValue(user)
await expect(createUser(user)).resolves.toEqual({
id: 1,
name: 'Rich',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
})
})
test('should update a users name ', async () => {
const user = {
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
}
prismaMock.user.update.mockResolvedValue(user)
await expect(updateUsername(user)).resolves.toEqual({
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
})
})
test('should fail if user does not accept terms', async () => {
const user = {
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: false,
}
prismaMock.user.create.mockImplementation()
await expect(createUser(user)).resolves.toEqual(
new Error('User must accept terms!')
)
})
import { MockContext, Context, createMockContext } from '../context'
import { createUser, updateUsername } from '../functions-with-context'
let mockCtx: MockContext
let ctx: Context
beforeEach(() => {
mockCtx = createMockContext()
ctx = mockCtx as unknown as Context
})
test('should create new user ', async () => {
const user = {
id: 1,
name: 'Rich',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
}
mockCtx.prisma.user.create.mockResolvedValue(user)
await expect(createUser(user, ctx)).resolves.toEqual({
id: 1,
name: 'Rich',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
})
})
test('should update a users name ', async () => {
const user = {
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
}
mockCtx.prisma.user.update.mockResolvedValue(user)
await expect(updateUsername(user, ctx)).resolves.toEqual({
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: true,
})
})
test('should fail if user does not accept terms', async () => {
const user = {
id: 1,
name: 'Rich Haines',
email: 'hello@prisma.io',
acceptTermsAndConditions: false,
}
mockCtx.prisma.user.create.mockImplementation()
await expect(createUser(user, ctx)).resolves.toEqual(
new Error('User must accept terms!')
)
})