5月28日 09:30

GraphQL 测试有哪些策略和最佳实践

测试金字塔:GraphQL 测试的分层思路

面试中回答 GraphQL 测试问题,不要上来就列工具,先讲清楚测试金字塔的分层逻辑:单元测试打底,集成测试验证核心链路,E2E 测试覆盖关键用户流程。GraphQL 的特殊性在于 Resolver 是天然可隔离的单元,Schema 是集成测试的契约,订阅(Subscription)则需要专门的实时性测试策略。这个分层思路适用于任何 GraphQL 项目的自动化测试流程搭建。

单元测试:Resolver 级别的逻辑验证

Resolver 是 GraphQL 的核心,每个 Resolver 函数接收 parentargscontext 三个参数,返回数据。单元测试的重点是验证 Resolver 在不同输入下的返回值和异常处理,不依赖数据库和外部服务。

typescript
import { describe, it, expect, vi } from 'vitest'; import { userResolvers } from './user.resolver'; describe('Query.user', () => { it('根据 id 返回用户', async () => { const mockUser = { id: '1', name: 'John', email: 'john@example.com' }; vi.spyOn(User, 'findById').mockResolvedValue(mockUser); const result = await userResolvers.Query.user(null, { id: '1' }, {}); expect(result).toEqual(mockUser); }); it('用户不存在时抛出错误', async () => { vi.spyOn(User, 'findById').mockResolvedValue(null); await expect( userResolvers.Query.user(null, { id: '999' }, {}) ).rejects.toThrow('User not found'); }); });

Mutation 的测试思路相同——mock 数据层,验证 Resolver 是否正确调用创建/更新方法并返回预期结果。下面是一个创建用户的典型测试:

typescript
describe('Mutation.createUser', () => { it('创建新用户并返回完整数据', async () => { const input = { name: 'John Doe', email: 'john@example.com' }; const createdUser = { id: '1', ...input }; vi.spyOn(User, 'create').mockResolvedValue(createdUser); const result = await userResolvers.Mutation.createUser(null, { input }, {}); expect(result).toEqual(createdUser); expect(User.create).toHaveBeenCalledWith(input); }); it('邮箱已存在时拒绝创建', async () => { vi.spyOn(User, 'findByEmail').mockResolvedValue({ id: '2', email: 'exists@example.com' }); await expect( userResolvers.Mutation.createUser(null, { input: { name: 'Test', email: 'exists@example.com' } }, {}) ).rejects.toThrow('Email already exists'); }); });

单元测试的覆盖率目标建议设为 80% 以上。Vitest 和 Jest 都支持 --coverage 参数生成覆盖率报告,在 CI/CD 中可以设置覆盖率门槛阻止合并。

集成测试:验证 Schema 到 Resolver 的完整链路

单元测试无法发现 Schema 定义和 Resolver 实现之间的不一致。集成测试通过构造真实的 GraphQL 请求,验证 Query、Mutation 在完整的 Schema 下是否按预期工作。这是 GraphQL 自动化测试中投入产出比最高的层级。

typescript
import { describe, it, expect } from 'vitest'; import { createYoga } from 'graphql-yoga'; import { schema } from './schema'; describe('集成测试', () => { const yoga = createYoga({ schema }); it('查询用户列表', async () => { const response = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `query { users { id name email } }` }) }); const { data, errors } = await response.json(); expect(errors).toBeUndefined(); expect(data.users).toBeInstanceOf(Array); }); it('Mutation 创建用户后可查询到', async () => { const createRes = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } }`, variables: { input: { name: 'Jane', email: 'jane@example.com' } } }) }); const { data: createData } = await createRes.json(); expect(createData.createUser.name).toBe('Jane'); // 验证创建后可查询 const queryRes = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `query { users { id name } }` }) }); const { data: queryData } = await queryRes.json(); expect(queryData.users.some(u => u.name === 'Jane')).toBe(true); }); });

面试加分点:提到集成测试应该覆盖 Schema 变更兼容性——新增字段不能破坏已有查询,删除字段必须走 @deprecated 废弃流程,而非直接移除。可以在 CI/CD 中加入 Schema diff 检查,自动拦截破坏性变更。

E2E 测试:端到端用户流程验证

E2E 测试模拟真实客户端的完整操作链路。GraphQL 的 E2E 重点在验证多步 Mutation 的数据一致性——创建资源后立即查询是否可见,权限变更后是否立即生效。

typescript
describe('用户注册登录流程', () => { it('注册后可登录获取 token', async () => { const regRes = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `mutation Register($input: RegisterInput!) { register(input: $input) { id email } }`, variables: { input: { email: 'test@example.com', password: 'Pass123!' } } }) }); const { data: regData } = await regRes.json(); expect(regData.register.email).toBe('test@example.com'); const loginRes = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { token user { id } } }`, variables: { email: 'test@example.com', password: 'Pass123!' } }) }); const { data: loginData } = await loginRes.json(); expect(loginData.login.token).toBeDefined(); }); });

