测试基础
📋 概述
软件测试是验证和确保软件质量的重要实践。在Node.js开发中,有效的测试策略能够保证代码质量、减少bug、提高可维护性,并为重构和新功能开发提供安全保障。
🎯 学习目标
- 理解软件测试的核心概念和重要性
- 掌握测试的基本分类和测试策略
- 学会设计有效的测试用例
- 了解测试驱动开发的实践方法
- 掌握Node.js测试环境的搭建和配置
📚 测试基础概念
什么是软件测试
javascript
/**
* 软件测试是一个系统性的过程,用于:
* 1. 验证软件是否满足需求规格
* 2. 发现软件中的错误和缺陷
* 3. 评估软件的质量和可靠性
* 4. 确保软件在不同条件下正常工作
*/
const TestingPurpose = {
VERIFICATION: '验证功能是否正确实现',
VALIDATION: '确认是否满足用户需求',
DEFECT_DETECTION: '发现和定位软件缺陷',
QUALITY_ASSURANCE: '保证软件质量标准',
RISK_MITIGATION: '降低软件上线风险'
};
测试的重要性
mermaid
graph TB
A[软件测试的价值] --> B[质量保障]
A --> C[成本控制]
A --> D[风险降低]
A --> E[用户满意度]
B --> B1[功能正确性]
B --> B2[性能稳定性]
B --> B3[安全可靠性]
C --> C1[早期发现缺陷]
C --> C2[减少修复成本]
C --> C3[避免生产事故]
D --> D1[业务风险]
D --> D2[技术风险]
D --> D3[合规风险]
E --> E1[功能可用性]
E --> E2[用户体验]
E --> E3[产品可靠性]
🔍 测试分类
按测试层级分类
javascript
// 测试金字塔层级
const TestingLevels = {
UNIT_TESTS: {
name: '单元测试',
scope: '测试单个函数、类或组件',
characteristics: ['快速执行', '独立性强', '数量最多'],
tools: ['Jest', 'Mocha', 'Vitest'],
coverage: '70-80%'
},
INTEGRATION_TESTS: {
name: '集成测试',
scope: '测试模块间的交互',
characteristics: ['验证接口', '数据流测试', '适量执行'],
tools: ['Supertest', 'Testcontainers', 'Docker'],
coverage: '15-25%'
},
E2E_TESTS: {
name: '端到端测试',
scope: '测试完整的用户场景',
characteristics: ['真实环境', '慢速执行', '数量最少'],
tools: ['Cypress', 'Playwright', 'Puppeteer'],
coverage: '5-15%'
}
};
按测试目的分类
javascript
const TestingTypes = {
FUNCTIONAL: {
name: '功能测试',
purpose: '验证功能是否正确实现',
types: ['正向测试', '负向测试', '边界测试']
},
NON_FUNCTIONAL: {
name: '非功能测试',
purpose: '验证系统质量属性',
types: ['性能测试', '安全测试', '可用性测试', '兼容性测试']
},
STRUCTURAL: {
name: '结构测试',
purpose: '验证代码结构和覆盖率',
types: ['白盒测试', '代码覆盖率测试', '静态分析']
}
};
🛠 Node.js测试环境搭建
基础测试环境
bash
# 创建新的Node.js项目
mkdir nodejs-testing-demo
cd nodejs-testing-demo
npm init -y
# 安装测试框架和工具
npm install --save-dev jest
npm install --save-dev @types/jest # TypeScript支持
# 安装其他测试工具
npm install --save-dev supertest # API测试
npm install --save-dev nock # HTTP请求模拟
npm install --save-dev sinon # Mock/Spy工具
npm install --save-dev nyc # 代码覆盖率
项目结构设计
project/
├── src/
│ ├── controllers/
│ ├── services/
│ ├── models/
│ ├── utils/
│ └── app.js
├── tests/
│ ├── unit/
│ │ ├── controllers/
│ │ ├── services/
│ │ ├── models/
│ │ └── utils/
│ ├── integration/
│ │ ├── api/
│ │ └── database/
│ ├── e2e/
│ │ └── scenarios/
│ ├── fixtures/
│ │ ├── data/
│ │ └── mocks/
│ └── helpers/
│ ├── setup.js
│ └── teardown.js
├── jest.config.js
└── package.json
Jest配置文件
javascript
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'node',
// 测试文件匹配模式
testMatch: [
'**/tests/**/*.test.js',
'**/tests/**/*.spec.js',
'**/__tests__/**/*.js'
],
// 覆盖率收集
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
'!src/**/index.js'
],
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 覆盖率报告
coverageReporters: ['text', 'lcov', 'html'],
// 设置和清理
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
// 模块映射
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@tests/(.*)$': '<rootDir>/tests/$1'
},
// 转换配置
transform: {
'^.+\\.js$': 'babel-jest'
},
// 清除模拟
clearMocks: true,
// 测试超时
testTimeout: 10000,
// 并行执行
maxWorkers: '50%'
};
测试设置和清理
javascript
// tests/helpers/setup.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongoServer;
// 全局设置
beforeAll(async () => {
// 启动内存数据库
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
// 连接数据库
await mongoose.connect(mongoUri, {
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log('🚀 测试环境初始化完成');
});
// 全局清理
afterAll(async () => {
// 断开数据库连接
await mongoose.disconnect();
// 停止内存数据库
await mongoServer.stop();
console.log('🛑 测试环境清理完成');
});
// 每个测试前清理
beforeEach(async () => {
// 清空所有集合
const collections = mongoose.connection.collections;
for (const key in collections) {
const collection = collections[key];
await collection.deleteMany({});
}
});
// 环境变量设置
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
process.env.LOG_LEVEL = 'error';
📝 第一个测试用例
简单函数测试
javascript
// src/utils/calculator.js
class Calculator {
add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
return a + b;
}
subtract(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
return a - b;
}
multiply(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
return a * b;
}
divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('参数必须是数字');
}
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
}
module.exports = Calculator;
javascript
// tests/unit/utils/calculator.test.js
const Calculator = require('@/utils/calculator');
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add方法', () => {
it('应该正确计算两个正数的和', () => {
// Arrange (准备)
const a = 5;
const b = 3;
const expected = 8;
// Act (执行)
const result = calculator.add(a, b);
// Assert (断言)
expect(result).toBe(expected);
});
it('应该正确计算负数', () => {
expect(calculator.add(-5, 3)).toBe(-2);
expect(calculator.add(-5, -3)).toBe(-8);
});
it('应该正确处理小数', () => {
expect(calculator.add(0.1, 0.2)).toBeCloseTo(0.3);
});
it('应该在参数不是数字时抛出错误', () => {
expect(() => calculator.add('5', 3)).toThrow('参数必须是数字');
expect(() => calculator.add(5, null)).toThrow('参数必须是数字');
expect(() => calculator.add(undefined, 3)).toThrow('参数必须是数字');
});
});
describe('divide方法', () => {
it('应该正确计算除法', () => {
expect(calculator.divide(10, 2)).toBe(5);
expect(calculator.divide(7, 2)).toBe(3.5);
});
it('应该在除数为零时抛出错误', () => {
expect(() => calculator.divide(5, 0)).toThrow('除数不能为零');
});
});
});
异步函数测试
javascript
// src/services/user-service.js
const axios = require('axios');
class UserService {
constructor(baseURL = 'https://api.example.com') {
this.baseURL = baseURL;
}
async getUserById(id) {
if (!id) {
throw new Error('用户ID不能为空');
}
try {
const response = await axios.get(`${this.baseURL}/users/${id}`);
return response.data;
} catch (error) {
if (error.response && error.response.status === 404) {
throw new Error('用户不存在');
}
throw new Error('获取用户信息失败');
}
}
async createUser(userData) {
if (!userData.email) {
throw new Error('邮箱不能为空');
}
try {
const response = await axios.post(`${this.baseURL}/users`, userData);
return response.data;
} catch (error) {
if (error.response && error.response.status === 400) {
throw new Error('用户数据无效');
}
throw new Error('创建用户失败');
}
}
async delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = UserService;
javascript
// tests/unit/services/user-service.test.js
const axios = require('axios');
const UserService = require('@/services/user-service');
// Mock axios
jest.mock('axios');
const mockedAxios = axios;
describe('UserService', () => {
let userService;
beforeEach(() => {
userService = new UserService();
jest.clearAllMocks();
});
describe('getUserById', () => {
it('应该成功获取用户信息', async () => {
// 准备测试数据
const userId = '123';
const mockUser = {
id: userId,
name: 'John Doe',
email: 'john@example.com'
};
// Mock axios响应
mockedAxios.get.mockResolvedValue({
data: mockUser
});
// 执行测试
const result = await userService.getUserById(userId);
// 验证结果
expect(result).toEqual(mockUser);
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://api.example.com/users/123'
);
});
it('应该在用户不存在时抛出错误', async () => {
// Mock 404错误
mockedAxios.get.mockRejectedValue({
response: { status: 404 }
});
// 验证异常
await expect(userService.getUserById('999'))
.rejects
.toThrow('用户不存在');
});
it('应该在ID为空时抛出错误', async () => {
await expect(userService.getUserById(null))
.rejects
.toThrow('用户ID不能为空');
await expect(userService.getUserById(''))
.rejects
.toThrow('用户ID不能为空');
});
});
describe('createUser', () => {
it('应该成功创建用户', async () => {
const userData = {
name: 'Jane Doe',
email: 'jane@example.com'
};
const mockResponse = {
id: '456',
...userData
};
mockedAxios.post.mockResolvedValue({
data: mockResponse
});
const result = await userService.createUser(userData);
expect(result).toEqual(mockResponse);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://api.example.com/users',
userData
);
});
});
describe('delay', () => {
it('应该在指定时间后完成', async () => {
const startTime = Date.now();
await userService.delay(100);
const endTime = Date.now();
const elapsed = endTime - startTime;
expect(elapsed).toBeGreaterThanOrEqual(90);
expect(elapsed).toBeLessThan(150);
});
});
});
🎭 测试断言和匹配器
Jest常用断言
javascript
describe('Jest断言示例', () => {
// 基本值断言
it('基本值匹配', () => {
expect(2 + 2).toBe(4); // 严格相等
expect({ name: 'John' }).toEqual({ name: 'John' }); // 深度相等
expect('Hello World').toMatch(/World/); // 正则匹配
expect('Hello World').toContain('World'); // 包含字符串
});
// 数字断言
it('数字匹配', () => {
expect(2 + 2).toBeGreaterThan(3);
expect(2 + 2).toBeGreaterThanOrEqual(4);
expect(2 + 2).toBeLessThan(5);
expect(2 + 2).toBeLessThanOrEqual(4);
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数比较
});
// 布尔值断言
it('布尔值匹配', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect('Hello').toBeDefined();
});
// 数组断言
it('数组匹配', () => {
const fruits = ['apple', 'banana', 'orange'];
expect(fruits).toHaveLength(3);
expect(fruits).toContain('banana');
expect(fruits).toEqual(expect.arrayContaining(['apple', 'banana']));
});
// 对象断言
it('对象匹配', () => {
const user = {
id: 1,
name: 'John',
email: 'john@example.com',
profile: {
age: 30,
country: 'US'
}
};
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('profile.age', 30);
expect(user).toEqual(expect.objectContaining({
name: 'John',
email: expect.stringContaining('@')
}));
});
// 异常断言
it('异常匹配', () => {
const throwError = () => {
throw new Error('出错了');
};
expect(throwError).toThrow();
expect(throwError).toThrow('出错了');
expect(throwError).toThrow(/错了/);
expect(throwError).toThrow(Error);
});
// 异步断言
it('异步匹配', async () => {
const asyncFunction = () => Promise.resolve('success');
const asyncError = () => Promise.reject(new Error('failed'));
await expect(asyncFunction()).resolves.toBe('success');
await expect(asyncError()).rejects.toThrow('failed');
});
});
自定义匹配器
javascript
// tests/helpers/custom-matchers.js
expect.extend({
toBeValidEmail(received) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
return {
message: () =>
`expected ${received} ${pass ? 'not ' : ''}to be a valid email`,
pass
};
},
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
return {
message: () =>
`expected ${received} ${pass ? 'not ' : ''}to be within range ${floor} - ${ceiling}`,
pass
};
},
toHaveValidationError(received, field) {
const hasError = received.errors &&
received.errors.some(error => error.field === field);
return {
message: () =>
`expected validation result ${hasError ? 'not ' : ''}to have error for field ${field}`,
pass: hasError
};
}
});
// 使用自定义匹配器
describe('自定义匹配器示例', () => {
it('应该验证邮箱格式', () => {
expect('test@example.com').toBeValidEmail();
expect('invalid-email').not.toBeValidEmail();
});
it('应该检查数值范围', () => {
expect(15).toBeWithinRange(10, 20);
expect(25).not.toBeWithinRange(10, 20);
});
it('应该检查验证错误', () => {
const validationResult = {
valid: false,
errors: [
{ field: 'email', message: '邮箱格式无效' },
{ field: 'password', message: '密码太短' }
]
};
expect(validationResult).toHaveValidationError('email');
expect(validationResult).not.toHaveValidationError('username');
});
});
📊 测试组织和结构
测试套件组织
javascript
describe('用户管理系统', () => {
// 嵌套测试套件
describe('用户注册', () => {
describe('有效数据', () => {
it('应该成功注册新用户', () => {
// 测试逻辑
});
it('应该返回用户信息', () => {
// 测试逻辑
});
});
describe('无效数据', () => {
it('应该拒绝空邮箱', () => {
// 测试逻辑
});
it('应该拒绝重复邮箱', () => {
// 测试逻辑
});
});
});
describe('用户登录', () => {
beforeEach(() => {
// 每个登录测试前的设置
});
it('应该允许有效用户登录', () => {
// 测试逻辑
});
it('应该拒绝无效凭据', () => {
// 测试逻辑
});
});
});
测试数据管理
javascript
// tests/fixtures/user-data.js
const userData = {
validUser: {
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
age: 30
},
invalidUsers: [
{
name: '',
email: 'john@example.com',
password: 'password123'
},
{
name: 'John Doe',
email: 'invalid-email',
password: 'password123'
},
{
name: 'John Doe',
email: 'john@example.com',
password: '123' // 太短
}
],
adminUser: {
name: 'Admin User',
email: 'admin@example.com',
password: 'admin123',
role: 'admin'
}
};
module.exports = userData;
// 在测试中使用
const userData = require('@tests/fixtures/user-data');
describe('用户验证', () => {
it('应该接受有效用户数据', () => {
const result = validateUser(userData.validUser);
expect(result.isValid).toBe(true);
});
it.each(userData.invalidUsers)('应该拒绝无效用户数据', (invalidUser) => {
const result = validateUser(invalidUser);
expect(result.isValid).toBe(false);
});
});
📝 测试最佳实践
测试命名规范
javascript
// 好的测试命名
describe('UserService', () => {
describe('createUser', () => {
it('应该在提供有效数据时创建新用户', () => {});
it('应该在邮箱已存在时抛出错误', () => {});
it('应该在密码太短时抛出验证错误', () => {});
});
});
// 避免的测试命名
describe('UserService', () => {
it('测试用户创建', () => {}); // 太模糊
it('test1', () => {}); // 没有意义
it('创建用户功能', () => {}); // 不够具体
});
AAA模式(Arrange-Act-Assert)
javascript
it('应该计算商品总价', () => {
// Arrange - 准备测试数据和环境
const items = [
{ price: 10, quantity: 2 },
{ price: 15, quantity: 1 },
{ price: 8, quantity: 3 }
];
const calculator = new PriceCalculator();
// Act - 执行被测试的操作
const total = calculator.calculateTotal(items);
// Assert - 验证结果
expect(total).toBe(59); // (10*2) + (15*1) + (8*3) = 59
});
测试隔离和独立性
javascript
describe('用户服务测试', () => {
let userService;
let mockDatabase;
beforeEach(() => {
// 每个测试前重新创建实例
mockDatabase = new MockDatabase();
userService = new UserService(mockDatabase);
});
afterEach(() => {
// 每个测试后清理
mockDatabase.clear();
});
it('测试1不应该影响测试2', () => {
// 这个测试的数据不会影响下一个测试
userService.addUser({ name: 'Test User' });
expect(userService.getUserCount()).toBe(1);
});
it('测试2从干净状态开始', () => {
// 这个测试从0开始,不受上一个测试影响
expect(userService.getUserCount()).toBe(0);
});
});
📝 总结
软件测试是确保Node.js应用质量的重要实践:
- 基础概念:理解测试的目的、分类和重要性
- 环境搭建:配置完整的测试环境和工具链
- 测试编写:掌握基本的测试用例编写技巧
- 断言使用:熟练使用各种断言和匹配器
- 最佳实践:遵循测试命名、组织和隔离的最佳实践
有效的测试不仅能发现bug,更能作为活文档帮助理解代码行为,并为重构提供安全保障。