插件集成测试
单元测试只验证"函数算得对不对",而插件真正的问题是"它和 VS Code 协作得对不对"。集成测试的做法是启动一个真实的 VS Code 实例,加载插件,再用 vscode API 像用户一样操作它:执行命令、打开编辑器、读写文件,最后断言结果。
runTests:拉起一个真实 VS Code
集成测试的运行入口和单元测试相同,都是 runTests,区别在于测试用例本身会操作真实的 VS Code 对象。runTest.ts 负责拼装参数:
import * as path from 'path';
import { runTests } from '@vscode/test-electron';
async function main() {
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
// 追加一个测试专用工作区:测试里 workspace 操作都发生在这里
const workspacePath = path.resolve(__dirname, './fixtures/workspace');
await runTests({
// 被测插件目录:可传数组同时测试多个插件
extensionDevelopmentPath,
// 测试运行器入口
extensionTestsPath,
// 把测试工作区作为参数传给启动的 VS Code:
// 位置参数会被当作要打开的文件夹
launchArgs: [workspacePath],
// 指定 VS Code 版本,保证本地与 CI 一致
version: '1.85.0'
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});| 选项 | 本示例的作用 |
|---|---|
extensionDevelopmentPath | 让测试版 VS Code 以"开发模式"加载当前插件 |
extensionTestsPath | 指定测试入口,插件激活后立即执行 |
launchArgs | 打开一个带真实文件的测试工作区,供 workspace.fs 操作 |
version | 固定版本,避免"本地通过、CI 挂了"的版本漂移 |
测试中调用 vscode API:触发插件命令
集成测试的核心动作是"执行插件注册的命令"。命令可能依赖插件激活后的状态,所以稳妥的写法是先显式激活扩展:
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('命令执行', () => {
test('helloWorld 命令弹出欢迎提示', async function () {
this.timeout(15000);
// 1. 显式激活被测插件(id 为 package.json 的 publisher.name)
// 插件激活时注册的命令才会就绪
const ext = vscode.extensions.getExtension('myext.my-extension');
assert.ok(ext, '扩展未找到');
await ext.activate();
// 2. 通过命令面板执行插件命令,返回值就是命令回调的返回值
const result = await vscode.commands.executeCommand<string>(
'myext.helloWorld'
);
assert.strictEqual(result, 'hello');
});
});对于带参数的命令,executeCommand 的剩余参数会原样传给回调:
suite('带参数的命令', () => {
test('格式化命令接收选中文本', async function () {
this.timeout(15000);
// 命令回调签名:async (text: string) => string
const result = await vscode.commands.executeCommand<string>(
'myext.formatText',
' hello '
);
assert.strictEqual(result, 'hello');
});
});如果命令涉及编辑器选区、光标等状态,就需要先构造编辑器环境再触发:
suite('依赖编辑器的命令', () => {
test('命令把当前文件标题写入状态栏', async function () {
this.timeout(15000);
// 1. 打开一个文档并激活
const doc = await vscode.workspace.openTextDocument({
content: 'hello world',
language: 'plaintext'
});
await vscode.window.showTextDocument(doc);
// 2. 执行命令(命令内部读 activeTextEditor)
await vscode.commands.executeCommand('myext.reportTitle');
// 3. 断言副作用:状态栏文本已更新
const status = vscode.window.createStatusBarItem('myext.status');
// 插件内部通常会维护一个可查询的状态对象,
// 这里演示用 getState 验证命令确实生效
const state = await vscode.commands.executeCommand<string>(
'myext.getState'
);
assert.strictEqual(state, 'hello world');
});
});executeCommand 另一个常见用途是触发 VS Code 内置命令来构造测试场景,比如触发保存:
test('保存后触发 onDidSaveTextDocument', async function () {
this.timeout(15000);
// 先拿到真实文件(工作区里的 fixture)
const uri = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'sample.txt');
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// 注册一次性监听,等保存事件
const saved = new Promise<vscode.TextDocument>((resolve) => {
const listener = vscode.workspace.onDidSaveTextDocument((d) => {
if (d.uri.toString() === uri.toString()) {
listener.dispose();
resolve(d);
}
});
});
// 触发保存(内置命令)
await vscode.commands.executeCommand('workbench.action.files.save');
// 等待事件送达
const savedDoc = await saved;
assert.ok(savedDoc);
});window.activeTextEditor:编辑器行为测试
编辑器是插件交互的主战场,测试要模拟"用户打开文件、选中文本、敲击命令"的完整过程。window.activeTextEditor 反映当前活动编辑器,配合 Selection 与 WorkspaceEdit 可验证绝大多数编辑类逻辑:
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('编辑器行为', () => {
test('命令将选中文本转为大写', async function () {
this.timeout(15000);
// 1. 打开文档
const doc = await vscode.workspace.openTextDocument({
content: 'hello world'
});
const editor = await vscode.window.showTextDocument(doc);
// 2. 模拟用户选中 "hello"
editor.selection = new vscode.Selection(
new vscode.Position(0, 0),
new vscode.Position(0, 5)
);
// 3. 执行"转大写"命令(命令内部用 editor.edit 改写选中区)
await vscode.commands.executeCommand('myext.upperCase');
// 4. 断言文档内容已变化——读的是底层文档模型,不依赖光标
const text = doc.getText();
assert.strictEqual(text, 'HELLO world');
});
test('多选区处理:每次编辑只命中一个选区', async function () {
this.timeout(15000);
const doc = await vscode.workspace.openTextDocument({
content: 'a b c'
});
const editor = await vscode.window.showTextDocument(doc);
// 设置两个选区
editor.selections = [
new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 1)),
new vscode.Selection(new vscode.Position(0, 4), new vscode.Position(0, 5))
];
await vscode.commands.executeCommand('myext.deleteSelection');
// 两个选区的内容被删掉:只剩 "b"
assert.strictEqual(doc.getText(), 'b');
});
});测试编辑类命令时几个值得注意的行为:
| 行为 | 说明 |
|---|---|
editor.edit 是异步的 | 命令内部 await editor.edit(...),测试侧只需 await executeCommand |
| 文本变化以文档为准 | 断言用 doc.getText(),不要依赖光标位置或选区快照 |
| 未保存文档可反复打开 | openTextDocument({ content }) 每次创建新文档,用后需 close() 防泄漏 |
suite('资源清理', () => {
let doc: vscode.TextDocument;
teardown(async () => {
// 每个用例结束后关闭打开的文档,避免跨用例污染
if (doc) {
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
}
});
test('打开文档并操作', async function () {
this.timeout(15000);
doc = await vscode.workspace.openTextDocument({ content: 'x' });
await vscode.window.showTextDocument(doc);
assert.ok(vscode.window.activeTextEditor);
});
});workspace.fs:文件操作测试
文件读写是配置类、任务类插件的常见场景。workspace.fs 是异步 API,测试里直接操作测试工作区的真实文件:
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('文件操作', () => {
// 工作区根目录(runTests 的 launchArgs 传入的文件夹)
const rootUri = vscode.workspace.workspaceFolders![0].uri;
test('创建并读取文件', async function () {
this.timeout(15000);
const fileUri = vscode.Uri.joinPath(rootUri, 'output.txt');
// 1. 写入内容(覆盖式)
await vscode.workspace.fs.writeFile(fileUri, Buffer.from('hello fs', 'utf8'));
// 2. 读取并断言
const data = await vscode.workspace.fs.readFile(fileUri);
assert.strictEqual(Buffer.from(data).toString('utf8'), 'hello fs');
});
test('复制与删除', async function () {
this.timeout(15000);
const src = vscode.Uri.joinPath(rootUri, 'output.txt');
const dst = vscode.Uri.joinPath(rootUri, 'copy.txt');
await vscode.workspace.fs.copy(src, dst, { overwrite: true });
let exists = await fileExists(dst);
assert.ok(exists, '复制后目标文件应存在');
await vscode.workspace.fs.delete(dst);
exists = await fileExists(dst);
assert.ok(!exists, '删除后目标文件不应存在');
});
test('目录遍历', async function () {
this.timeout(15000);
const entries = await vscode.workspace.fs.readDirectory(rootUri);
const names = entries.map(([name]) => name);
assert.ok(names.includes('output.txt'), '目录中应包含 output.txt');
});
});
// 判断文件是否存在:readFile 失败即不存在
async function fileExists(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}workspace.fs 与 Node 的 fs 模块差异明显,测试时注意:
| 差异 | 说明 |
|---|---|
基于 Uint8Array | 写文件传 Uint8Array,读文件拿到的也是 Uint8Array,需要转 Buffer 再转字符串 |
| 全平台统一路径 | 用 Uri.joinPath 拼路径,不要手写 / 或 \ |
| 删除不存在的文件报错 | 用 try/catch 包裹或先 stat 判断 |
断言与异步等待模式
VS Code 的许多 API 是"事件驱动"而非"返回结果",比如监听文档变更、配置变化。测试这类行为需要把事件包装成 Promise:
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('异步等待模式', () => {
test('等待文档内容变更事件', async function () {
this.timeout(15000);
const doc = await vscode.workspace.openTextDocument({ content: 'abc' });
const editor = await vscode.window.showTextDocument(doc);
// 1. 先注册监听,再触发变更,避免竞态
const changed = new Promise<vscode.TextDocumentChangeEvent>((resolve) => {
const listener = vscode.workspace.onDidChangeTextDocument((e) => {
if (e.document.uri.toString() === doc.uri.toString()) {
listener.dispose();
resolve(e);
}
});
});
// 2. 执行编辑(模拟插件逻辑:光标处插入字符)
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 3), '!');
});
// 3. 等待事件并断言变更内容
const event = await changed;
assert.strictEqual(event.contentChanges[0].text, '!');
assert.strictEqual(doc.getText(), 'abc!');
});
test('轮询等待:状态位最终达到期望值', async function () {
this.timeout(15000);
// 某些插件状态通过 setState 异步更新,事件不明显时用轮询
const deadline = Date.now() + 5000;
let state = '';
while (Date.now() < deadline) {
state = await vscode.commands.executeCommand<string>('myext.getState');
if (state === 'ready') break;
await sleep(100); // 每次间隔 100ms
}
assert.strictEqual(state, 'ready');
});
});
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}等待模式的选取原则:
| 模式 | 适用场景 | 优点 |
|---|---|---|
| 事件 Promise | 有明确事件可听(保存、变更、配置) | 快、无竞态 |
| 轮询 | 状态由异步任务写入,无事件暴露 | 简单通用,但慢且有间隔误差 |
| 长超时 + 直接 await | API 本身返回 Promise(读写文件、执行命令) | 最直接 |
三个通用原则:先注册监听再触发动作,防止事件早到丢失;事件回调里按 uri 过滤,避免被其他文档的事件干扰;监听器用完即 dispose,防止测试进程泄漏监听导致退出挂起。
完整集成测试示例
把以上模式拼成一个完整场景:验证一个"文件头注释生成器"插件,从命令触发到文件落盘全链路:
import * as assert from 'assert';
import * as vscode from 'vscode';
const EXT_ID = 'myext.header-gen';
suite('文件头注释生成器集成测试', () => {
this.timeout(30000);
// 测试前:激活插件,确保命令就绪
suiteSetup(async () => {
await vscode.extensions.getExtension(EXT_ID)?.activate();
});
test('对当前文件生成文件头', async function () {
const rootUri = vscode.workspace.workspaceFolders![0].uri;
const fileUri = vscode.Uri.joinPath(rootUri, 'demo.ts');
await vscode.workspace.fs.writeFile(fileUri, Buffer.from('export const a = 1;'));
// 打开文件,激活编辑器
const doc = await vscode.workspace.openTextDocument(fileUri);
await vscode.window.showTextDocument(doc);
// 等待命令写入完成(命令返回 Promise,await 即同步完成)
await vscode.commands.executeCommand('myext.addHeader', {
author: 'tester',
license: 'MIT'
});
// 读回文件验证内容
const text = Buffer.from(await vscode.workspace.fs.readFile(fileUri)).toString('utf8');
assert.ok(text.startsWith('/**'), '文件应以注释块开头');
assert.ok(text.includes('@author tester'), '应包含作者信息');
assert.ok(text.includes('@license MIT'), '应包含许可证信息');
// 清理:关闭编辑器并删除文件
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
await vscode.workspace.fs.delete(fileUri);
});
});这个用例覆盖了集成测试的四要素:环境准备(activate + 建文件)、用户动作模拟(executeCommand)、VS Code 状态验证(编辑器 + 文件系统)、资源回收(关闭 + 删除)。每一条都在真实的 VS Code 实例里跑通,插件与 API 的协作方式有没有问题,一目了然。