GraphQL 的强类型契约让「Mock」这件事变得格外顺手:Schema 本身就是一份完整的接口定义,只要照着它生成符合类型的假数据,就能在没有后端的情况下跑通整个前端。这催生了一套与 REST 时代截然不同的测试范式——Schema 驱动的 Mock:一份 Schema、一个 mock 引擎,就能为前端开发、集成测试、契约验证提供一致的假数据源。
但 Mock 也是最容易被误用的工具。用 mock 替代集成测试,会让「测试全绿但线上全崩」成为常态;用随机数据做断言,会让测试变得不可复现;mock 与真实实现漂移,会让测试沦为形式。本文把 Mock 放在测试金字塔的正确位置:它服务于「隔离验证」与「并行开发」,而不是取代端到端验证。我们会讲清 @graphql-tools/mock 的实现、确定性 mock 的做法、resolver 单元测试与集成测试的边界、客户端侧的 MSW 拦截,以及一套可落地的 CI 分层策略。
一、测试金字塔中的 GraphQL
1.1 四层测试与它们各自的职责
| 层 | 验证对象 | 是否用 Mock | 速度 |
|---|---|---|---|
| 单元测试 | 单个 resolver / 工具函数 | 是(mock 依赖) | 毫秒 |
| 集成测试 | Schema + resolver + 真实数据层 | 否(用测试库) | 百毫秒 |
| 契约测试 | Schema 与客户端期望的一致性 | 部分(mock 生成) | 秒 |
| 端到端测试 | 完整链路(浏览器 → 网关 → 服务) | 否 | 秒~分钟 |
关键判断:Mock 属于前两层与契约层,不属于端到端层。把端到端测试用 mock 替换,等于放弃了唯一能发现「集成问题」的手段。
1.2 Mock 的三种用途
| 用途 | 场景 | 关键要求 |
|---|---|---|
| 并行开发 | 前端等后端接口 | 数据形状正确、可交互 |
| 隔离测试 | 测 resolver 逻辑 | 依赖可控、可断言 |
| 故障注入 | 测错误处理与降级 | 能模拟慢、错、部分失败 |
三种用途对 mock 的要求不同,混用会导致「一个 mock 谁都服务不好」。建议按用途准备不同的 mock 配置。
二、Schema 驱动的 Mock
2.1 addMocksToSchema
@graphql-tools/mock 的 addMocksToSchema 会为 Schema 中每个字段自动生成符合类型的假数据,无需手写任何 resolver:
import { buildSchema } from 'graphql';
import { addMocksToSchema } from '@graphql-tools/mock';
const schema = buildSchema(/* 你的 SDL */);
const mockedSchema = addMocksToSchema({ schema });
默认的 mock 规则:
| 类型 | 默认返回值 |
|---|---|
Int | 随机整数(负值也可能) |
Float | 随机浮点 |
String | 随机字符串 |
Boolean | 随机布尔 |
ID | 随机 UUID |
| 枚举 | 第一个枚举值 |
| 对象 | 递归 mock 其所有字段 |
| 列表 | 长度为 1~2 的数组 |
默认值对前端联调够用,但对测试往往不够——随机布尔会让 UI 在 true/false 之间跳,随机整数会让断言无法写死。
2.2 定制 mock:mocks 与 MockList
通过 mocks 配置覆盖默认规则:
import { addMocksToSchema, MockList } from '@graphql-tools/mock';
const mockedSchema = addMocksToSchema({
schema,
mocks: {
// 标量级:所有 Int 都返回固定值
Int: () => 42,
// 类型级:User 的字段定制
User: () => ({
id: 'user-1',
nickname: 'Leeting',
email: 'leeting@example.com',
role: 'ADMIN',
}),
// 字段级:OrderConnection 的 edges 固定返回 3 条
OrderConnection: () => ({
edges: new MockList(3),
pageInfo: { hasNextPage: false, endCursor: 'cursor-3' },
totalCount: 3,
}),
},
// 保留部分真实 resolver
preserveResolvers: false,
});
MockList 用来控制列表长度,支持固定长度或区间:
new MockList(5) // 恰好 5 条
new MockList([1, 10]) // 1~10 条随机
new MockList(3, () => ({ id: 'x' })) // 5 条且每项定制
2.3 用 faker 生成拟真数据
手写每个字段的假数据很枯燥,用 faker 生成「看起来像真的」数据:
import { faker } from '@faker-js/faker';
import { addMocksToSchema, MockList } from '@graphql-tools/mock';
const mockedSchema = addMocksToSchema({
schema,
mocks: {
User: () => ({
id: faker.string.uuid(),
nickname: faker.internet.username(),
email: faker.internet.email(),
avatarUrl: faker.image.avatar(),
createdAt: faker.date.past().toISOString(),
}),
Post: () => ({
id: faker.string.uuid(),
title: faker.lorem.sentence(),
excerpt: faker.lorem.paragraph(),
publishedAt: faker.date.recent().toISOString(),
}),
PostConnection: () => ({
edges: new MockList([3, 8]),
totalCount: faker.number.int({ min: 3, max: 8 }),
}),
},
});
2.4 确定性 mock:可复现的测试
随机数据是测试的敌人。同一份测试跑两次结果不同,就无法用于断言与回归。解决方案是固定随机种子:
import { faker } from '@faker-js/faker';
beforeEach(() => {
faker.seed(12345); // 每个用例开始前重置种子
});
固定种子后,faker 的随机序列在每次运行中完全一致,测试即可复现。对于需要「每次不同」的场景(如唯一性测试),再在用例内显式 faker.seed(Date.now())。
更严格的做法是用固定数据集替代随机生成:
const FIXTURES = {
users: [
{ id: 'u1', nickname: 'Alice', email: 'alice@example.com' },
{ id: 'u2', nickname: 'Bob', email: 'bob@example.com' },
],
};
const mockedSchema = addMocksToSchema({
schema,
mocks: {
Query: () => ({
user: (_, { id }) => FIXTURES.users.find((u) => u.id === id) ?? null,
users: () => FIXTURES.users,
}),
},
});
固定数据集让断言可以写死(expect(data.user.nickname).toBe('Alice')),测试的可读性与稳定性都更好。这也是「mock 数据即测试夹具」的正确姿势。
三、Mock 驱动的并行开发
3.1 前端不必等后端
Schema 一旦确定,前端就可以基于 mock 服务开发:
import { createServer } from 'node:http';
import { createYoga } from 'graphql-yoga';
import { addMocksToSchema } from '@graphql-tools/mock';
import { buildSchema } from 'graphql';
const schema = buildSchema(readFileSync('./schema.graphql', 'utf-8'));
const yoga = createYoga({
schema: addMocksToSchema({
schema,
mocks: { /* 定制的拟真数据 */ },
}),
graphiql: true,
});
createServer(yoga).listen(4000);
console.log('Mock GraphQL Server: http://localhost:4000/graphql');
前端把 endpoint 指向这个 mock 服务,即可跑通所有查询、分页、错误态——唯一的依赖是 Schema 文件,而 Schema 是团队最先敲定的契约。
3.2 用 mock 覆盖边界场景
Mock 的真正威力在于构造真实后端难以复现的场景:
const mockedSchema = addMocksToSchema({
schema,
mocks: {
Query: () => ({
// 空列表:测空态 UI
emptyFeed: () => ({ edges: [], totalCount: 0 }),
// 超长内容:测截断与换行
longPost: () => ({ title: 'x'.repeat(200) }),
// 部分失败:测降级 UI
partialData: () => ({ recommendations: null }),
// 深分页:测性能与体验
deepPage: () => ({ edges: new MockList(50) }),
}),
},
});
这些场景在生产环境要么难以触发(空列表),要么代价高昂(制造失败)。Mock 让它们变成一行配置,UI 的边界态因此能在开发阶段就被覆盖,而不是等线上出问题才补。
3.3 故障注入
测试客户端的错误处理与重试,需要可控的故障:
const mockedSchema = addMocksToSchema({
schema,
mocks: {
Query: () => ({
// 抛错:测错误提示
brokenField: () => {
throw new GraphQLError('Internal error', {
extensions: { code: 'INTERNAL_SERVER_ERROR' },
});
},
// 部分成功 + 部分错误
riskyField: () => {
// 返回数据,但通过插件注入 errors[]
return { value: 'ok' };
},
}),
},
});
要模拟「慢响应」,在 mock resolver 里加 await sleep(ms) 即可。要模拟网络层失败(超时、断连),则需要在传输层拦截——这正是 MSW 的用武之地(见第五节)。
四、服务端测试
4.1 resolver 单元测试
单元测试的目标是隔离验证一个 resolver 的逻辑,依赖全部 mock:
import { resolvers } from '../resolvers';
test('createOrder 校验库存后下单', async () => {
const ctx = {
db: {
inventory: { check: jest.fn().mockResolvedValue({ available: 5 }) },
orders: { create: jest.fn().mockResolvedValue({ id: 'o1', total: 100 }) },
},
user: { id: 'u1' },
};
const result = await resolvers.Mutation.createOrder(
{},
{ input: { sku: 'sku-1', qty: 2 } },
ctx,
{} as any,
);
expect(ctx.db.inventory.check).toHaveBeenCalledWith('sku-1', 2);
expect(result).toEqual({ id: 'o1', total: 100 });
});
test('库存不足时抛错', async () => {
const ctx = {
db: {
inventory: { check: jest.fn().mockResolvedValue({ available: 0 }) },
orders: { create: jest.fn() },
},
user: { id: 'u1' },
};
await expect(
resolvers.Mutation.createOrder({}, { input: { sku: 'sku-1', qty: 2 } }, ctx, {} as any),
).rejects.toThrow('INSUFFICIENT_STOCK');
expect(ctx.db.orders.create).not.toHaveBeenCalled();
});
要点:断言调用参数(验证依赖被正确使用)、断言副作用未发生(库存不足时不下单)、覆盖错误分支。
4.2 集成测试:executeOperation
单元测试无法验证「Schema 与 resolver 是否对得上」。集成测试用真实的 Schema + 真实的(测试)数据层执行查询:
import { ApolloServer } from '@apollo/server';
import { executeOperation } from '@apollo/server/helpers';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';
let server: ApolloServer;
beforeAll(async () => {
server = new ApolloServer({
typeDefs,
resolvers,
// 用内存数据库或测试库,而非 mock
context: async () => ({ db: testDb, user: { id: 'u1', role: 'USER' } }),
});
await server.start();
});
afterAll(() => server.stop());
test('查询当前用户订单', async () => {
const res = await executeOperation(server, {
query: `query {
me { id nickname orders(first: 10) { edges { node { id total } } } }
}`,
});
expect(res.body.singleResult.errors).toBeUndefined();
expect(res.body.singleResult.data.me.orders.edges).toHaveLength(2);
});
集成测试的价值在于它会同时验证字段解析、类型匹配、可空性传播、错误格式——这些都是单元测试覆盖不到的。
4.3 该测什么、不该测什么
| 该测 | 不该测 |
|---|---|
| resolver 的业务分支 | GraphQL 引擎自身的解析行为 |
| Schema 与 resolver 的对齐 | 第三方库的内部实现 |
| 权限与校验的拒绝路径 | 框架提供的默认行为 |
错误码与 extensions 格式 | 具体的日志文案 |
| 分页游标的编解码 | 数据库的排序稳定性 |
一个常见的浪费是把「查询能返回 200」当作测试——那是冒烟测试的职责,不是集成测试的价值所在。集成测试应当断言具体的业务语义。
五、客户端测试与 MSW
5.1 用 MSW 拦截网络层
前端测试的 mock 有两条路线:mock 客户端库(如 Apollo Client 的 MockedProvider)或 mock 网络层(MSW)。MSW 的优势是不侵入业务代码——被测组件照常发起请求,只是请求被 Service Worker / 拦截器截获:
import { setupServer } from 'msw/node';
import { graphql, HttpResponse } from 'msw';
const server = setupServer(
graphql.query('GetMe', () => {
return HttpResponse.json({
data: { me: { id: 'u1', nickname: 'Alice' } },
});
}),
graphql.mutation('CreateOrder', ({ variables }) => {
if (variables.input.qty > 10) {
return HttpResponse.json({
errors: [{ message: '库存不足', extensions: { code: 'INSUFFICIENT_STOCK' } }],
});
}
return HttpResponse.json({ data: { createOrder: { id: 'o1' } } });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
按 operation name 拦截,让测试与真实查询一一对应;resetHandlers 保证用例之间不串。
5.2 测试错误与加载态
MSW 能构造客户端测试中最难的场景:
test('网络错误时展示重试按钮', async () => {
server.use(
graphql.query('GetMe', () => HttpResponse.error()),
);
render(<Profile />);
expect(await screen.findByText('加载失败,请重试')).toBeInTheDocument();
});
test('加载中展示骨架屏', async () => {
server.use(
graphql.query('GetMe', async () => {
await delay(500); // 人为延迟
return HttpResponse.json({ data: { me: { id: 'u1' } } });
}),
);
render(<Profile />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
加载态与错误态是 UI 最容易出 bug 的地方,也是真实后端最难复现的地方——MSW 让它们变成几行配置。
5.3 与 codegen 联动
若前端用了 codegen(typed document node),MSW 的 handler 可以复用生成的类型,让 mock 数据在编译期就与 Schema 对齐:
import type { GetMeQuery } from '../generated/graphql';
const mockMe: GetMeQuery = {
me: { id: 'u1', nickname: 'Alice' }, // 字段缺失或多余都会编译报错
};
server.use(graphql.query<GetMeQuery>('GetMe', () => HttpResponse.json({ data: mockMe })));
这消除了「mock 数据与 Schema 漂移」的整类问题。相关工作流参见 代码生成与端到端类型安全 。
六、Mock 与契约的边界
6.1 Mock 不能替代契约测试
Mock 证明「客户端能处理这种形状的数据」,契约测试证明「服务端真的会返回这种形状的数据」。两者互补,缺一不可:
| 问题 | 谁能发现 |
|---|---|
| 客户端渲染逻辑错 | Mock 测试 |
| Schema 与 resolver 不一致 | 服务端集成测试 |
| 服务端实际返回与 Schema 不符 | 契约测试 |
| 客户端依赖了不存在的字段 | 契约测试 + codegen |
如果只做 mock 测试,最危险的漏网之鱼是「客户端依赖了一个服务端根本没实现的字段」——mock 会愉快地返回它,测试全绿,线上报错。契约测试的完整设计参见 契约测试与自动化验证 。
6.2 Mock 漂移的防治
Mock 漂移(mock 与真实实现不一致)是长期项目的慢性病。三种防治手段:
- Schema 驱动:mock 从 Schema 生成,Schema 变则 mock 变(
addMocksToSchema天然如此); - 类型对齐:用 codegen 生成的类型约束 mock 数据,字段缺失编译期报错;
- 定期对比:CI 里跑一次「mock 响应 vs 真实响应」的结构对比(不比对值,只比对形状)。
6.3 联邦场景下的 Mock
在 Federation 中,mock 子图要保留联邦指令,否则网关无法做实体解析:
const mockedSubgraph = addMocksToSchema({
schema: buildSubgraphSchema([{ typeDefs, resolvers }]),
mocks: {
User: () => ({ id: 'u1', __resolveReference: (ref) => ({ id: ref.id }) }),
},
});
关键是 __resolveReference 必须被 mock,否则网关按 @key 委派时会拿不到实体。测试联邦查询计划时,可以用 mock 子图替代真实子图,快速验证网关的委派路径是否正确。
七、CI 分层策略
7.1 分层执行
| 阶段 | 测试类型 | 触发时机 | 时长预算 |
|---|---|---|---|
| Pre-commit | 单元测试(改动文件) | 本地提交前 | < 5s |
| PR | 单元 + 集成 | 每次推送 | < 5min |
| PR | 契约测试 + Schema 校验 | 每次推送 | < 2min |
| 合并后 | 端到端 + 压测冒烟 | 主干合并 | < 30min |
| 每晚 | 全量端到端 + 浸泡 | 定时 | 数小时 |
7.2 测试数据管理
三个原则:
- 每个用例自建数据:不依赖其他用例留下的状态,保证可独立运行;
- 事务回滚:集成测试在事务中执行,结束回滚,保持库干净;
- 固定种子:faker 固定种子,保证可复现。
测试数据管理的更多模式可参考 测试数据管理 与 服务虚拟化与 Mock 。
FAQ
Q1:Mock 测试通过就能上线吗?
不能。Mock 只验证「客户端逻辑正确」,不验证「服务端行为正确」。上线前至少要有:服务端集成测试(Schema 与 resolver 对齐)+ 契约测试(客户端期望与服务端实际一致)+ 一次端到端冒烟。
Q2:随机 mock 数据有什么问题?
不可复现。同一份测试两次运行结果不同,就无法用于回归;断言只能写「是字符串」这类弱断言,无法验证具体业务逻辑。必须固定 faker 种子,或用固定数据集。
Q3:preserveResolvers 什么时候用?
当你希望「部分字段用真实 resolver,其余用 mock」时。典型场景是本地开发:真实 resolver 连本地数据库(快速验证真实逻辑),其余字段 mock。测试场景通常设为 false,避免真实 IO 拖慢测试。
Q4:Mock 服务要一直维护吗?
要,但成本很低。因为 mock 由 Schema 驱动,Schema 变了 mock 自动跟随,真正需要手工维护的只有「定制数据」那部分。若定制数据很少(只定制关键实体),维护成本可忽略。若发现自己在 mock 里写了大量业务逻辑,说明 mock 用错了地方——那是集成测试的职责。
Q5:如何测 GraphQL 订阅?
订阅测试要同时验证「事件被正确触发」和「消息被正确推送」。用内存事件总线 + 真实的订阅执行器,订阅后向总线发布事件,断言收到的消息。不要 mock 掉事件总线本身,否则测不出「事件名拼错」这类错误。
小结
GraphQL Mock 的价值来自 Schema 这层强契约:一份 Schema 就能驱动出符合类型的假数据,让前端并行开发、让边界场景可复现、让故障注入变成一行配置。但 Mock 在测试金字塔里只占前两层与契约层——它能证明「客户端逻辑对」,不能证明「服务端行为对」。工程上要守住四条线:mock 必须确定性(固定种子或固定数据集)、mock 数据必须与 Schema 类型对齐(用 codegen 约束)、mock 不能替代契约测试、联邦 mock 必须保留 __resolveReference。把 mock 放在正确的位置、配合分层 CI 执行,它才是加速器;放错位置,它就是「测试全绿但线上全崩」的源头。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。