E2E 测试成本高、速度慢,只覆盖最关键的业务流程即可,不要追求全面。一般 3-5 条 E2E 用例就能覆盖核心链路。

Context 测试:认证与数据源

GraphQL 的 Context 是请求级别的共享对象,承载认证信息和数据源。测试重点:认证拦截数据源注入

typescript
describe('Context 认证测试', () => { it('已认证用户可访问 me 查询', async () => { const context = { user: { id: '1', name: 'John' } }; const result = await resolvers.Query.me(null, {}, context); expect(result.id).toBe('1'); }); it('未认证访问 me 抛出错误', async () => { await expect( resolvers.Query.me(null, {}, { user: null }) ).rejects.toThrow('Authentication required'); }); });

数据源注入的测试关注 Resolver 是否正确调用了 Context 中的 API:

typescript
describe('数据源 Context 测试', () => { it('Resolver 通过 Context 数据源获取数据', async () => { const mockUserAPI = { getUser: vi.fn().mockResolvedValue({ id: '1', name: 'John' }) }; const context = { dataSources: { userAPI: mockUserAPI } }; await resolvers.Query.user(null, { id: '1' }, context); expect(mockUserAPI.getUser).toHaveBeenCalledWith('1'); }); });

面试追问:Context 放什么、不放什么?——放用户身份、数据源实例、日志追踪 ID;不放请求敏感信息,不放可变状态。

错误处理测试:验证错误格式与边界

GraphQL 的错误处理和 REST 不同——即使出错,HTTP 状态码也是 200,错误信息放在 errors 数组里。测试要覆盖三类错误:

  • 业务错误:Resolver 主动抛出的逻辑错误(如"用户不存在")
  • 验证错误:输入不满足 Schema 类型约束(如邮箱格式不对)
  • 运行时错误:数据库连接断开等未预期异常
typescript
describe('错误处理', () => { it('查询不存在的资源返回结构化错误', async () => { vi.spyOn(User, 'findById').mockResolvedValue(null); const response = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `query { user(id: "999") { id name } }` }) }); const { errors } = await response.json(); expect(errors[0].message).toContain('not found'); expect(errors[0].extensions?.code).toBe('NOT_FOUND'); }); it('输入验证失败返回明确错误', async () => { const response = await yoga.fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: `mutation { createUser(input: { name: "A", email: "bad" }) { id } }` }) }); const { errors } = await response.json(); expect(errors[0].message).toContain('Invalid email'); }); });

实际项目中建议统一错误格式,用 extensions.code 区分错误类型,方便客户端做差异化处理。可以在 Apollo Server 或 graphql-yoga 中通过自定义错误格式化函数统一处理。

订阅测试:实时数据推送的验证

Subscription 的测试比 Query/Mutation 复杂,涉及异步事件流。核心验证两点:事件是否正确推送过滤条件是否生效

