摘要:在现代网络开发中,端到端测试对于确保应用程序按预期运行至关重要。当使用像 React Server Components 这样的新功能时,我们通常依赖第三方 API 在服务器端获取数据。虽然这种方法在性能和可扩展性方面提供了显著优势,但它也为测试带来了挑战。实
在现代网络开发中,端到端测试对于确保应用程序按预期运行至关重要。当使用像 React Server Components 这样的新功能时,我们通常依赖第三方 API 在服务器端获取数据。虽然这种方法在性能和可扩展性方面提供了显著优势,但它也为测试带来了挑战。实时 API 响应可能会随着时间的推移而变化,即使应用程序逻辑仍然正确,也可能导致测试失败。
在本文中,我将介绍一种新的服务器端 API 模拟方法,这种方法可以让测试快速且可靠,且设置工作量最小。在技术栈方面,我将使用Playwright 和 Next.js,尽管这种方法适用于任何框架或测试运行器。让我们开始吧!
问题:测试服务器端数据获取
考虑服务器组件,它从第三方 API 获取并渲染用户列表:
export async function UserList { const res = await fetch('https://jsonplaceholder.typicode.com/users'); const users = await res.json; return ( {users.map((user) => ( {user.name} ))} );}当浏览器请求此页面时,服务器会向 `/users` API 发起后续调用并返回渲染后的 HTML:
浏览器中的页面:
一个基本的 Playwright 测试可能如下所示:
test('show user list', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('listitem').first).toHaveText('Leanne Graham');});如果 API 响应中的第一个条目是 Leanne Graham,此测试将通过。但如果 API 返回不同的顺序或数据,即使应用程序本身运行正常,测试也会失败。
当元素以相反的顺序返回时,测试失败的示例:
如果测试能够模拟 `GET /users` 请求并提供一个静态的用户列表,测试将更加可靠:
当从浏览器上下文中发起请求时,Playwright 的 API 模拟功能可以正常工作。然而,这种方法无法拦截服务器端请求。
现有方法
人们已经尝试了多种方法来解决这一挑战:
Playwright 代理方法
Playwright 仓库中正在进行的拉取请求通过运行一个与测试进程并行的 HTTP 代理来引入服务器端模拟。应用程序被配置为将传出请求通过此代理路由。如果请求匹配指定的 URL 模式,用户定义的处理程序将应用模拟响应。
尽管这种方法非常灵活,但它也带来了挑战。例如,如果你的应用程序部署在像 Vercel 这样的平台上,测试在 GitHub 工作流中运行,那么设置隧道以连接应用程序和代理可能会非常复杂且容易出错。
Mock Service Worker (MSW)
MSW 是一个流行的用于模拟 HTTP 请求的工具。它也在开发服务器端模拟支持,如这个拉取请求所示。与使用 HTTP 代理不同,MSW 依赖 WebSocket 作为传输层来解决连接问题:
然而,这种方法也有其自身的局限性,如拉取请求中所指出的:
> 你不能同时对同一个应用程序进行多个测试,这些测试会覆盖相同请求的处理程序。
这意味着具有服务器端模拟的测试不能并行运行,这对于端到端测试来说是一个重大缺点。
总的来说,现有方法旨在使用任意函数作为模拟请求处理程序,但引入了连接和并行化的挑战。
提出的解决方案
在尝试这些解决方案时,我得出了一个更简单的想法:
如果我们通过自定义 HTTP 标头在导航请求中传递模拟数据会怎样?
它的工作原理
- 嵌入模拟数据:而不是将服务器端请求通过外部代理路由,我们将静态模拟响应编码为 JSON 并附加到自定义标头(例如 `x-mock-request`)中。
- 服务器端解析:在服务器端,我们拦截传出的 API 调用,读取自定义标头,并在请求匹配预定义模式时应用相应的模拟。
这种方法解决了连接和并行化的问题:
- 无需设置隧道或启动单独的代理服务器。
- 每个测试都可以通过 HTTP 标头传递自己的模拟数据,而不会发生冲突。
当然,这种方法也有局限性:
1. 仅支持静态数据:模拟必须能够序列化为 JSON。这意味着你只能提供静态响应(例如 `{ status: 200, body: 'Hello' }`),而不是基于函数的动态模拟。
2. 标头大小限制:HTTP 标头通常支持 4KB 到 8KB 的数据。这种方法最适合小负载。
在许多实际场景中,这些限制是可以接受的。大多数模拟都是轻量级且静态的,这使得这种方法成为确保测试稳定性的实用解决方案。
实现
以下是使用 Playwright 和 Next.js 实现此解决方案的逐步指南。
定义模式
首先,定义请求和响应的模式。例如,要模拟对 `https://jsonplaceholder.typicode.com/users` 的服务器端 GET 请求,你可以设置如下:
请求模式:
const reqSchema = { method: 'GET', url: 'https://jsonplaceholder.typicode.com/users',};响应模式:
const resSchema = { status: 200, body: [ { id: 1, name: 'John Smith' } ]};合并模式并构建标头:
const mockSchema = { reqSchema, resSchema };const mockSchemaString = JSON.stringify(mockSchema);const headers = { 'x-mock-request': mockSchemaString};Playwright 集成
要将自定义 HTTP 标头附加到导航请求中,请使用 Playwright 的 page.setExtraHTTPHeaders:
test('show user list', async ({ page }) => { await page.setExtraHTTPHeaders({ 'x-mock-request': mockSchemaString }); await page.goto('/');});有了此配置,页面的每次导航和后续请求都将包含模拟标头。
在服务器端处理
在服务器端,需要执行以下步骤:
1. 读取传入标头
2. 获取 `x-mock-request` 值并提取模拟模式
3. 拦截传出请求
4. 应用模拟模式并返回模拟响应
读取传入标头并提取模式
要读取传入标头,你可以使用 Next.js 的 `headers` 辅助函数。找到 `x-mock-request` 标头时,使用 `JSON.parse` 提取模拟模式:
import { headers } from 'next/headers';// ...const headersList = await headers;const mockHeader = headersList.get('x-mock-request');const mockSchemas = JSON.parse(mockHeader);拦截传出请求
要在 Next.js 应用程序中拦截所有传出请求,你可以覆盖 `globalThis.fetch` 函数:
const originalFetch = globalThis.fetch;globalThis.fetch = async (input, init) => { // 检查并可能模拟传出请求};在拦截的函数中,你可以读取传入标头并应用模拟。完整函数代码如下:
function interceptGlobalFetch { const originalFetch = globalThis.fetch; globalThis.fetch = async (input, init) => { // 读取传入标头并提取模拟 const headersList = await headers; const mockHeader = headersList.get('x-mock-request'); const mockSchemas = JSON.parse(mockHeader); // 将请求与模式匹配 const request = new Request(input, init); const matchedSchema = mockSchemas.find(schema => matchRequest(request, schema)); // 返回模拟响应或发起真实请求 return matchedSchema ? buildMockedResponse(request, matchedSchema) : originalFetch(request) };}应在服务器启动时对全局 `fetch` 进行工具化,且在发出任何请求之前。Next.js 为此任务提供了一个专用文件,名为 instrumentation.js:
// instrumentation.jsexport async function register { if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NODE_ENV !== 'production') { interceptGlobalFetch; }}> **注意**:仅应在 `nodejs` 运行时和非生产环境中启用拦截。
测试整个流程
一旦服务器端拦截就绪,你就可以运行带有服务器端模拟的 Playwright 测试。以下是一个示例:
test('show user list', async ({ page }) => { // 设置服务器端模拟 await page.setExtraHTTPHeaders({ 'x-mock-request': buildMockHeader }); // 导航到页面 await page.goto('/'); // 根据模拟数据断言页面内容 await expect(page.getByRole('listitem').first).toHaveText('John Smith');});`buildMockHeader` 辅助函数只是合并请求和响应模式:
function buildMockHeader { const reqSchema = { method: 'GET', url: 'https://jsonplaceholder.typicode.com/users', }; const resSchema = { status: 200, body: [ { id: 1, name: 'John Smith' } ] }; return JSON.stringify([ { reqSchema, resSchema } ]);}运行测试:
> npx playwright testRunning 1 test using 1 worker 1 passed (1.3s)页面的截图显示了一个带有模拟数据的列表 - 单个用户 `John Smith`:
有了这样的模拟,测试不再依赖 API 响应,同时确保服务器组件正确渲染数据。
封装为库
为了减少服务器端模拟的样板代码,我将功能封装到一个名为 request-mocking-protocol 的独立包中。它隐藏了实现细节,并为在客户端和服务器端设置模拟提供了友好的 API。
使用库的示例
以下示例展示了如何在 Playwright 测试中使用该库:
test('show user list', async ({ page, mockServerRequest }) => { // 设置服务器端模拟 await mockServerRequest.GET('https://jsonplaceholder.typicode.com/users', { body: [{ id: 1, name: 'John Smith' }], }); // 导航到页面 await page.goto('/'); // 根据模拟数据断言页面内容 await expect(page.getByRole('listitem').first).toHaveText('John Smith');});自定义 fixture `mockServerRequest` 定义如下:
import { test as base } from '@playwright/test';import { MockClient } from 'request-mocking-protocol';export const test = base.extend({ mockServerRequest: async ({ context }, use) => { const mockClient = new MockClient; mockClient.onChange = async (headers) => context.setExtraHTTPHeaders(headers); await use(mockClient); },});在服务器端,你可以通过调用 `setupFetchInterceptor` 来设置拦截器:
// instrumentation.jsimport { headers } from 'next/headers';export async function register { if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NODE_ENV !== 'production') { const { setupFetchInterceptor } = await import('request-mocking-protocol/fetch'); setupFetchInterceptor( => headers); }}总结
在本文中,我介绍了一种替代的服务器端请求模拟方法,该方法使用 HTTP 标头传输模拟数据。这种设置更简单,因为它消除了对额外代理的需求。每个测试都携带自己的模拟数据,允许并行执行和更好的可扩展性。
这种方法确实有一些局限性。它仅支持静态模拟 —— 不允许使用任意 JavaScript 函数。此外,HTTP 标头有大小限制,这使得该方法最适合较小的负载。
尽管存在这些权衡,但该解决方案看起来很有希望。我已将其封装到一个库中,以便更容易地与不同框架集成。欢迎你尝试并分享反馈。
AI测试涨薪交流群,内含银行业务、车载、互联网、游戏更多行业测试实战和面试题库 &【软件测试专用AI提示词包】等各种好用的
来源:啊将登胡