外观
测试
Node.js 命令行测试概述
在 Node.js 命令行工具开发中,测试是确保代码质量、功能正确性和用户体验的关键环节。命令行工具的测试相比普通应用有其特殊性,需要处理进程执行、用户交互、输出验证等复杂场景。
测试金字塔结构:
↗ 端到端测试 (E2E)
↗ 集成测试
↗ 单元测试
测试覆盖范围:
用户界面 → 业务逻辑 → 外部依赖
↓ ↓ ↓
E2E测试 集成测试 单元测试测试基础概念
测试类型分类
Node.js 命令行测试通常分为三个层次,每个层次关注不同的测试目标:
javascript
// 测试层次示例
// 单元测试 - 测试独立函数
export function add(a, b) {
return a + b;
}
// 集成测试 - 测试模块协作
export async function processFile(filePath) {
const content = await readFile(filePath);
return transformContent(content);
}
// E2E测试 - 测试完整命令行行为
// 执行: node cli.js process --input file.txt测试驱动开发流程
TDD (测试驱动开发) 遵循“红-绿-重构”循环:
编写失败测试 → 实现通过代码 → 重构优化
(红) (绿) (优化)常用测试框架与库
Jest - 全功能测试框架
Jest 是 Facebook 开发的测试框架,提供完整的测试解决方案,包括断言库、模拟功能和覆盖率报告。
基础测试配置
javascript
// jest.config.mjs
export default {
preset: 'jest-preset-esm',
extensionsToTreatAsEsm: ['.mjs'],
moduleNameMapping: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
testEnvironment: 'node',
collectCoverageFrom: [
'src/**/*.mjs',
'!src/**/*.test.mjs'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
// math.test.mjs
import { sum, multiply } from '../src/math.mjs';
describe('数学工具函数', () => {
test('sum 函数应该正确相加数字', () => {
expect(sum(1, 2)).toBe(3);
expect(sum(-1, 1)).toBe(0);
expect(sum(0, 0)).toBe(0);
});
test('multiply 函数应该正确相乘数字', () => {
expect(multiply(2, 3)).toBe(6);
expect(multiply(0, 5)).toBe(0);
expect(multiply(-2, 3)).toBe(-6);
});
test('应该处理浮点数运算', () => {
expect(sum(0.1, 0.2)).toBeCloseTo(0.3);
expect(multiply(1.5, 2)).toBe(3);
});
});异步代码测试
javascript
// async.test.mjs
import { fetchData, processWithRetry } from '../src/async-operations.mjs';
describe('异步操作测试', () => {
test('fetchData 应该返回解析的数据', async () => {
const data = await fetchData('https://api.example.com/data');
expect(data).toHaveProperty('id');
expect(data.name).toBe('测试数据');
});
test('processWithRetry 应该在失败后重试', async () => {
const mockService = jest.fn()
.mockRejectedValueOnce(new Error('第一次失败'))
.mockResolvedValueOnce('成功结果');
const result = await processWithRetry(mockService, 3);
expect(result).toBe('成功结果');
expect(mockService).toHaveBeenCalledTimes(2);
});
test('应该正确处理拒绝的 Promise', async () => {
await expect(fetchData('invalid-url'))
.rejects
.toThrow('请求失败');
});
});Mocha + Chai + Sinon 组合
Mocha 是灵活的测试框架,Chai 提供丰富的断言语法,Sinon 用于测试替身 (spies,stubs,mocks)。
测试套件配置
javascript
// mocha-setup.mjs
import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import chaiAsPromised from 'chai-as-promised';
chai.use(sinonChai);
chai.use(chaiAsPromised);
export const { expect } = chai;
export { sinon };
// file-processor.test.mjs
import { expect, sinon } from './mocha-setup.mjs';
import { FileProcessor } from '../src/file-processor.mjs';
import fs from 'fs/promises';
describe('FileProcessor', () => {
let fileProcessor;
let fsStub;
beforeEach(() => {
fileProcessor = new FileProcessor();
fsStub = sinon.stub(fs, 'readFile');
});
afterEach(() => {
fsStub.restore();
});
describe('#processFile', () => {
it('应该成功读取并处理文件', async () => {
// 准备
const mockContent = '文件内容';
fsStub.resolves(mockContent);
// 执行
const result = await fileProcessor.processFile('test.txt');
// 断言
expect(fsStub).to.have.been.calledOnceWith('test.txt', 'utf8');
expect(result).to.equal('处理后的: 文件内容');
});
it('应该在文件不存在时抛出错误', async () => {
// 准备
fsStub.rejects(new Error('文件不存在'));
// 执行和断言
await expect(fileProcessor.processFile('nonexistent.txt'))
.to.be.rejectedWith('文件不存在');
});
});
});Node.js 内置测试模块
Node.js 18+ 提供了稳定的 node:test 模块,包含现代测试功能。
javascript
// node-test.mjs
import { test, describe, mock } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
describe('事件发射器', () => {
test('应该发射事件并传递数据', () => {
const emitter = new EventEmitter();
const mockListener = mock.fn();
emitter.on('data', mockListener);
emitter.emit('data', { value: 42 });
assert.strictEqual(mockListener.mock.callCount(), 1);
assert.deepStrictEqual(mockListener.mock.calls[0].arguments[0], { value: 42 });
});
});
// 异步测试
test('异步操作测试', async (t) => {
await t.test('应该解析 Promise', async () => {
const result = await Promise.resolve('成功');
assert.strictEqual(result, '成功');
});
await t.test('应该拒绝 Promise', async () => {
await assert.rejects(
Promise.reject(new Error('失败')),
{ message: '失败' }
);
});
});命令行工具专用测试技术
子进程执行测试
测试命令行工具需要执行实际的子进程并验证其行为。
javascript
// cli-execution.test.mjs
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PATH = join(__dirname, '../src/cli.mjs');
class CLITestRunner {
static async execute(args = [], options = {}) {
const child = spawn('node', [CLI_PATH, ...args], {
...options,
env: { ...process.env, ...options.env },
encoding: 'utf8'
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
const [exitCode] = await once(child, 'exit');
return {
exitCode,
stdout: stdout.trim(),
stderr: stderr.trim()
};
}
}
describe('命令行界面测试', () => {
test('应该显示帮助信息', async () => {
const result = await CLITestRunner.execute(['--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('用法:');
expect(result.stdout).toContain('选项:');
});
test('应该处理文件转换', async () => {
const result = await CLITestRunner.execute([
'convert',
'--input', 'test.txt',
'--output', 'output.json'
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('转换完成');
});
test('应该在参数缺失时显示错误', async () => {
const result = await CLITestRunner.execute(['convert']);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain('缺少必要参数');
});
});用户交互模拟
测试需要模拟用户输入和验证输出。
javascript
// interactive-cli.test.mjs
import { spawn } from 'node:child_process';
import { Writable } from 'node:stream';
class InteractiveCLITester {
constructor(cliPath) {
this.cliPath = cliPath;
}
async runWithInput(inputs, args = []) {
const child = spawn('node', [this.cliPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe']
});
// 收集输出
let output = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
// 模拟用户输入
for (const input of inputs) {
child.stdin.write(input + '\n');
// 等待输出刷新
await new Promise(resolve => setTimeout(resolve, 50));
}
child.stdin.end();
const [exitCode] = await new Promise((resolve) => {
child.on('exit', (code) => resolve([code]));
});
return {
exitCode,
output: output.trim()
};
}
}
describe('交互式命令行测试', () => {
let tester;
beforeAll(() => {
tester = new InteractiveCLITester('./src/interactive-cli.mjs');
});
test('应该处理问答流程', async () => {
const inputs = [
'John Doe', // 姓名
'30', // 年龄
'y' // 确认
];
const result = await tester.runWithInput(inputs);
expect(result.exitCode).toBe(0);
expect(result.output).toContain('姓名: John Doe');
expect(result.output).toContain('年龄: 30');
expect(result.output).toContain('确认: 是');
});
test('应该验证输入并重新提示', async () => {
const inputs = [
'', // 空姓名
'John Doe', // 有效姓名
'invalid', // 无效年龄
'30' // 有效年龄
];
const result = await tester.runWithInput(inputs);
expect(result.output).toContain('姓名不能为空');
expect(result.output).toContain('请输入有效数字');
});
});模拟与测试替身
文件系统模拟
使用内存文件系统避免真实文件操作。
javascript
// fs-mocking.test.mjs
import { describe, it, beforeEach, afterEach } from 'mocha';
import { expect } from 'chai';
import { createFsFromVolume, Volume } from 'memfs';
import { ufs } from 'unionfs';
import { patchFs } from 'fs-monkey';
import { FileManager } from '../src/file-manager.mjs';
describe('文件管理器测试', () => {
let fileManager;
let memFs;
beforeEach(() => {
// 创建内存文件系统
memFs = createFsFromVolume(new Volume());
// 使用联合文件系统(真实FS + 内存FS)
ufs
.use(await import('node:fs'))
.use(memFs);
patchFs(ufs);
// 初始化测试数据
memFs.mkdirSync('/project', { recursive: true });
memFs.writeFileSync('/project/config.json', '{"name": "test"}');
fileManager = new FileManager();
});
afterEach(() => {
// 恢复原始文件系统
patchFs(await import('node:fs'));
});
it('应该读取配置文件', async () => {
const config = await fileManager.readConfig('/project/config.json');
expect(config).to.deep.equal({ name: 'test' });
});
it('应该创建新文件', async () => {
await fileManager.createFile('/project/new-file.txt', '内容');
const content = memFs.readFileSync('/project/new-file.txt', 'utf8');
expect(content).to.equal('内容');
});
it('应该在文件不存在时抛出错误', async () => {
await expect(fileManager.readConfig('/nonexistent.json'))
.to.be.rejectedWith('文件不存在');
});
});网络请求模拟
模拟外部 API 调用以确保测试的独立性和速度。
javascript
// api-client.test.mjs
import nock from 'nock';
import { APIClient } from '../src/api-client.mjs';
describe('API 客户端测试', () => {
let apiClient;
let apiScope;
beforeEach(() => {
apiClient = new APIClient('https://api.example.com');
apiScope = nock('https://api.example.com');
});
afterEach(() => {
nock.cleanAll();
});
it('应该成功获取用户数据', async () => {
// 模拟成功的 API 响应
apiScope
.get('/users/123')
.reply(200, {
id: 123,
name: 'John Doe',
email: 'john@example.com'
});
const user = await apiClient.getUser(123);
expect(user).to.deep.equal({
id: 123,
name: 'John Doe',
email: 'john@example.com'
});
});
it('应该处理 API 错误', async () => {
// 模拟失败的 API 响应
apiScope
.get('/users/999')
.reply(404, { error: '用户不存在' });
await expect(apiClient.getUser(999))
.to.be.rejectedWith('API 错误: 用户不存在');
});
it('应该重试失败的请求', async () => {
// 模拟第一次失败,第二次成功
apiScope
.get('/data')
.reply(500)
.get('/data')
.reply(200, { data: '成功' });
const data = await apiClient.fetchWithRetry('/data', 2);
expect(data).to.deep.equal({ data: '成功' });
});
});测试工具与实用库
Supertest - HTTP 测试
测试命令行工具中的 HTTP 服务器组件。
javascript
// http-server.test.mjs
import request from 'supertest';
import { createServer } from '../src/http-server.mjs';
describe('HTTP 服务器测试', () => {
let server;
let app;
beforeAll(async () => {
app = await createServer();
server = app.listen(3000);
});
afterAll(async () => {
await server.close();
});
it('应该响应健康检查', async () => {
const response = await request(app)
.get('/health')
.expect(200);
expect(response.body).toHaveProperty('status', 'healthy');
});
it('应该处理数据提交', async () => {
const testData = { name: '测试', value: 42 };
const response = await request(app)
.post('/data')
.send(testData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('测试');
});
it('应该验证请求数据', async () => {
await request(app)
.post('/data')
.send({}) // 空数据
.expect(400)
.expect(/缺少必要字段/);
});
});TestDouble - 现代测试替身
提供清晰的测试替身 API,改善测试可读性。
javascript
// service-test.test.mjs
import td from 'testdouble';
import { UserService } from '../src/user-service.mjs';
import { EmailService } from '../src/email-service.mjs';
describe('用户服务测试', () => {
let userService;
let mockEmailService;
let mockDatabase;
beforeEach(() => {
mockEmailService = td.object(EmailService.prototype);
mockDatabase = td.object(['findUser', 'saveUser']);
userService = new UserService(mockDatabase);
userService.emailService = mockEmailService;
});
afterEach(() => {
td.reset();
});
it('应该注册用户并发送欢迎邮件', async () => {
// 设置测试替身行为
td.when(mockDatabase.findUser('test@example.com'))
.thenResolve(null);
td.when(mockDatabase.saveUser(td.matchers.anything()))
.thenResolve({ id: 1, email: 'test@example.com' });
td.when(mockEmailService.sendWelcomeEmail('test@example.com'))
.thenResolve(true);
// 执行测试
const result = await userService.registerUser({
email: 'test@example.com',
password: 'secure123'
});
// 验证行为
expect(result).toHaveProperty('id', 1);
td.verify(mockEmailService.sendWelcomeEmail('test@example.com'));
});
it('应该在邮箱已存在时抛出错误', async () => {
td.when(mockDatabase.findUser('existing@example.com'))
.thenResolve({ id: 1, email: 'existing@example.com' });
await expect(
userService.registerUser({
email: 'existing@example.com',
password: 'password'
})
).rejects.toThrow('用户已存在');
});
});高级测试模式
快照测试
确保输出格式和内容的一致性。
javascript
// snapshot.test.mjs
import { Formatter } from '../src/formatter.mjs';
describe('格式化器快照测试', () => {
let formatter;
beforeEach(() => {
formatter = new Formatter();
});
it('应该生成一致的用户报告', () => {
const userData = {
id: 123,
name: '张三',
email: 'zhangsan@example.com',
roles: ['admin', 'user'],
createdAt: '2023-01-01T00:00:00Z'
};
const report = formatter.formatUserReport(userData);
expect(report).toMatchSnapshot();
});
it('应该生成一致的系统状态报告', () => {
const systemStatus = {
cpu: { usage: 45.5, cores: 8 },
memory: { used: 16384, total: 32768 },
disk: { used: 256000, total: 500000 }
};
const report = formatter.formatSystemStatus(systemStatus);
expect(report).toMatchInlineSnapshot(`
"系统状态报告:
CPU: 45.5% (8 核心)
内存: 16.0 GB / 32.0 GB (50.0%)
磁盘: 250.0 GB / 488.3 GB (51.2%)"
`);
});
});性能测试
确保命令行工具的性能符合要求。
javascript
// performance.test.mjs
import { performance } from 'node:perf_hooks';
import { DataProcessor } from '../src/data-processor.mjs';
describe('性能测试', () => {
let dataProcessor;
beforeEach(() => {
dataProcessor = new DataProcessor();
});
it('应该在合理时间内处理大量数据', async () => {
const largeDataset = Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.random()
}));
const startTime = performance.now();
const result = await dataProcessor.processLargeDataset(largeDataset);
const endTime = performance.now();
const processingTime = endTime - startTime;
expect(processingTime).toBeLessThan(1000); // 应该在1秒内完成
expect(result).toHaveLength(10000);
});
it('应该具有线性时间复杂度', async () => {
const sizes = [100, 1000, 10000];
const times = [];
for (const size of sizes) {
const dataset = Array.from({ length: size }, (_, i) => ({ id: i }));
const start = performance.now();
await dataProcessor.processLargeDataset(dataset);
const end = performance.now();
times.push(end - start);
}
// 验证时间增长大致是线性的
const ratio1 = times[1] / times[0]; // 10倍数据量
const ratio2 = times[2] / times[1]; // 10倍数据量
expect(ratio1).toBeLessThan(15); // 允许一些波动
expect(ratio2).toBeLessThan(15);
});
});持续集成与测试优化
GitHub Actions 集成
在 CI 环境中运行测试套件。
yaml
# .github/workflows/test.yml
name: Node.js 测试
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: 使用 Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 安装依赖
run: npm ci
- name: 运行测试
run: |
npm test
npm run test:coverage
- name: 上传覆盖率报告
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
- name: 运行 E2E 测试
run: npm run test:e2e测试覆盖率配置
javascript
// nyc.config.mjs
export default {
extends: '@istanbuljs/nyc-config-esm',
all: true,
include: [
'src/**/*.mjs'
],
exclude: [
'src/**/*.test.mjs',
'src/**/*.spec.mjs',
'src/test-utils/**/*.mjs'
],
reporter: [
'text',
'lcov',
'html'
],
'check-coverage': true,
branches: 80,
lines: 80,
functions: 80,
statements: 80
};
// package.json 脚本配置
/*
{
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:coverage": "npm test -- --coverage",
"test:watch": "npm test -- --watch",
"test:e2e": "node test/e2e/runner.mjs"
}
}
*/通过实施全面的测试策略,Node.js 命令行工具可以确保代码质量、功能正确性和优秀的用户体验。从单元测试到端到端测试,每个层次都扮演着重要的角色,共同构建可靠、可维护的命令行应用程序。