实战:测试驱动开发插件
这篇文章用测试驱动开发(TDD)方式从零写一个完整插件:「行尾空格清理器」。流程严格遵循"红灯 → 绿灯 → 重构":先写测试定义期望行为,看着它失败,再写最小实现让它通过,最后补集成测试、CI 与性能优化。目标不是"写完代码再补测试",而是"用测试倒逼出正确设计"。
需求定义
| 需求 | 行为 |
|---|---|
| 命令 | myext.trimTrailingSpaces,清理当前文档所有行尾空格与 Tab |
| 边界 | 整行只有空白时保留该行(不删除空行) |
| 反馈 | 无活动编辑器时提示;清理完成显示清理数量 |
| 副作用 | 不移动光标位置 |
红灯:先写测试
TDD 第一步不写实现,先写测试。把"找出行尾空格"抽成纯函数 findTrailingWhitespace,它不依赖 VS Code 环境,可以当单元测试跑;命令行为则用集成测试验证。
src/test/suite/trim.test.ts——纯逻辑单元测试:
import * as assert from 'assert';
import { findTrailingWhitespace } from '../../trim';
suite('findTrailingWhitespace', () => {
test('识别行尾空格并给出准确范围', () => {
const trims = findTrailingWhitespace('hello \nworld');
assert.strictEqual(trims.length, 1);
// 第 0 行,空格从第 5 列到第 8 列
assert.deepStrictEqual(trims[0], { line: 0, start: 5, end: 8 });
});
test('多行多处分别上报', () => {
const trims = findTrailingWhitespace('a \nb \nc');
assert.strictEqual(trims.length, 2);
assert.strictEqual(trims[0].line, 0);
assert.strictEqual(trims[1].line, 1);
});
test('Tab 也属于行尾空白', () => {
const trims = findTrailingWhitespace('a\t\t');
assert.strictEqual(trims.length, 1);
assert.deepStrictEqual(trims[0], { line: 0, start: 1, end: 3 });
});
test('整行空白保留,不当作行尾空格', () => {
const trims = findTrailingWhitespace(' \n');
assert.strictEqual(trims.length, 0);
});
test('没有行尾空格时返回空数组', () => {
assert.strictEqual(findTrailingWhitespace('clean\ncode').length, 0);
});
});src/test/suite/extension.test.ts——命令级集成测试:
import * as assert from 'assert';
import * as vscode from 'vscode';
// package.json 里的 publisher.name
const EXT_ID = 'mypub.trailing-trimmer';
suite('行尾空格清理器', () => {
this.timeout(30000);
// 所有用例开始前激活插件,确保命令已注册
suiteSetup(async () => {
const ext = vscode.extensions.getExtension(EXT_ID);
assert.ok(ext, '扩展未找到');
await ext.activate();
});
test('命令清理当前文档所有行尾空白', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'hello \nworld\t\t\nclean'
});
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand('myext.trimTrailingSpaces');
// 空格与 Tab 都被清掉,干净行不受影响
assert.strictEqual(doc.getText(), 'hello\nworld\nclean');
});
test('无行尾空格时内容不变', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'clean\ncode'
});
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand('myext.trimTrailingSpaces');
assert.strictEqual(doc.getText(), 'clean\ncode');
});
test('整行空白行被保留', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'a \n \nb'
});
await vscode.window.showTextDocument(doc);
await vscode.commands.executeCommand('myext.trimTrailingSpaces');
// 第 0 行清理,第 1 行整行空白保留,第 2 行不动
assert.strictEqual(doc.getText(), 'a\n \nb');
});
test('清理不改变光标位置', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'abc \nd'
});
const editor = await vscode.window.showTextDocument(doc);
// 光标放在第 0 行第 1 列(删除范围之前)
editor.selection = new vscode.Selection(
new vscode.Position(0, 1),
new vscode.Position(0, 1)
);
await vscode.commands.executeCommand('myext.trimTrailingSpaces');
assert.strictEqual(doc.getText(), 'abc\nd');
// 光标在删除范围之前,位置不变
assert.deepStrictEqual(editor.selection.active, new vscode.Position(0, 1));
});
});还差两个运行器文件:src/test/runTest.ts 负责启动测试版 VS Code,src/test/suite/index.ts 负责装 Mocha。它们沿用脚手架标准写法:
// src/test/runTest.ts
import * as path from 'path';
import { runTests } from '@vscode/test-electron';
async function main() {
try {
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
await runTests({ extensionDevelopmentPath, extensionTestsPath });
} catch (err) {
console.error('Failed to run tests:', err);
process.exit(1);
}
}
main();// src/test/suite/index.ts
import * as path from 'path';
import Mocha from 'mocha';
import { glob } from 'glob';
export async function run(): Promise<void> {
const mocha = new Mocha({ ui: 'tdd', color: true });
const testsRoot = path.resolve(__dirname, '..');
const files = await glob('**/**.test.js', { cwd: testsRoot });
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));
await new Promise<void>((resolve, reject) => {
mocha.run((failures) => {
if (failures > 0) reject(new Error(`${failures} tests failed.`));
else resolve();
});
});
}现在运行 npm test(需要先有能编译的 tsconfig 与空的 src/trim.ts,否则编译失败)。此时 trim.ts 里 findTrailingWhitespace 还不存在,测试报"找不到模块"——红灯亮起,这正是 TDD 想要的起点。
绿灯:最小实现
先实现纯函数 src/trim.ts:
// src/trim.ts
// 纯逻辑模块:不 import vscode,单元测试零依赖即可运行
export interface TrailingWhitespace {
line: number;
start: number;
end: number;
}
// 找出文本中所有"行尾空格/Tab"。
// 设计决策:整行只有空白时不返回(保留空行)。
export function findTrailingWhitespace(text: string): TrailingWhitespace[] {
const results: TrailingWhitespace[] = [];
const lines = text.split('\n');
lines.forEach((lineText, line) => {
// 匹配行尾的连续空格或 Tab
const match = /[ \t]+$/.exec(lineText);
if (match) {
// 整行都是空白:跳过,保留空行
if (match.index === 0) return;
results.push({
line,
start: match.index,
end: lineText.length
});
}
});
return results;
}再实现入口 src/extension.ts,用 TextEditor.edit 应用删除:
// src/extension.ts
import * as vscode from 'vscode';
import { findTrailingWhitespace } from './trim';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand('myext.trimTrailingSpaces', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showInformationMessage('没有活动编辑器');
return;
}
// 1. 找出所有行尾空白位置
const trims = findTrailingWhitespace(editor.document.getText());
if (trims.length === 0) {
vscode.window.showInformationMessage('没有需要清理的行尾空格');
return;
}
// 2. 一次性批量删除(一次 edit 调用,撤销是一个整体)
await editor.edit((builder) => {
for (const t of trims) {
const start = new vscode.Position(t.line, t.start);
const end = new vscode.Position(t.line, t.end);
builder.delete(new vscode.Range(start, end));
}
});
vscode.window.showInformationMessage(`已清理 ${trims.length} 处行尾空格`);
})
);
}
export function deactivate() {}此时再跑 npm test:单元测试与集成测试全部通过——绿灯。注意 editor.edit 对光标位置的处理:删除发生在光标之后时,光标位置自动保持,测试里"光标不变"的断言验证了这一点。
项目配置:package.json 与 tsconfig.json
{
"name": "trailing-trimmer",
"displayName": "Trailing Trimmer",
"description": "一键清理行尾空格与 Tab",
"version": "0.0.1",
"publisher": "mypub",
"engines": { "vscode": "^1.85.0" },
"categories": ["Formatters"],
"main": "./out/extension.js",
"contributes": {
"commands": [
{
"command": "myext.trimTrailingSpaces",
"title": "Trimmer: 清理行尾空格",
"enablement": "editorTextFocus"
}
],
"keybindings": [
{
"command": "myext.trimTrailingSpaces",
"key": "ctrl+shift+t",
"mac": "cmd+shift+t",
"when": "editorTextFocus"
}
]
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"pretest": "npm run compile",
"test": "node ./out/test/runTest.js"
},
"devDependencies": {
"@types/vscode": "^1.85.0",
"@types/mocha": "^10.0.0",
"@types/node": "^20.0.0",
"@vscode/test-electron": "^2.3.9",
"glob": "^10.3.0",
"mocha": "^10.2.0",
"typescript": "^5.3.0"
}
}{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"outDir": "out",
"lib": ["ES2022"],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"exclude": ["node_modules", ".vscode-test"]
}安装依赖后执行 npm test,观察输出:
Extension Test Suite
findTrailingWhitespace
✓ 识别行尾空格并给出准确范围
✓ 多行多处分别上报
✓ Tab 也属于行尾空白
✓ 整行空白保留,不当作行尾空格
✓ 没有行尾空格时返回空数组
行尾空格清理器
✓ 命令清理当前文档所有行尾空白
✓ 无行尾空格时内容不变
✓ 整行空白行被保留
✓ 清理不改变光标位置
9 passing集成 GitHub Actions CI
在仓库根目录创建 .github/workflows/ci.yml,三平台并行跑同一套测试:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
# Linux 无头环境需要虚拟显示
- name: Run tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a npm test
- name: Run tests (macOS / Windows)
if: runner.os != 'Linux'
run: npm test
# VS Code 测试版下载缓存,加速重复构建
- name: Cache VS Code
uses: actions/cache@v4
with:
path: ~/.vscode-test
key: ${{ runner.os }}-vscode-test
restore-keys: |
${{ runner.os }}-vscode-test提交后,每次 push 与 PR 都会在三个平台各跑一遍 9 个用例,任何一个平台失败都能立刻暴露。
优化收尾:激活与性能
插件已可运行,最后做两处轻量优化,让它在真实使用中不拖累 VS Code。
按需激活
package.json 没有写 activationEvents 数组——VS Code 1.74+ 对 contributes.commands 里的命令自动注册按需激活,插件只在用户第一次执行命令(或按快捷键)时才会被激活。enablement: "editorTextFocus" 进一步保证命令只出现在文本编辑器聚焦时,避免命令面板噪音。
批量编辑与纯逻辑分层
editor.edit 把所有删除合成一次编辑事务:撤销一步到位、文档版本号只跳一次、UI 只重绘受影响区域。纯函数 findTrailingWhitespace 与 VS Code 解耦,既是单元测试的靶子,也让未来扩展(比如加"清理后保存"选项)不动核心逻辑。
完整文件清单
| 文件 | 角色 |
|---|---|
package.json | 插件声明、命令与快捷键、scripts |
tsconfig.json | TypeScript 编译配置 |
src/trim.ts | 纯逻辑:查找行尾空白(单元测试对象) |
src/extension.ts | 命令注册与 TextEditor.edit 应用 |
src/test/runTest.ts | 启动测试版 VS Code |
src/test/suite/index.ts | Mocha 运行器 |
src/test/suite/trim.test.ts | 单元测试(5 例) |
src/test/suite/extension.test.ts | 集成测试(4 例) |
.github/workflows/ci.yml | 三平台 CI |
从红灯到绿灯再到优化,插件在每一步都有测试兜底:trim.test.ts 锁定了边界行为,extension.test.ts 验证了命令与编辑器的真实协作,CI 保证了三平台一致。测试不是开发的附属品,而是驱动实现走向正确的引擎。