跳到主要內容

單元測試

單元測試旨在隔離一小部分(單元)程式碼,並針對邏輯上可預測的行為進行測試。它通常涉及模擬物件或伺服器回應,以模擬真實世界的行為。單元測試的一些好處包括:

  • 快速找到並隔離程式碼中的錯誤。
  • 透過指示特定程式碼區塊應執行的操作,為每個程式碼模組提供文件。
  • 有助於衡量重構是否順利。程式碼重構後,測試應仍然通過。

在 Prisma ORM 的背景下,這通常意味著測試一個使用 Prisma Client 進行資料庫呼叫的函式。

單一測試應著重於您的函式邏輯如何處理不同的輸入(例如空值或空列表)。

這表示您應盡可能移除依賴項,例如外部服務和資料庫,以保持測試及其環境盡可能輕量。

注意:這篇部落格文章提供了在您的 Express 專案中使用 Prisma ORM 實作單元測試的完整指南。如果您想深入探討這個主題,請務必閱讀它!

先決條件

本指南假設您的專案中已設定 JavaScript 測試函式庫 Jestts-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。

  1. 在您的專案根目錄建立一個名為 `client.ts` 的檔案,並新增以下程式碼。這將實例化一個 Prisma Client 實例。

    client.ts
    import { PrismaClient } from '@prisma/client'

    const prisma = new PrismaClient()
    export default prisma
  2. 接下來,在您的專案根目錄建立一個名為 `singleton.ts` 的檔案,並新增以下內容

    singleton.ts
    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>

singleton 檔案告知 Jest 模擬預設匯出 (./client.ts 中的 Prisma Client 實例),並使用 `jest-mock-extended` 中的 `mockDeep` 方法來啟用對 Prisma Client 上可用物件和方法的存取。然後,它會在每次測試執行前重設模擬的實例。

接下來,將 `setupFilesAfterEnv` 屬性新增至您的 `jest.config.js` 檔案,並指定 `singleton.ts` 檔案的路徑。

jest.config.js
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/singleton.ts'],
}

依賴注入

另一種可以使用的常見模式是依賴注入。

  1. 建立一個 `context.ts` 檔案並新增以下內容

    context.ts
    import { 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 突顯了循環依賴錯誤,請嘗試將 "strictNullChecks": true 新增至您的 tsconfig.json

  1. 若要使用 context,您可以在您的測試檔案中執行以下操作

    import { MockContext, Context, createMockContext } from '../context'

    let mockCtx: MockContext
    let ctx: Context

    beforeEach(() => {
    mockCtx = createMockContext()
    ctx = mockCtx as unknown as Context
    })

這會在每次測試執行前透過 createMockContext 函式建立新的 context。此 (mockCtx) context 將用於對 Prisma Client 進行模擬呼叫並執行查詢以進行測試。ctx context 將用於執行針對其進行測試的情境查詢。

單元測試範例

單元測試 Prisma ORM 的真實世界使用案例可能是註冊表單。您的使用者填寫表單,該表單呼叫一個函式,而該函式又使用 Prisma Client 來呼叫您的資料庫。

以下所有範例都使用以下 schema 模型

schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
acceptTermsAndConditions Boolean
}

以下單元測試將模擬以下流程:

  • 建立新使用者
  • 更新使用者名稱
  • 如果未接受條款,則無法建立使用者

使用依賴注入模式的函式將把 context 注入(作為參數傳遞)到其中,而使用單例模式的函式將使用 Prisma Client 的單例模式實例。

functions-with-context.ts
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,
})
}
functions-without-context.ts
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 的使用方式。

**依賴注入**範例將 context 傳遞到正在測試的函式,並使用它來呼叫模擬實作。

**單例模式**範例使用單例模式 client 實例來呼叫模擬實作。

__tests__/with-singleton.ts
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!')
)
})
__tests__/with-dependency-injection.ts
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!')
)
})