单元测试
单元测试旨在隔离代码的一小部分(单元)并测试其逻辑上可预测的行为。它通常涉及模拟对象或服务器响应来模拟真实世界的行为。单元测试的一些好处包括:
- 快速发现并隔离代码中的错误。
- 通过说明某些代码块应该做什么,为每个代码模块提供文档。
- 有助于衡量重构是否顺利。代码重构后,测试仍应通过。
在 Prisma ORM 的语境下,这通常意味着测试使用 Prisma Client 进行数据库调用的函数。
单个测试应专注于您的函数逻辑如何处理不同的输入(例如 null 值或空列表)。
这意味着您应该尽量消除尽可能多的依赖,例如外部服务和数据库,以使测试及其环境尽可能轻量化。
注意:这篇博客文章提供了在 Express 项目中使用 Prisma ORM 实现单元测试的全面指南。如果您想深入探讨此主题,请务必阅读!
前提条件
本指南假设您已在项目中设置了 JavaScript 测试库 Jest
和 ts-jest
。
模拟 Prisma Client
为了确保您的单元测试与外部因素隔离,您可以模拟 Prisma Client,这意味着您可以利用 schema(类型安全)的好处,而无需在运行测试时对数据库进行实际调用。
本指南将介绍两种模拟 Prisma Client 的方法:单例实例和依赖注入。两者都有其优点,具体取决于您的用例。为了帮助模拟 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'],
}
依赖注入
另一种常用的模式是依赖注入。
-
创建一个
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 对数据库进行调用。
以下所有示例都使用以下 schema 模型
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!')
)
})