typescript
import { PubSub } from 'graphql-subscriptions'; describe('Subscription 测试', () => { it('postCreated 事件正确推送', async () => { const pubsub = new PubSub(); const iterator = pubsub.asyncIterator('POST_CREATED'); const mockPost = { id: '1', title: 'New Post' }; setTimeout(() => pubsub.publish('POST_CREATED', { postCreated: mockPost }), 10); const result = await iterator.next(); expect(result.value.postCreated).toEqual(mockPost); }); it('按 userId 过滤订阅事件', async () => { const pubsub = new PubSub(); // 带过滤的 withFilter 用法 const iterator = withFilter( () => pubsub.asyncIterator('NOTIFICATION'), (payload, variables) => payload.notification.userId === variables.userId )({}, { userId: '1' }); // 只推送匹配的事件 pubsub.publish('NOTIFICATION', { notification: { userId: '1', message: 'Hello' } }); pubsub.publish('NOTIFICATION', { notification: { userId: '2', message: 'Ignored' } }); const result = await iterator.next(); expect(result.value.notification.userId).toBe('1'); }); });

如果项目用 WebSocket 传输订阅,还需要测试连接断开重连、消息顺序等边界情况。

性能测试:N+1 查询检测与 DataLoader

GraphQL 最常见的性能坑是 N+1 查询——一个列表查询触发 N 次关联查询。检测方法:mock 数据源并统计调用次数。

typescript
describe('N+1 查询检测', () => { it('查询帖子列表不应产生 N+1 查询', async () => { const users = Array.from({ length: 10 }, (_, i) => ({ id: String(i), name: `User${i}` })); const posts = users.map((u, i) => ({ id: String(i), title: `Post${i}`, authorId: u.id })); vi.spyOn(Post, 'findAll').mockResolvedValue(posts); const userFindById = vi.spyOn(User, 'findById').mockImplementation( (id) => Promise.resolve(users.find(u => u.id === id)) ); await resolvers.Query.posts(null, {}, {}); expect(userFindById.mock.calls.length).toBeLessThan(10); }); });

解决 N+1 的标准方案是 DataLoader,面试必答。DataLoader 的原理:利用事件循环在同一次 tick 内收集所有 id,合并为一次批量查询。具体实现是为每个请求创建一个 DataLoader 实例,放在 Context 中传递:

typescript
import DataLoader from 'dataloader'; const userLoader = new DataLoader(async (ids: string[]) => { const users = await User.findByIds(ids); return ids.map(id => users.find(u => u.id === id)); }); // 在 Context 中注入 const context = () => ({ userLoader: new DataLoader(/* ... */) });

大数据量场景还可以用 Artillery 或 k6 做压力测试,模拟并发请求检测响应时间和资源消耗。

安全测试:容易被忽略的必考项

GraphQL 的灵活性也是安全风险的来源,面试中经常被追问。三个重点:

查询深度限制:恶意客户端可以构造无限嵌套的查询,耗尽服务器资源。用 graphql-depth-limit 限制最大深度。

查询复杂度分析:用 graphql-cost-analysis 为每个字段分配权重,拒绝复杂度超标的查询。

字段级权限控制:不同角色访问同一类型的不同字段,需要在 Resolver 层做授权,而非只在入口拦截。

typescript
import depthLimit from 'graphql-depth-limit'; const server = createYoga({ schema, plugins: [{ onParse: () => depthLimit(5) }] });

安全测试在 CI/CD 流水线中应该自动化执行,每次 Schema 变更都触发深度和复杂度校验。还可以用 introspection 检查 Schema 是否意外暴露了内部字段——生产环境建议关闭 introspection。

Mock 数据策略

测试需要可预测的数据,两个思路:

  • 静态 Mock:手写固定数据,适合简单场景和边界条件测试
  • 动态生成:用 @faker-js/faker 生成随机但符合格式要求的数据,适合批量性能测试
typescript
import { faker } from '@faker-js/faker'; function generateUser() { return { id: faker.string.uuid(), name: faker.person.fullName(), email: faker.internet.email() }; } function generateUsers(count: number) { return Array.from({ length: count }, generateUser); }

注意:faker(原 faker.js)已停止维护,社区维护版本是 @faker-js/faker,不要用错。Mock 数据库的推荐做法是创建一个内存数据结构,配合 jest.mock 或 vi.mock 替换真实模型。

测试工具选型

工具适用场景特点
Vitest单元/集成测试速度快,Vite 生态集成好
Jest单元/集成测试社区成熟,文档丰富
graphql-yoga集成测试轻量,内置 fetch 接口方便测试
Apollo Server集成测试生态完整,适合 Apollo 项目
Artillery性能/压力测试支持 GraphQL,模拟高并发
k6性能测试脚本灵活,支持 GraphQL 协议

新项目推荐 Vitest + graphql-yoga;已有 Jest 的项目继续用 Jest,迁移成本不值得。apollo-server-testing 已废弃,如果还在用请迁移到 graphql-yoga 或 Apollo Server 4 的内建测试方式。

测试覆盖率与 CI/CD 集成

测试覆盖率是衡量测试质量的重要指标。Vitest 和 Jest 都支持 --coverage 参数,建议在 package.json 中配置覆盖率门槛:

json
{ "scripts": { "test": "vitest", "test:coverage": "vitest --coverage" } }

在 CI/CD 流水线中,每次提交都应该自动运行单元测试和集成测试,覆盖率低于阈值则阻止合并。E2E 测试因为速度慢,可以只在主分支合并前或定时任务中执行。Schema 变更兼容性检查也应该纳入流水线,可以用 graphql-inspector 对比新旧 Schema,自动检测破坏性变更。

面试回答框架

被问到"GraphQL 怎么测试",按这个结构回答:

  1. 先讲分层思路(单元 → 集成 → E2E),展示全局视野
  2. 重点讲 Resolver 单元测试和 Schema 集成测试,这是日常用得最多的
  3. 提到 N+1 检测和 DataLoader,体现性能意识
  4. 补充安全测试(深度限制、复杂度分析),这是区分度
  5. 追问工具时说清楚选型理由,不要只列名字

GraphQL 测试的核心原则和 REST API 测试一致——隔离外部依赖、覆盖正常和异常路径、关注边界条件。区别在于 GraphQL 的强类型 Schema 让集成测试更有针对性,Resolver 的纯函数特性让单元测试更容易编写。掌握这两点,面试回答就站稳了。

标签:GraphQL