TestController 运行与结果
上一篇文章建立了测试树与运行配置,这一篇解决核心问题:测试跑起来之后,结果如何精确上报到测试资源管理器。TestRun 的每个方法都对应一种 UI 呈现,掌握它们的语义才能让失败信息对用户真正有用。
createTestRun 执行测试
createTestRun 是执行测试的入口,它把一次请求转化为可上报的运行实例:
async function runHandler(
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// 确定本次运行涉及的全部叶子测试
const testsToRun = expandTests(request.include ?? Array.from(controller.items));
// 创建运行实例
const run = controller.createTestRun(request);
try {
for (const test of testsToRun) {
if (token.isCancellationRequested) {
break; // 用户点击停止,提前结束
}
await runSingleTest(run, test);
}
} finally {
// 无论成功失败都要调用 ended,否则 UI 一直处于运行中
run.ended();
}
}展开测试集合
request.include 可能包含集合节点(文件、类),需要递归展开为叶子用例。只跑叶子节点,父节点结果由子节点聚合:
function expandTests(items: Iterable<vscode.TestItem>): vscode.TestItem[] {
const leaves: vscode.TestItem[] = [];
const visit = (item: vscode.TestItem) => {
if (item.children.size === 0) {
leaves.push(item);
} else {
item.children.forEach(visit);
}
};
for (const item of items) {
visit(item);
}
return leaves;
}TestRun 结果上报 API
TestRun 提供四类结果方法:
| 方法 | 语义 | UI 表现 |
|---|---|---|
run.passed(item) | 测试通过 | 绿色对勾 |
run.failed(item, messages?) | 测试失败 | 红色叉号,带消息列表 |
run.skipped(item) | 测试被跳过 | 灰色跳过标记 |
run.errored(item, messages?) | 测试出错(无法运行) | 与 failed 类似,但表示框架级错误 |
run.started(test); // 先标记开始
if (skipped) {
run.skipped(test); // 跳过
} else if (error) {
run.errored(test, [new vscode.TestMessage(`执行前报错: ${error}`)]);
} else if (passed) {
run.passed(test); // 通过
} else {
run.failed(test, buildFailureMessages(assertion));
}appendResult 的另一种写法
除了逐方法上报,还可以用 appendResult 批量写入,适合从测试框架的 XML 结果文件中一次性导入:
// 假设从 junit.xml 解析出一条记录
for (const record of parsedResults) {
const test = findItemById(record.id);
if (!test) continue;
const runState: vscode.TestRunState =
record.status === 'passed' ? vscode.TestRunState.Passed
: record.status === 'skipped' ? vscode.TestRunState.Skipped
: vscode.TestRunState.Failed;
// appendResult 需要完整构造 TestResult,比 run.passed 更底层
run.appendResult({
test, // 对应的 TestItem
task: undefined, // 多任务运行时可指定子任务
state: runState, // TestRunState 枚举
duration: record.duration, // 毫秒,显示在结果列表
messages: record.messages?.map(m => new vscode.TestMessage(m))
});
}TestRunState 枚举完整列表:Queued、Running、Passed、Failed、Skipped、Errored。appendResult 是底层统一入口,passed 等方法只是它的便捷封装;用 appendResult 时记得也要先调用 run.started(test)。
TestMessage 断言失败详情
TestMessage 描述一条失败信息,是用户定位问题的第一手材料:
const message = new vscode.TestMessage('断言失败:期望 2,实际得到 1');关联源码位置
location 让失败信息可点击跳转:
const message = new vscode.TestMessage('test_add 断言失败');
// 定位到断言所在行(第 12 行第 8 列)
message.location = new vscode.Location(
vscode.Uri.file('/workspace/tests/test_math.py'),
new vscode.Range(12, 8, 12, 20)
);资源管理器会为带 location 的消息渲染跳转链接,点击直达断言代码。
期望与实际输出
expectedOutput 与 actualOutput 让 VSCode 渲染差异视图(diff),对比失败时一目了然:
const message = new vscode.TestMessage('输出不匹配');
message.expectedOutput = 'sum(1, 2) = 3';
message.actualOutput = 'sum(1, 2) = 4';
message.location = assertionLocation;组合使用
一个失败的测试可以带多条消息,测试框架的输出按行拆分:
function buildFailureMessages(assertion: AssertionResult): vscode.TestMessage[] {
const messages: vscode.TestMessage[] = [];
// 第一行:失败概述
messages.push(new vscode.TestMessage(assertion.reason));
// 第二行:错误堆栈,附带位置
const stack = new vscode.TestMessage(assertion.stackTrace);
stack.location = new vscode.Location(
assertion.uri,
assertion.range
);
messages.push(stack);
// 第三行:期望 vs 实际
if (assertion.expected !== undefined) {
const diff = new vscode.TestMessage('期望值与实际值对比');
diff.expectedOutput = String(assertion.expected);
diff.actualOutput = String(assertion.actual);
messages.push(diff);
}
return messages;
}运行进度报告
appendOutput 输出日志
appendOutput 把运行日志实时送入测试输出面板,支持 ANSI 颜色:
// 正常输出
run.appendOutput('收集测试...\n');
// 带颜色:\x1b[32m 绿色 \x1b[31m 红色 \x1b[0m 重置
run.appendOutput(`\x1b[31mFAIL\x1b[0m test_math.py::test_add\n`);
run.appendOutput(`\x1b[32mPASS\x1b[0m test_math.py::test_sub\n`);输出面板与调试控制台一样支持 ANSI 转义序列,写日志时可用颜色区分失败与成功,但注意不要在输出中夹带不可见字符。
started 与 ended
// 整个运行开始时调用(可选,用于标记"等待中"状态)
// run.started() 无参调用表示开始整个运行
run.started();
for (const test of testsToRun) {
run.started(test); // 每个测试单独标记
...
}
// 结束:标记本次运行的总体统计
run.ended({
duration: Date.now() - startTime, // 总耗时
// 其他统计字段由 VSCode 从各项结果自动汇总
});started 与 ended 成对出现,ended 必须调用且只能调用一次。传入 duration 后,历史测试结果列表会显示运行时长。
测试覆盖率集成
覆盖率通过 TestCoverage 与 TestRunCoverage 上报,VSCode 渲染到编辑器内联色条:
// run.ended 之前调用:上报本次运行的覆盖率
run.ended({ coverage: [
{
uri: vscode.Uri.file('/workspace/src/math.py'),
statements: new vscode.TestCoverage(
40, // covered 语句数
50, // 总语句数
80 // 分支覆盖百分比(0-100)
),
branches: new vscode.TestCoverage(15, 20, 75)
}
] });TestCoverage 构造参数为 (covered: number, total: number, coveredPercent: number),第三个参数会显示在编辑器装饰中。
覆盖率数据通常来自覆盖率工具(如 Python 的 coverage.py、JS 的 c8)。插件负责把工具输出转成 TestRunCoverage:
// 解析 coverage.py 的 json 报告
async function parseCoverage(jsonPath: string): Promise<vscode.TestRunCoverage[]> {
const raw = JSON.parse(await vscode.workspace.fs.readFile(vscode.Uri.file(jsonPath)));
const result: vscode.TestRunCoverage[] = [];
for (const [file, data] of Object.entries(raw.files)) {
const uri = vscode.Uri.file(path.join(rootDir, file));
result.push({
uri,
statements: new vscode.TestCoverage(
data.executed_lines.length,
data.num_statements,
Math.round((data.executed_lines.length / data.num_statements) * 100)
),
branches: new vscode.TestCoverage(0, 0, 0)
});
}
return result;
}完整的覆盖率集成还可以通过 controller.onDidChangeCoverage 响应 TestCoverageProvider 查询,把历史覆盖率持久化显示。
完整示例:带结果上报的运行
把以上 API 组合成一个完整的最小运行器:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const controller = vscode.tests.createTestController('mathRunner', 'Math Tests');
// 注册 RunProfile
controller.createRunProfile('运行测试', vscode.TestRunProfileKind.Run, async (request, token) => {
const run = controller.createTestRun(request);
const start = Date.now();
run.appendOutput('开始运行测试\n');
// 展开测试集合
const tests: vscode.TestItem[] = [];
for (const item of request.include ?? []) {
collectLeaves(item, tests);
}
for (const test of tests) {
if (token.isCancellationRequested) break;
run.started(test);
run.appendOutput(`运行 ${test.label} ...\n`);
const result = await execute(test); // 调用底层测试框架
if (result.skipped) {
run.skipped(test);
} else if (result.passed) {
run.passed(test, result.duration);
} else {
run.failed(test, buildFailureMessages(result));
}
run.appendOutput(`\x1b[32m完成\x1b[0m ${test.label}\n`);
}
// 结束并附上总耗时
run.ended({ duration: Date.now() - start });
}, true);
context.subscriptions.push(controller);
}
// 收集叶子节点
function collectLeaves(item: vscode.TestItem, out: vscode.TestItem[]) {
if (item.children.size === 0) {
out.push(item);
} else {
item.children.forEach(c => collectLeaves(c, out));
}
}结果上报的完整链路就此打通:started 标记开始、appendOutput 输出过程、四类结果方法标记终态、ended 收尾,配合 TestMessage 的定位与差异信息,测试失败从"一行红字"变成"可点击、可对比、可跳转"的完整诊断。