GraphQL 测试策略与最佳实践
GraphQL API 的测试对于确保代码质量和系统稳定性至关重要。以下是 GraphQL 测试的全面策略和最佳实践。
1. 测试类型
单元测试
测试单个 Resolver 函数的逻辑。
javascriptimport { userResolvers } from './user.resolver'; describe('User Resolvers', () => { describe('Query.user', () => { it('should return user by id', async () => { const mockUser = { id: '1', name: 'John', email: 'john@example.com' }; User.findById = jest.fn().mockResolvedValue(mockUser); const result = await userResolvers.Query.user(null, { id: '1' }); expect(result).toEqual(mockUser); expect(User.findById).toHaveBeenCalledWith('1'); }); it('should throw error if user not found', async () => { User.findById = jest.fn().mockResolvedValue(null); await expect( userResolvers.Query.user(null, { id: '1' }) ).rejects.toThrow('User not found'); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const input = { name: 'John Doe', email: 'john@example.com' }; const createdUser = { id: '1', ...input }; User.create = jest.fn().mockResolvedValue(createdUser); const result = await userResolvers.Mutation.createUser( null, { input } ); expect(result).toEqual(createdUser); expect(User.create).toHaveBeenCalledWith(input); }); }); });
集成测试
测试多个组件协同工作。
javascriptimport { ApolloServer } from 'apollo-server'; import { createTestClient } from 'apollo-server-testing'; import { typeDefs, resolvers } from './schema'; describe('GraphQL API Integration Tests', () => { const server = new ApolloServer({ typeDefs, resolvers }); const { query, mutate } = createTestClient(server); describe('Query.users', () => { it('should return all users', async () => { const GET_USERS = ` query GetUsers { users { id name email } } `; const { data, errors } = await query(GET_USERS); expect(errors).toBeUndefined(); expect(data.users).toBeDefined(); expect(Array.isArray(data.users)).toBe(true); }); }); describe('Mutation.createUser', () => { it('should create a new user', async () => { const CREATE_USER = ` mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id name email } } `; const variables = { input: { name: 'John Doe', email: 'john@example.com' } }; const { data, errors } = await mutate(CREATE_USER, { variables }); expect(errors).toBeUndefined(); expect(data.createUser).toBeDefined(); expect(data.createUser.name).toBe('John Doe'); }); }); });
E2E 测试
测试完整的用户流程。
javascriptimport { createHttpLink } from 'apollo-link-http'; import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import fetch from 'node-fetch'; describe('E2E Tests', () => { let client; beforeAll(() => { client = new ApolloClient({ link: createHttpLink({ uri: 'http://localhost:4000/graphql', fetch }), cache: new InMemoryCache() }); }); it('should complete user registration and login flow', async () => { // 注册用户 const REGISTER = ` mutation Register($input: RegisterInput!) { register(input: $input) { id name email } } `; const registerResult = await client.mutate({ mutation: REGISTER, variables: { input: { name: 'John Doe', email: 'john@example.com', password: 'password123' } } }); expect(registerResult.data.register).toBeDefined(); // 登录用户 const LOGIN = ` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { token user { id name } } } `; const loginResult = await client.mutate({ mutation: LOGIN, variables: { email: 'john@example.com', password: 'password123' } }); expect(loginResult.data.login.token).toBeDefined(); expect(loginResult.data.login.user.name).toBe('John Doe'); }); });
2. 测试工具
Jest
javascript// jest.config.js module.exports = { testEnvironment: 'node', setupFilesAfterEnv: ['<rootDir>/tests/setup.js'], testMatch: ['**/tests/**/*.test.js'], collectCoverageFrom: [ 'src/**/*.js', '!src/**/*.test.js' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } } };
Mocha + Chai
javascriptconst { expect } = require('chai'); const { ApolloServer } = require('apollo-server'); const { createTestClient } = require('apollo-server-testing'); describe('GraphQL API Tests', () => { let server; let client; beforeEach(() => { server = new ApolloServer({ typeDefs, resolvers }); client = createTestClient(server); }); it('should return user', async () => { const { data } = await client.query({ query: 'query { user(id: "1") { id name } }' }); expect(data.user).to.exist; expect(data.user.name).to.be.a('string'); }); });
3. Mock 数据
使用 Mock 数据库
javascriptconst mockDatabase = { users: [ { id: '1', name: 'John', email: 'john@example.com' }, { id: '2', name: 'Jane', email: 'jane@example.com' } ], posts: [ { id: '1', title: 'Post 1', authorId: '1' }, { id: '2', title: 'Post 2', authorId: '2' } ] }; const mockUserModel = { findById: (id) => { return Promise.resolve( mockDatabase.users.find(user => user.id === id) ); }, findAll: () => { return Promise.resolve(mockDatabase.users); }, create: (data) => { const newUser = { id: String(mockDatabase.users.length + 1), ...data }; mockDatabase.users.push(newUser); return Promise.resolve(newUser); } }; // 在测试中使用 jest.mock('../models/User', () => mockUserModel);
使用 Faker.js 生成测试数据
javascriptconst faker = require('faker'); function generateMockUser() { return { id: faker.random.uuid(), name: `${faker.name.firstName()} ${faker.name.lastName()}`, email: faker.internet.email(), age: faker.datatype.number({ min: 18, max: 80 }) }; } function generateMockUsers(count = 10) { return Array.from({ length: count }, generateMockUser); } describe('User Tests', () => { it('should handle multiple users', async () => { const mockUsers = generateMockUsers(5); User.findAll = jest.fn().mockResolvedValue(mockUsers); const result = await resolvers.Query.users(); expect(result).toHaveLength(5); expect(result[0].email).toMatch(/@/); }); });
4. 测试 Context
测试认证 Context
javascriptdescribe('Authenticated Resolvers', () => { it('should return current user', async () => { const mockUser = { id: '1', name: 'John' }; const context = { user: mockUser }; const result = await resolvers.Query.me(null, {}, context); expect(result).toEqual(mockUser); }); it('should throw error if not authenticated', async () => { const context = { user: null }; await expect( resolvers.Query.me(null, {}, context) ).rejects.toThrow('Authentication required'); }); });
测试数据源 Context
javascriptdescribe('Data Source Tests', () => { it('should use data source', async () => { const mockDataSource = { getUser: jest.fn().mockResolvedValue({ id: '1', name: 'John' }) }; const context = { dataSources: { userAPI: mockDataSource } }; await resolvers.Query.user(null, { id: '1' }, context); expect(mockDataSource.getUser).toHaveBeenCalledWith('1'); }); });
5. 测试错误处理
测试验证错误
javascriptdescribe('Validation Tests', () => { it('should validate email format', async () => { const input = { name: 'John', email: 'invalid-email' }; await expect( resolvers.Mutation.createUser(null, { input }) ).rejects.toThrow('Invalid email format'); }); it('should require required fields', async () => { const input = { name: 'John' // email is missing }; await expect( resolvers.Mutation.createUser(null, { input }) ).rejects.toThrow('Email is required'); }); });
测试 GraphQL 错误
javascriptdescribe('Error Handling Tests', () => { it('should return GraphQL error for not found', async () => { User.findById = jest.fn().mockResolvedValue(null); const { data, errors } = await client.query({ query: 'query { user(id: "999") { id name } }' }); expect(data.user).toBeNull(); expect(errors).toBeDefined(); expect(errors[0].message).toContain('not found'); }); });
6. 测试订阅
测试订阅 Resolver
javascriptdescribe('Subscription Tests', () => { it('should publish post created event', async () => { const mockPost = { id: '1', title: 'New Post' }; const mockIterator = { [Symbol.asyncIterator]: jest.fn().mockReturnValue( (async function* () { yield { postCreated: mockPost }; })() ) }; pubsub.asyncIterator = jest.fn().mockReturnValue(mockIterator); const iterator = resolvers.Subscription.postCreated.subscribe(); const result = await iterator.next(); expect(result.value.postCreated).toEqual(mockPost); }); });
测试订阅过滤
javascriptdescribe('Subscription Filtering Tests', () => { it('should filter subscriptions by userId', async () => { const mockIterator = { [Symbol.asyncIterator]: jest.fn().mockReturnValue( (async function* () { yield { notification: { userId: '1', message: 'Hello' } }; yield { notification: { userId: '2', message: 'Hi' } }; })() ) }; pubsub.asyncIterator = jest.fn().mockReturnValue(mockIterator); const iterator = resolvers.Subscription.notification.subscribe( null, { userId: '1' } ); const results = []; for await (const event of iterator) { results.push(event); if (results.length >= 2) break; } expect(results).toHaveLength(1); expect(results[0].notification.userId).toBe('1'); }); });
7. 性能测试
测试查询性能
javascriptdescribe('Performance Tests', () => { it('should handle large datasets efficiently', async () => { const largeDataset = generateMockUsers(10000); User.findAll = jest.fn().mockResolvedValue(largeDataset); const startTime = Date.now(); const result = await resolvers.Query.users(); const duration = Date.now() - startTime; expect(result).toHaveLength(10000); expect(duration).toBeLessThan(1000); // 应该在 1 秒内完成 }); });
测试 N+1 查询
javascriptdescribe('N+1 Query Tests', () => { it('should not have N+1 query problem', async () => { const posts = generateMockPosts(10); const users = generateMockUsers(10); Post.findAll = jest.fn().mockResolvedValue(posts); User.findById = jest.fn() .mockImplementation((id) => Promise.resolve(users.find(u => u.id === id)) ); const result = await resolvers.Query.posts(); // 应该只调用一次批量查询,而不是 10 次单独查询 expect(User.findById).not.toHaveBeenCalledTimes(10); }); });
8. 测试覆盖率
配置覆盖率
javascript// package.json { "scripts": { "test": "jest", "test:coverage": "jest --coverage", "test:watch": "jest --watch" } }
生成覆盖率报告
bashnpm run test:coverage
9. 测试最佳实践
| 实践 | 说明 |
|---|---|
| 测试所有 Resolvers | 确保每个 Resolver 都有测试 |
| 使用 Mock 数据 | 隔离测试,避免依赖外部服务 |
| 测试错误场景 | 验证错误处理逻辑 |
| 测试边界条件 | 测试空值、极大值等 |
| 保持测试独立 | 每个测试应该独立运行 |
| 使用描述性名称 | 测试名称应该清楚描述测试内容 |
| 测试 Context | 验证认证、授权等 Context 功能 |
| 测试订阅 | 确保订阅功能正常工作 |
| 性能测试 | 确保查询性能符合预期 |
| 监控覆盖率 | 保持高测试覆盖率 |
10. 常见测试问题及解决方案
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 测试不稳定 | 异步操作未正确处理 | 使用 async/await、适当的等待 |
| Mock 失败 | Mock 配置不正确 | 检查 Mock 的配置和调用 |
| 测试慢 | 测试数据量过大 | 使用较小的测试数据集 |
| 覆盖率低 | 未测试所有代码路径 | 增加测试用例,覆盖所有分支 |
| 测试难以维护 | 测试代码复杂 | 重构测试代码,使用测试工具 |