测试 Mock 与 Fixture
集成测试依赖真实 VS Code,但单元级测试往往只关心"插件的纯逻辑"。此时用 Mock(伪造对象)和 Fixture(受控的测试数据)把被测代码与 VS Code 环境隔离开,测试跑得更快、更稳定、更容易覆盖边界情况。
构造伪造的 TextDocument
vscode.TextDocument 是接口而非类,测试里可以用对象字面量按需实现它。只实现被测代码用到的成员即可,不用完整实现:
import * as vscode from 'vscode';
// 用对象字面量实现 TextDocument 接口的最小版本
// as unknown as vscode.TextDocument 告诉编译器"我故意只实现了一部分"
function fakeDocument(text: string): vscode.TextDocument {
const lines = text.split('\n');
const doc = {
uri: vscode.Uri.file('/fake/file.ts'),
fileName: '/fake/file.ts',
languageId: 'typescript',
isUntitled: false,
isDirty: false,
version: 1,
isClosed: false,
lineCount: lines.length,
// 关键行为:getText 返回构造时传入的内容
getText: () => text,
lineAt: (position: number | vscode.Position) => {
const index = typeof position === 'number' ? position : position.line;
const line = lines[index];
return {
lineNumber: index,
text: line,
range: new vscode.Range(index, 0, index, line.length),
rangeIncludingLineBreak: new vscode.Range(index, 0, index + 1, 0),
firstNonWhitespaceCharacterIndex: line.search(/\S/) === -1 ? line.length : line.search(/\S/),
isEmptyOrWhitespace: line.trim() === ''
} as vscode.TextLine;
},
positionAt: (offset: number) => {
// 偏移量转 Position:逐行累加长度
let remaining = offset;
for (let i = 0; i < lines.length; i++) {
if (remaining <= lines[i].length) {
return new vscode.Position(i, remaining);
}
remaining -= lines[i].length + 1;
}
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
},
offsetAt: (position: vscode.Position) => {
return lines
.slice(0, position.line)
.reduce((acc, l) => acc + l.length + 1, 0) + position.character;
},
getWordRangeAtPosition: () => undefined,
validateRange: (r: vscode.Range) => r,
validatePosition: (p: vscode.Position) => p,
save: async () => true,
eol: vscode.EndOfLine.LF,
// 事件监听用 no-op 占位即可
onDidChangeContent: () => ({ dispose() {} }),
onDidSave: () => ({ dispose() {} }),
onDidChange: () => ({ dispose() {} })
};
return doc as unknown as vscode.TextDocument;
}核心思路:被测代码调用哪些成员,就实现哪些成员;其余用 no-op 或直接省略。as unknown as 双断言绕开 TypeScript 对完整实现的检查。
使用示例——测一个统计注释占比的函数:
import * as assert from 'assert';
// 被测函数:统计文档中注释行的数量
export function countCommentLines(doc: vscode.TextDocument): number {
let count = 0;
for (let i = 0; i < doc.lineCount; i++) {
if (doc.lineAt(i).text.trim().startsWith('//')) {
count++;
}
}
return count;
}
suite('countCommentLines', () => {
test('统计混合代码中的注释行', () => {
const doc = fakeDocument([
'// 头部注释',
'const a = 1;',
'// 中间注释',
'export {}'
].join('\n'));
assert.strictEqual(countCommentLines(doc), 2);
});
test('空文档返回 0', () => {
assert.strictEqual(countCommentLines(fakeDocument('')), 0);
});
});伪造 TextEditor 与 Stub 方法
TextEditor 同样可以伪造。当被测代码只读 editor.document 和 editor.selection 时,一个轻量伪造对象足够:
import * as vscode from 'vscode';
function fakeEditor(options: {
text: string;
selection?: vscode.Selection;
}): vscode.TextEditor {
const doc = fakeDocument(options.text);
const editor = {
document: doc,
selection: options.selection ?? new vscode.Selection(0, 0, 0, 0),
selections: options.selection ? [options.selection] : [],
visibleRanges: [new vscode.Range(0, 0, 10, 0)],
// 被测代码若调用 edit,这里记录回调产生的编辑操作
edit: async (callback: (builder: vscode.TextEditorEdit) => void) => {
const ops: Array<{ type: string; range: vscode.Range; text?: string }> = [];
const builder: vscode.TextEditorEdit = {
replace: (range, text) => ops.push({ type: 'replace', range, text }),
insert: (position, text) => ops.push({ type: 'insert', range: new vscode.Range(position, position), text }),
delete: (range) => ops.push({ type: 'delete', range })
};
callback(builder);
// 把编辑操作真实应用到伪造文档上(简化:只处理 insert 到行尾)
for (const op of ops) {
if (op.type === 'insert' && op.text) {
(doc as any).__applyInsert(op.range.start, op.text);
}
}
return true;
},
setDecorations: () => {},
revealRange: () => {},
show: () => {}
};
return editor as unknown as vscode.TextEditor;
}更通用的做法是结合 Sinon 的 stub:不手工实现行为,而是"替换方法、记录调用":
import * as sinon from 'sinon';
import * as assert from 'assert';
suite('stub 方法', () => {
test('stub 掉 editor.edit,断言调用参数', async () => {
const doc = fakeDocument('hello');
const editor = fakeEditor({ text: 'hello' });
// stub 替换 edit:不执行真实编辑,只记录参数并返回 true
const editStub = sinon.stub(editor, 'edit').resolves(true);
// 被测代码:在文档末尾追加 "!"(调用 editor.edit)
await appendBang(editor);
// 断言 stub 被调用,且回调生成的编辑操作正确
assert.ok(editStub.calledOnce);
const callback = editStub.firstCall.args[0];
const ops: any[] = [];
callback({ replace: (r: any, t: string) => ops.push({ r, t }), insert: (p: any, t: string) => ops.push({ p, t }), delete: () => {} });
assert.strictEqual(ops[0].t, '!');
});
});
async function appendBang(editor: vscode.TextEditor) {
await editor.edit((builder) => {
const end = editor.document.lineAt(editor.document.lineCount - 1).range.end;
builder.insert(end, '!');
});
}stub 的核心价值是"验证交互":断言某方法被调了几次、用什么参数、返回了什么。这把测试关注点从"结果对不对"扩展到"行为对不对"。
临时文件与工作区 Fixture
涉及真实文件系统的测试,用 mkdtemp 创建一次性临时目录,测试结束用 tearDown 清理,保证互不干扰:
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as assert from 'assert';
suite('临时目录 Fixture', () => {
let tempDir: string;
// 每个用例前:创建独立临时目录
setup(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'myext-'));
});
// 每个用例后:递归删除,防止垃圾残留
teardown(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test('写出配置并读回', () => {
const configPath = path.join(tempDir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ rules: ['a'] }));
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
assert.deepStrictEqual(parsed.rules, ['a']);
});
test('目录天然隔离:两个用例的 tempDir 不同', () => {
assert.ok(fs.existsSync(tempDir));
});
});mkdtempSync 每次生成不同的随机目录名,用例之间天然隔离。配合 setup/teardown(tdd 风格)或 beforeEach/afterEach(bdd 风格),每个用例都从干净状态开始。
用 workspace.fs 创建测试工作区文件也遵循同一模式,只是换成 Uri:
import * as vscode from 'vscode';
suite('workspace.fs Fixture', () => {
let fixtureDir: vscode.Uri;
setup(async () => {
// 在系统临时目录下建子目录
fixtureDir = vscode.Uri.joinPath(
vscode.Uri.file(os.tmpdir()),
`myext-${Date.now()}`
);
await vscode.workspace.fs.createDirectory(fixtureDir);
});
teardown(async () => {
await vscode.workspace.fs.delete(fixtureDir, { recursive: true });
});
test('创建项目结构', async () => {
const pkgUri = vscode.Uri.joinPath(fixtureDir, 'package.json');
await vscode.workspace.fs.writeFile(pkgUri, Buffer.from('{}'));
const entries = await vscode.workspace.fs.readDirectory(fixtureDir);
assert.deepStrictEqual(entries.map(([n]) => n), ['package.json']);
});
});workspace.updateWorkspaceFolders 动态工作区
集成测试里想让被测插件"感知到工作区",又不想预置目录,可以用 workspace.updateWorkspaceFolders 动态挂载:
import * as assert from 'assert';
import * as vscode from 'vscode';
suite('动态工作区', () => {
let added: vscode.Uri;
setup(async () => {
added = vscode.Uri.file(path.join(os.tmpdir(), 'myext-proj'));
await vscode.workspace.fs.createDirectory(added);
});
teardown(async () => {
// 卸载工作区(把数量改回 1 并保持原有第一个)
vscode.workspace.updateWorkspaceFolders(1, 1);
await vscode.workspace.fs.delete(added, { recursive: true });
});
test('插件能枚举到新挂载的工作区', async function () {
this.timeout(15000);
// 在索引 1 处插入一个文件夹(start=1, deleteCount=0)
const ok = vscode.workspace.updateWorkspaceFolders(1, 0, { uri: added });
assert.ok(ok, 'updateWorkspaceFolders 应返回 true');
// 等 VS Code 完成工作区变更
await sleep(500);
const folders = vscode.workspace.workspaceFolders;
assert.ok(folders, '应有工作区');
const names = folders.map((f) => f.uri.fsPath);
assert.ok(names.includes(added.fsPath), '新工作区应被枚举到');
});
});updateWorkspaceFolders(start, deleteCount, ...foldersToAdd) 的三个参数:起始索引、删除数量、待添加的文件夹。删除数量为 0 即纯插入。注意该方法同步返回 boolean,但实际生效有延迟,断言前需要等待。
ExtensionContext 测试 Mock
许多插件逻辑接收 vscode.ExtensionContext(激活函数的参数)。直接造一个假的 context,就能在单元测试里驱动订阅与状态读写:
import * as vscode from 'vscode';
export class FakeContext implements Partial<vscode.ExtensionContext> {
// 记录所有被 push 的 Disposable,测试后统一清理
subscriptions: { dispose(): unknown }[] = [];
workspaceState = new FakeMemento();
globalState = new FakeMemento();
extensionPath = '/fake/path';
extensionUri = vscode.Uri.file('/fake/path');
storageUri = vscode.Uri.file('/fake/storage');
extensionMode = vscode.ExtensionMode.Test;
constructor() {
// subscriptions 必须是带 dispose 的数组容器:
// 插件里 context.subscriptions.push(disposable) 会落到这个数组
const subs = this.subscriptions;
this.subscriptions = subs as any;
}
}
// Memento:模拟 workspaceState/globalState 的键值存储
class FakeMemento implements vscode.Memento {
private store = new Map<string, any>();
get<T>(key: string): T | undefined;
get<T>(key: string, defaultValue: T): T;
get(key: string, defaultValue?: unknown) {
return this.store.has(key) ? this.store.get(key) : defaultValue;
}
update(key: string, value: unknown): Promise<void> {
this.store.set(key, value);
return Promise.resolve();
}
// 测试辅助:直接断言内部状态
keys() {
return [...this.store.keys()];
}
}在用例中使用:
import * as assert from 'assert';
// 被测函数:把当前时间戳记入工作区状态
export function recordVisit(context: vscode.ExtensionContext) {
context.workspaceState.update('lastVisit', Date.now());
// 注册一个命令(会被记录进 subscriptions)
context.subscriptions.push(
vscode.commands.registerCommand('myext.visit', () => {})
);
}
suite('ExtensionContext Mock', () => {
test('workspaceState 写入可读回', async () => {
const ctx = new FakeContext();
await recordVisit(ctx as unknown as vscode.ExtensionContext);
const saved = await ctx.workspaceState.get<number>('lastVisit');
assert.ok(typeof saved === 'number');
});
test('subscriptions 记录了注册的 Disposable', () => {
const ctx = new FakeContext();
recordVisit(ctx as unknown as vscode.ExtensionContext);
// 验证插件确实把订阅挂到了 context 上(vs 事件/命令注册)
assert.strictEqual(ctx.subscriptions.length, 1);
});
});Mock context 的价值在于:被测代码不直接依赖真实的 ExtensionContext 实现,测试可以精确断言"它到底订阅了什么、写入了什么状态"。
Sinon:stub 与 spy 完整用法
Sinon 是 Mocha 生态最常用的测试替身库,提供三种工具:
| 工具 | 作用 | 典型场景 |
|---|---|---|
stub | 替换方法实现并记录调用 | 让 workspace.getConfiguration 返回固定配置 |
spy | 包裹原方法,只记录调用 | 确认事件监听被触发、参数正确 |
fake | 生成无行为的假函数 | 替代回调参数 |
import * as sinon from 'sinon';
import * as assert from 'assert';
import * as vscode from 'vscode';
// 被测代码:从配置读取最大警告数
export function maxWarningsFromConfig(): number {
const config = vscode.workspace.getConfiguration('myext');
return config.get<number>('maxWarnings', 100);
}
suite('Sinon stub', () => {
let sandbox: sinon.SinonSandbox;
setup(() => {
// 用 sandbox 统一管理所有替身,restore 一次搞定
sandbox = sinon.createSandbox();
});
teardown(() => {
sandbox.restore();
});
test('stub 掉 getConfiguration 返回固定配置', () => {
// stub 返回一个伪造的配置对象
const fakeConfig = {
get: sandbox.stub().withArgs('maxWarnings', 100).returns(50)
};
sandbox.stub(vscode.workspace, 'getConfiguration').returns(fakeConfig as any);
assert.strictEqual(maxWarningsFromConfig(), 50);
});
test('spy 断言 showWarningMessage 被调用', async () => {
const spy = sandbox.spy(vscode.window, 'showWarningMessage');
await warnIfTooMany(120);
assert.ok(spy.calledOnce, '应弹出一条警告');
assert.match(spy.firstCall.args[0], /120/);
});
});
async function warnIfTooMany(count: number) {
if (count > 100) {
await vscode.window.showWarningMessage(`警告数量过多:${count}`);
}
}sinon 常见的断言接口:
stub.calledOnce / calledTwice; // 调用次数
stub.calledWith('a', 1); // 参数匹配
stub.firstCall.args; // 第一次调用的参数数组
stub.withArgs('x').returns('y'); // 按参数返回不同值
stub.throws(new Error('boom')); // 模拟异常路径
spy.calledBefore(otherSpy); // 调用顺序sandbox 是推荐写法:sandbox.stub() 创建的所有替身在 sandbox.restore() 时自动还原,不会因某个用例忘记清理而污染后续用例。stub 和 spy 让"伪造环境"与"验证交互"两条需求同时满足,与前面的 fakeDocument、FakeContext 组合使用,就能在不启动真实 VS Code 的情况下覆盖插件的大部分逻辑。