TestController 测试 API
测试资源管理器是 VSCode 内置的测试入口:左侧活动栏的烧瓶图标、编辑器内联的播放按钮、状态栏的运行指示。要接入这套体系,插件需要从 vscode.tests.createTestController 开始,构建一棵 TestItem 树,再注册运行配置把测试真正跑起来。
创建测试控制器
createTestController 返回一个 TestController 对象,它是整个测试能力的入口:
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
// controllerId 全工作区唯一,多个扩展各自的控制器靠它区分
const controller = vscode.tests.createTestController(
'pythonTestController', // 唯一 ID,建议用扩展名做前缀
'Python 单元测试' // 显示在测试资源管理器中的名称
);
context.subscriptions.push(controller);
}controllerId 的命名规则与命令 ID 一致:用扩展名做命名空间(如 myext.pythonTests),避免与其他扩展冲突。label 只影响展示,允许重复。
TestController 同时承担三类职责:
| 职责 | 对应 API | 说明 |
|---|---|---|
| 维护测试树 | items、createTestItem、resolveHandler | 测试项的增删与懒加载 |
| 定义运行方式 | createRunProfile | 注册 run / debug / coverage 入口 |
| 承载运行实例 | createTestRun | 返回一次具体的测试运行 |
TestItem 测试项
测试树由 TestItem 构成,每个测试项代表一个文件、一个测试类或一个测试函数。控制器用 createTestItem 创建节点:
// 文件节点:一个文件对应一个 TestItem
const fileItem = controller.createTestItem(
'tests/test_math.py', // id 在同一控制器内必须唯一
'test_math.py', // label 显示文本
vscode.Uri.file('/workspace/tests/test_math.py') // uri 关联源码位置
);
fileItem.canResolveChildren = true; // 子节点懒加载,展开时才解析
// 测试函数节点:挂在文件节点下
const testItem = controller.createTestItem(
'tests/test_math.py::test_add',
'test_add',
vscode.Uri.file('/workspace/tests/test_math.py')
);id、label 与 uri
| 属性 | 含义 | 要点 |
|---|---|---|
id | 唯一标识 | 同一控制器内不得重复;建议用 文件路径::函数名 这类可读格式 |
label | 树中显示的文本 | 保持简短 |
uri | 源码位置 | 双击测试项会打开对应文件;可省略,但省略后无法定位源码 |
children | 子节点集合 | 只读,需通过 controller.createTestItem 生成后再 children.add |
parent | 父节点 | 加入 children 后自动设置,只读 |
range 与位置关联
range 让编辑器知道测试函数在文件中的位置,实现行内 Run Test 按钮与 CodeLens:
// 从解析器拿到函数起始行(假设第 10 行,列 0)
const start = new vscode.Position(10, 0);
const end = start.translate(5); // 函数体到第 15 行
testItem.range = new vscode.Range(start, end);设置 range 后,VSCode 会在函数定义行渲染内联的运行按钮。双击测试项也会跳转到该位置。
kind、tags 与 state
// kind:区分容器与叶子节点
fileItem.kind = vscode.TestItemKind.Collection; // 集合(文件夹/类)
testItem.kind = vscode.TestItemKind.TestCase; // 用例(函数/方法)
// tags:给测试打标签,标签可关联到 RunProfile,实现"只跑冒烟测试"
testItem.tags = [new vscode.TestTag('smoke')];
// state:当前测试结果状态,通常由运行过程自动更新,
// 也可手动设置实现"上次运行结果缓存"
testItem.state = vscode.TestRunState.Passed;TestItemKind 只有两个值:Collection 与 TestCase。标签则是运行筛选的关键:创建 RunProfile 时传入标签数组,资源管理器的筛选器就能按标签过滤测试。
子节点操作
children 是 TestItemCollection,支持批量增删:
// 批量更新:传入 Map 可一次性覆盖
const children = new Map<string, vscode.TestItem>();
children.set(testItem.id, testItem);
fileItem.children.replace(children);
// 单个添加
fileItem.children.add(testItem);
// 删除
fileItem.children.delete(testItem.id);测试发现机制
构建测试树
测试发现即构建 TestItem 树的过程。典型流程:扫描目录 → 解析文件 → 生成节点 → 挂到 controller.items。控制器暴露的 items 是整棵树的根集合:
async function discoverTests(controller: vscode.TestController) {
// 1. 扫描测试目录,找到测试文件
const files = await findTestFiles('tests');
// 2. 每个文件构建一个 TestItem,加入根集合
const rootItems = new Map<string, vscode.TestItem>();
for (const file of files) {
const item = controller.createTestItem(
file.fsPath,
path.basename(file.fsPath),
file
);
item.canResolveChildren = true; // 子节点交给 resolveHandler
item.kind = vscode.TestItemKind.Collection;
rootItems.set(item.id, item);
}
// 3. 一次性替换根集合
controller.items.replace(rootItems);
}refresh 命令
测试资源管理器顶部的刷新按钮触发内部命令 testing.refreshTests。插件可以注册同名命令接管刷新逻辑,也可以提供自定义刷新命令放进 Testing 菜单:
context.subscriptions.push(
vscode.commands.registerCommand('pythonTests.refresh', async () => {
await discoverTests(controller);
vscode.window.showInformationMessage('测试树已刷新');
})
);resolveHandler 懒加载
测试文件可能很多,全部解析代价高。把文件节点设为 canResolveChildren = true 并设置 resolveHandler,只有节点被展开时才解析其子节点:
controller.resolveHandler = async (item: vscode.TestItem) => {
if (!item) {
// 无参数表示刷新根节点:重跑完整发现
await discoverTests(controller);
return;
}
if (item.uri && item.uri.fsPath.endsWith('.py')) {
// 展开文件节点:解析其中的测试函数
const functions = await parseTestFunctions(item.uri);
const children = new Map<string, vscode.TestItem>();
for (const fn of functions) {
const child = controller.createTestItem(
`${item.id}::${fn.name}`,
fn.name,
item.uri
);
child.range = fn.range;
child.kind = vscode.TestItemKind.TestCase;
children.set(child.id, child);
}
item.children.replace(children);
}
};resolveHandler 的参数 item 为 undefined 时代表刷新根节点,这与资源管理器的刷新按钮联动。懒加载模式下根节点只需放入文件列表,真实解析推迟到用户展开时,首次加载速度大幅提升。
TestRunProfile 运行配置
测试树建好了,接下来告诉 VSCode 怎么运行。createRunProfile 注册一种运行方式:
const runProfile = controller.createRunProfile(
'运行测试', // 显示名称
vscode.TestRunProfileKind.Run, // 类型:Run / Debug / Coverage
runHandler, // 回调:真正执行测试
true // isDefault:是否作为默认运行配置
);
context.subscriptions.push(runProfile);三种运行类型
TestRunProfileKind | 用途 |
|---|---|
Run | 常规运行,覆盖率等场景会额外使用 Coverage |
Debug | 调试运行,通常触发调试会话并暂停在断言处 |
Coverage | 与覆盖率工具联动,收集并上报覆盖率数据 |
isDefault 为 true 时,行内按钮直接使用该配置,无需弹出选择菜单。多个 profile 可共存(如"运行全部"与"只跑冒烟")。
runHandler 回调
async function runHandler(
request: vscode.TestRunRequest, // 包含要运行的测试集合
token: vscode.CancellationToken // 用户点击"停止"时取消
) {
// 1. 确定本次要运行的测试
const tests = collectTests(request);
// 2. 创建 TestRun 实例(下文详述)
const run = controller.createTestRun(request);
// 3. 逐项执行并报告结果
for (const test of tests) {
if (token.isCancellationRequested) break; // 尊重取消
await executeTest(run, test);
}
// 4. 结束运行
run.ended();
}TestRunRequest 携带关键信息:
| 字段 | 含义 |
|---|---|
include | 显式选择的测试(TestItem[]),空表示全部 |
exclude | 需要排除的测试(如标记跳过的) |
profile | 触发本次运行的 RunProfile |
continuous | 是否为持续监视模式 |
collectTests 需要把 include 中的集合节点展开为叶子用例:若 include 为 undefined 则取控制器根集合下的全部叶子,否则遍历 include 中每个 TestItem 及其子孙节点。
TestRun 生命周期
createTestRun 返回的 TestRun 是本次运行的进度与结果通道,遵循固定的生命周期:
// 1. 标记某个测试开始执行
run.started(testItem);
// 2. 输出运行日志(实时显示在测试输出面板)
run.appendOutput(`运行 test_add ...\n`);
// 3. 报告结果
run.passed(testItem); // 通过
run.skipped(testItem); // 跳过
run.failed(testItem, [new vscode.TestMessage('断言失败:1 != 2')]); // 失败
// 4. 全部结束后必须调用 ended
run.ended();生命周期顺序强制:started → passed/failed/skipped → ended。ended 必须在所有结果上报完成后调用一次,漏调会导致资源管理器的运行状态一直悬挂。
export function activate(context: vscode.ExtensionContext) {
const controller = vscode.tests.createTestController('demo', 'Demo Tests');
const profile = controller.createRunProfile('Run', vscode.TestRunProfileKind.Run, async (request, token) => {
const run = controller.createTestRun(request);
const test = request.include?.[0];
if (test) {
run.started(test);
// 模拟执行
await new Promise(r => setTimeout(r, 500));
run.passed(test);
}
run.ended();
}, true);
context.subscriptions.push(controller, profile);
}与编辑器联动
range、uri、state 三个属性共同支撑编辑器交互:
| 能力 | 依赖属性 | 行为 |
|---|---|---|
| 行内运行按钮 | range + RunProfile | 在函数定义行渲染播放图标 |
| 双击定位 | uri + range | 打开文件并滚动到测试函数 |
| 结果状态回显 | state | 测试项图标变为对勾/叉号,显示错误数量 |
把测试发现、运行配置与运行生命周期串起来,一个测试扩展的最小闭环就成立了。结果如何更精细地上报——断言详情、输出日志、覆盖率——是下一篇文章的内容。