単体テスト
単体テストは、コードの小さな部分(ユニット)を分離し、論理的に予測可能な動作をテストすることを目的としています。これは一般に、オブジェクトやサーバーの応答をモックして実際の動作をシミュレートすることを含みます。単体テストの利点としては、以下が挙げられます。
- コード内のバグを迅速に発見し、分離する。
- 特定のコードブロックが何をすべきかを示すことで、コードの各モジュールに対するドキュメントを提供する。
- リファクタリングがうまくいったかどうかの有用な指標となる。コードがリファクタリングされた後もテストは合格するはずである。
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で利用可能なオブジェクトとメソッドにアクセスできるようにします。そして、各テストが実行される前にモックされたインスタンスをリセットします。
次に、jest.config.js
ファイルにsetupFilesAfterEnv
プロパティを、singleton.ts
ファイルへのパスと共に記述します。
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/singleton.ts'],
}
依存性注入
もう一つのよく使われるパターンとして、依存性注入があります。
-
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!')
)
})