实战:测试运行器插件
把前两篇的 API 串成完整产品:实现一个 Python unittest 测试运行器插件。它能扫描工作区发现测试、在测试资源管理器渲染层级树、运行单个或全部测试,并把通过/失败/跳过结果与断言详情上报回 UI。
设计概览
| 模块 | 职责 | 核心 API |
|---|---|---|
discover.ts | 扫描目录、解析测试函数 | workspace.fs.readDirectory、正则解析 |
tree.ts | 构建 TestItem 树 | createTestItem、items.replace、resolveHandler |
runner.ts | 执行测试、解析输出 | createTestRun、spawn、逐行解析 |
extension.ts | 组装控制器与命令 | createTestController、createRunProfile |
package.json
json
{
"name": "pytest-runner",
"displayName": "Python Unit Test Runner",
"description": "在测试资源管理器中运行 Python unittest 测试",
"version": "1.0.0",
"publisher": "mypublisher",
"engines": { "vscode": "^1.80.0" },
"categories": ["Testing"],
"main": "./out/extension.js",
"activationEvents": [],
"contributes": {
"commands": [
{ "command": "pytestRunner.refresh", "title": "Python 测试: 刷新测试树" }
],
"menus": {
"commandPalette": [
{ "command": "pytestRunner.refresh", "when": "inTestExplorer" }
]
}
},
"scripts": {
"compile": "tsc -p ./"
},
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/node": "^18.0.0",
"typescript": "^5.0.0"
}
}activationEvents 留空:VSCode 1.74+ 的测试扩展由 Testing 分类自动激活。
第一步:测试发现
扫描测试文件
typescript
// discover.ts
import * as vscode from 'vscode';
import * as path from 'path';
// 递归扫描目录,收集符合 unittest 命名约定的文件
export async function findTestFiles(
root: vscode.Uri
): Promise<vscode.Uri[]> {
const result: vscode.Uri[] = [];
async function walk(dir: vscode.Uri, depth: number) {
if (depth > 5) return; // 限制深度防止无限递归
const entries = await vscode.workspace.fs.readDirectory(dir);
for (const [name, type] of entries) {
const uri = vscode.Uri.joinPath(dir, name);
if (type === vscode.FileType.Directory) {
// 跳过常见无关目录
if (['node_modules', '.git', '__pycache__', 'venv'].includes(name)) {
continue;
}
await walk(uri, depth + 1);
} else if (
type === vscode.FileType.File &&
(name.startsWith('test_') || name.endsWith('_test.py')) &&
name.endsWith('.py')
) {
result.push(uri);
}
}
}
await walk(root, 0);
return result;
}解析测试函数
typescript
// discover.ts(续)
export interface TestFunction {
name: string; // 函数名,如 test_add
startLine: number; // 起始行(0 基,供 range 使用)
}
// 用正则提取 def test_xxx,够用且无依赖;
// 真实产品可改用 AST 解析(Python)或 @babel/parser(JS)
export function parseTestFunctions(
content: string
): TestFunction[] {
const functions: TestFunction[] = [];
const regex = /^(\s*)def\s+(test_\w+)\s*\(/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
// 统计该 def 前的换行数得到行号
const startLine = content
.slice(0, match.index)
.split('\n').length - 1;
functions.push({ name: match[2], startLine });
}
return functions;
}第二步:构建 TestItem 树
typescript
// tree.ts
import * as vscode from 'vscode';
import * as path from 'path';
import { findTestFiles, parseTestFunctions } from './discover';
export function createTestTree(controller: vscode.TestController) {
// 根集合 = 测试文件(懒加载子节点)
controller.resolveHandler = async (item) => {
if (!item) {
await refreshRoot(controller); // 无参数:刷新全部
return;
}
await resolveChildren(controller, item);
};
// 首次构建
refreshRoot(controller);
}
// 扫描文件 → 根集合
async function refreshRoot(controller: vscode.TestController) {
const root = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!root) return;
const files = await findTestFiles(root);
const items = new Map<string, vscode.TestItem>();
for (const uri of files) {
const item = controller.createTestItem(
uri.fsPath, // id:绝对路径天然唯一
path.basename(uri.fsPath), // label:文件名
uri
);
item.canResolveChildren = true; // 函数节点懒加载
item.kind = vscode.TestItemKind.Collection;
items.set(item.id, item);
}
// 一次性替换,避免闪烁
controller.items.replace(items);
}
// 展开文件节点 → 测试函数节点
async function resolveChildren(
controller: vscode.TestController,
fileItem: vscode.TestItem
) {
if (!fileItem.uri) return;
const bytes = await vscode.workspace.fs.readFile(fileItem.uri);
const functions = parseTestFunctions(
Buffer.from(bytes).toString('utf8')
);
const children = new Map<string, vscode.TestItem>();
for (const fn of functions) {
const test = controller.createTestItem(
`${fileItem.id}::${fn.name}`, // 文件::函数 复合 ID
fn.name,
fileItem.uri
);
// range 指向函数定义行 → 获得行内运行按钮
const start = new vscode.Position(fn.startLine, 0);
test.range = new vscode.Range(start, start.translate(3));
test.kind = vscode.TestItemKind.TestCase;
children.set(test.id, test);
}
fileItem.children.replace(children);
}第三步:运行执行器
运行器负责真正调用 Python,并逐行解析输出:
typescript
// runner.ts
import * as vscode from 'vscode';
import * as path from 'path';
import { spawn } from 'child_process';
export interface RunContext {
controller: vscode.TestController;
request: vscode.TestRunRequest;
token: vscode.CancellationToken;
}
// 把 include 中的集合节点展开为叶子用例
function collectLeafIds(item: vscode.TestItem, out: string[]) {
if (item.children.size === 0) {
out.push(item.id);
} else {
item.children.forEach(c => collectLeafIds(c, out));
}
}
// 根据请求计算目标模块点路径列表:
// 空 include 表示运行全部测试;否则只运行选中的叶子
function resolveTargets(
controller: vscode.TestController,
request: vscode.TestRunRequest
): string[] {
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!root) return [];
// include 为空 → 全部叶子
const include = request.include
? Array.from(request.include)
: Array.from(controller.items);
const leafIds: string[] = [];
for (const item of include) {
collectLeafIds(item, leafIds);
}
if (leafIds.length === 0) return [];
return leafIds.map((id) => {
const [filePath, fn] = id.split('::');
const rel = filePath
.replace(root + path.sep, '')
.replace(/\.py$/, '');
const module = rel.split(path.sep).join('.');
return fn ? `${module}.${fn}` : module;
});
}执行测试
typescript
// runner.ts(续)
export async function runTests(ctx: RunContext) {
const { controller, request, token } = ctx;
const run = controller.createTestRun(request);
const start = Date.now();
const targets = resolveTargets(controller, request);
if (targets.length === 0) {
run.appendOutput('没有发现可运行的测试\n');
run.ended();
return;
}
// 构造命令:单测用模块点路径,全量用 discover
const args = targets.length === 1
? ['-m', 'unittest', targets[0], '-v']
: ['-m', 'unittest', 'discover', '-s', '.', '-p', 'test_*.py', '-v'];
run.appendOutput(`执行: python ${args.join(' ')}\n`);
// spawn 子进程执行
const child = spawn('python', args, {
cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
});
// 用户点击停止 → 杀掉子进程
token.onCancellationRequested(() => child.kill());
// 逐行解析 stdout
const itemMap = buildItemMap(controller);
let buffer = '';
child.stdout.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
run.appendOutput(line + '\n');
handleLine(run, itemMap, line);
}
});
// stderr 原样输出
child.stderr.on('data', (chunk: Buffer) => {
run.appendOutput(chunk.toString());
});
// 进程结束:收尾
child.on('close', (code) => {
run.appendOutput(`\n退出码: ${code ?? -1}\n`);
run.ended({ duration: Date.now() - start });
});
}第四步:解析结果并上报
unittest -v 的标准输出形如:
text
test_add (test_math.MathTests) ... ok
test_sub (test_math.MathTests) ... FAIL
test_skip (test_math.MathTests) ... skipped '需要数据库'解析器把每行结果映射到 TestItem 并上报:
typescript
// runner.ts(续)
import * as vscode from 'vscode';
// 构建 id → TestItem 的映射表
function buildItemMap(
controller: vscode.TestController
): Map<string, vscode.TestItem> {
const map = new Map<string, vscode.TestItem>();
const visit = (item: vscode.TestItem) => {
map.set(`${item.uri?.fsPath}::${item.label}`, item);
item.children.forEach(visit);
};
controller.items.forEach(visit);
return map;
}
// unittest 输出行 → 查找 TestItem
function findItem(
map: Map<string, vscode.TestItem>,
line: string
): vscode.TestItem | undefined {
const m = line.match(/^(\w+) \(([\w.]+)\) \.\.\. (ok|FAIL|ERROR|skipped)/);
if (!m) return undefined;
const [, testName, module] = m;
// 文件名=模块最后一节,构造 id
const filePart = module.split('.')[0];
return map.get(`${filePart}::${testName}`) ??
map.get(`${module}::${testName}`);
}
// 处理单行 unittest 结果
function handleLine(
run: vscode.TestRun,
map: Map<string, vscode.TestItem>,
line: string
) {
const item = findItem(map, line);
if (!item) return;
run.started(item);
const [, , status, skipReason] = line.match(
/^(\w+) \(([\w.]+)\) \.\.\. (ok|FAIL|ERROR|skipped)(?: '?(.*)'?)?$/
) ?? [];
switch (status) {
case 'ok':
run.passed(item);
break;
case 'skipped':
run.skipped(item);
run.appendOutput(`跳过原因: ${skipReason ?? ''}\n`);
break;
case 'FAIL':
// 构造断言失败消息:错误详情在后续 traceback 行,
// 这里先用输出行本身兜底
run.failed(item, [
new vscode.TestMessage(
`断言失败: ${item.label} (详见下方错误输出)`
)
]);
break;
case 'ERROR':
run.errored(item, [new vscode.TestMessage('测试执行出错')]);
break;
}
}关联 traceback 与断言详情
unittest 的失败详情在 FAIL: test_xxx 之后的缩进块里。完整做法是把 traceback 累积起来,ended 前补报给对应测试:
typescript
// runner.ts(续)
// 用 Error 详情块丰富失败消息:收集 FAIL/ERROR 头之后的行
export class OutputCollector {
private current: vscode.TestItem | undefined;
private detailLines: string[] = [];
constructor(
private run: vscode.TestRun,
private map: Map<string, vscode.TestItem>
) {}
feed(line: string) {
// 检测新失败的头部(如 ===== FAIL: test_add (test_math.MathTests) =====)
const header = line.match(/^(FAIL|ERROR): (\w+) \(([\w.]+)\)/);
if (header) {
this.flush();
this.current = this.map.get(`${header[3]}::${header[2]}`) ??
this.map.get(`${header[3].split('.')[0]}::${header[2]}`);
this.detailLines = [line];
return;
}
// 失败详情块的结束标志:非缩进行
if (this.current && !line.startsWith(' ') && line.trim() !== '') {
this.flush();
return;
}
if (this.current) {
this.detailLines.push(line);
}
}
flush() {
if (!this.current || this.detailLines.length === 0) return;
// 提取 traceback 里的源码行: self.assertEqual(1, 2)
const sourceLine = this.detailLines.find(l =>
l.includes('assert') || l.includes('Error')
);
const message = new vscode.TestMessage(
sourceLine ?? this.detailLines[0]
);
if (this.current.range) {
message.location = new vscode.Location(
this.current.uri!,
this.current.range
);
}
this.run.failed(this.current, [message]);
this.current = undefined;
this.detailLines = [];
}
}第五步:组装激活入口
typescript
// extension.ts
import * as vscode from 'vscode';
import { createTestTree } from './tree';
import { runTests } from './runner';
export function activate(context: vscode.ExtensionContext) {
// 1. 创建控制器
const controller = vscode.tests.createTestController(
'pytestRunner',
'Python 单元测试'
);
// 2. 注册 RunProfile:支持单测与全量
controller.createRunProfile(
'运行测试',
vscode.TestRunProfileKind.Run,
(request, token) => runTests({ controller, request, token }),
true
);
// 3. 构建测试树(懒加载)
createTestTree(controller);
// 4. 刷新命令
context.subscriptions.push(
vscode.commands.registerCommand('pytestRunner.refresh', async () => {
await (controller.resolveHandler as any)?.();
vscode.window.showInformationMessage('测试树已刷新');
})
);
// 5. 文件变更时自动刷新(可选增强)
const watcher = vscode.workspace.createFileSystemWatcher(
'**/test_*.py'
);
watcher.onDidChange(() => {
(controller.resolveHandler as any)?.();
});
context.subscriptions.push(controller, watcher);
}
export function deactivate() {}验证流程
text
1. 项目根目录新建 tests/test_math.py:
import unittest
class MathTests(unittest.TestCase):
def test_add(self):
self.assertEqual(1 + 1, 2)
def test_sub(self):
self.assertEqual(5 - 2, 4) # 故意写错
@unittest.skip('需要数据库')
def test_skip(self):
pass
2. F5 启动扩展开发宿主 → 打开测试资源管理器
3. 展开 tests/test_math.py 节点 → 看到三个测试函数
4. 点击文件节点的运行按钮 → 全部运行:
- test_add 绿勾(ok)
- test_sub 红叉(FAIL),点击消息跳转到断言行
- test_skip 灰色跳过
5. 点击 test_add 行内按钮 → 只运行该函数(单测模式)
6. 修改 test_sub 断言后 → 测试树自动刷新能力扩展对照
| 目标能力 | 改造点 |
|---|---|
| 支持 pytest 报告 | 改用 python -m pytest --json-report,解析 JSON |
| 支持 Jest | findTestFiles 匹配 *.test.js,运行器换 npx jest |
| 超时控制 | spawn 后设置 setTimeout 强制 kill |
| 并发运行 | 多 profile 或任务队列拆分 |
| 覆盖率 | 运行命令加 --coverage,解析结果传 run.ended 的 coverage |
这个插件证明了 TestController 的完整工作流:发现(扫描 + 解析)、建模(TestItem 树 + range)、执行(子进程 + 输出解析)、上报(四类结果 + TestMessage 详情)。换一套解析器,同样的骨架就能支持任何测试框架。