测试 API
测试 API 允许 Visual Studio Code 扩展在工作区中发现测试并发布结果。用户可以在测试资源管理器视图、装饰器和命令内部执行测试。有了这些新的 API,Visual Studio Code 支持比以前更丰富的输出和差异显示。
注意:测试 API 在 VS Code 1.59 及更高版本中可用。
示例
VS Code 团队维护着两个测试提供程序
发现测试
测试由 TestController
提供,它需要一个全局唯一的 ID 和人类可读的标签才能创建
const controller = vscode.tests.createTestController(
'helloWorldTests',
'Hello World Tests'
);
要发布测试,您可以将 TestItem
作为子项添加到控制器的 items
集合中。TestItem
是测试 API 的基础,在 TestItem
接口中,它们是一个通用类型,可以描述测试用例、套件或代码中存在的树项。它们本身也可以有 children
,从而形成层次结构。例如,这是示例测试扩展如何创建测试的简化版本
parseMarkdown(content, {
onTest: (range, numberA, mathOperator, numberB, expectedValue) => {
// If this is a top-level test, add it to its parent's children. If not,
// add it to the controller's top level items.
const collection = parent ? parent.children : controller.items;
// Create a new ID that's unique among the parent's children:
const id = [numberA, mathOperator, numberB, expectedValue].join(' ');
// Finally, create the test item:
const test = controller.createTestItem(id, data.getLabel(), item.uri);
test.range = range;
collection.add(test);
}
// ...
});
与诊断类似,主要由扩展控制何时发现测试。一个简单的扩展可能会监视整个工作区,并在激活时解析所有文件中的所有测试。但是,立即解析所有内容对于大型工作区来说可能很慢。相反,您可以执行两项操作
- 当文件在编辑器中打开时,通过监视
vscode.workspace.onDidOpenTextDocument
来主动发现文件的测试。 - 设置
item.canResolveChildren = true
并设置controller.resolveHandler
。如果用户采取操作来要求发现测试,例如通过展开测试资源管理器中的项目,则会调用resolveHandler
。
以下是如何在延迟解析文件的扩展中实现此策略
// First, create the `resolveHandler`. This may initially be called with
// "undefined" to ask for all tests in the workspace to be discovered, usually
// when the user opens the Test Explorer for the first time.
controller.resolveHandler = async test => {
if (!test) {
await discoverAllFilesInWorkspace();
} else {
await parseTestsInFileContents(test);
}
};
// When text documents are open, parse tests in them.
vscode.workspace.onDidOpenTextDocument(parseTestsInDocument);
// We could also listen to document changes to re-parse unsaved changes:
vscode.workspace.onDidChangeTextDocument(e => parseTestsInDocument(e.document));
// In this function, we'll get the file TestItem if we've already found it,
// otherwise we'll create it with `canResolveChildren = true` to indicate it
// can be passed to the `controller.resolveHandler` to gets its children.
function getOrCreateFile(uri: vscode.Uri) {
const existing = controller.items.get(uri.toString());
if (existing) {
return existing;
}
const file = controller.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri);
file.canResolveChildren = true;
return file;
}
function parseTestsInDocument(e: vscode.TextDocument) {
if (e.uri.scheme === 'file' && e.uri.path.endsWith('.md')) {
parseTestsInFileContents(getOrCreateFile(e.uri), e.getText());
}
}
async function parseTestsInFileContents(file: vscode.TestItem, contents?: string) {
// If a document is open, VS Code already knows its contents. If this is being
// called from the resolveHandler when a document isn't open, we'll need to
// read them from disk ourselves.
if (contents === undefined) {
const rawContent = await vscode.workspace.fs.readFile(file.uri);
contents = new TextDecoder().decode(rawContent);
}
// some custom logic to fill in test.children from the contents...
}
可以使用 VS Code 现有的文件监视功能构建 discoverAllFilesInWorkspace
的实现。当调用 resolveHandler
时,您应该继续监视更改,以便测试资源管理器中的数据保持最新。
async function discoverAllFilesInWorkspace() {
if (!vscode.workspace.workspaceFolders) {
return []; // handle the case of no open folders
}
return Promise.all(
vscode.workspace.workspaceFolders.map(async workspaceFolder => {
const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.md');
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
// When files are created, make sure there's a corresponding "file" node in the tree
watcher.onDidCreate(uri => getOrCreateFile(uri));
// When files change, re-parse them. Note that you could optimize this so
// that you only re-parse children that have been resolved in the past.
watcher.onDidChange(uri => parseTestsInFileContents(getOrCreateFile(uri)));
// And, finally, delete TestItems for removed files. This is simple, since
// we use the URI as the TestItem's ID.
watcher.onDidDelete(uri => controller.items.delete(uri.toString()));
for (const file of await vscode.workspace.findFiles(pattern)) {
getOrCreateFile(file);
}
return watcher;
})
);
}
TestItem
接口很简单,没有自定义数据的空间。如果您需要将额外信息与 TestItem
关联,可以使用 WeakMap
const testData = new WeakMap<vscode.TestItem, MyCustomData>();
// to associate data:
const item = controller.createTestItem(id, label);
testData.set(item, new MyCustomData());
// to get it back later:
const myData = testData.get(item);
可以保证传递给所有与 TestController
相关的方法的 TestItem
实例将与最初从 createTestItem
创建的实例相同,因此您可以确保从 testData
映射获取项目将起作用。
对于此示例,我们只存储每个项目的类型
enum ItemType {
File,
TestCase
}
const testData = new WeakMap<vscode.TestItem, ItemType>();
const getType = (testItem: vscode.TestItem) => testData.get(testItem)!;
运行测试
通过 TestRunProfile
执行测试。每个配置文件都属于特定的执行 kind
:运行、调试或覆盖率。大多数测试扩展在这些组中的每一个中最多有一个配置文件,但允许更多。例如,如果您的扩展在多个平台上运行测试,则可以为每个平台和 kind
的组合设置一个配置文件。每个配置文件都有一个 runHandler
,当请求该类型的运行时,会调用该 runHandler
。
function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// todo
}
const runProfile = controller.createRunProfile(
'Run',
vscode.TestRunProfileKind.Run,
(request, token) => {
runHandler(false, request, token);
}
);
const debugProfile = controller.createRunProfile(
'Debug',
vscode.TestRunProfileKind.Debug,
(request, token) => {
runHandler(true, request, token);
}
);
runHandler
应该至少调用一次 controller.createTestRun
,并传递原始请求。该请求包含要包含在测试运行中的测试(如果用户要求运行所有测试,则省略),以及可能要从运行中排除的测试。扩展应该使用生成的 TestRun
对象来更新运行中涉及的测试的状态。例如
async function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
const run = controller.createTestRun(request);
const queue: vscode.TestItem[] = [];
// Loop through all included tests, or all known tests, and add them to our queue
if (request.include) {
request.include.forEach(test => queue.push(test));
} else {
controller.items.forEach(test => queue.push(test));
}
// For every test that was queued, try to run it. Call run.passed() or run.failed().
// The `TestMessage` can contain extra information, like a failing location or
// a diff output. But here we'll just give it a textual message.
while (queue.length > 0 && !token.isCancellationRequested) {
const test = queue.pop()!;
// Skip tests the user asked to exclude
if (request.exclude?.includes(test)) {
continue;
}
switch (getType(test)) {
case ItemType.File:
// If we're running a file and don't know what it contains yet, parse it now
if (test.children.size === 0) {
await parseTestsInFileContents(test);
}
break;
case ItemType.TestCase:
// Otherwise, just run the test case. Note that we don't need to manually
// set the state of parent tests; they'll be set automatically.
const start = Date.now();
try {
await assertTestPasses(test);
run.passed(test, Date.now() - start);
} catch (e) {
run.failed(test, new vscode.TestMessage(e.message), Date.now() - start);
}
break;
}
test.children.forEach(test => queue.push(test));
}
// Make sure to end the run after all tests have been executed:
run.end();
}
除了 runHandler
之外,您还可以在 TestRunProfile
上设置 configureHandler
。如果存在,VS Code 将具有 UI,允许用户配置测试运行,并在他们这样做时调用处理程序。在这里,您可以打开文件、显示快速选择,或者执行适合您的测试框架的任何操作。
VS Code 有意以不同于调试或任务配置的方式处理测试配置。这些传统上是以编辑器或 IDE 为中心的功能,并在
.vscode
文件夹中的特殊文件中配置。但是,测试传统上是从命令行执行的,并且大多数测试框架都有现有的配置策略。因此,在 VS Code 中,我们避免重复配置,而是让扩展来处理。
测试输出
除了传递给 TestRun.failed
或 TestRun.errored
的消息之外,您还可以使用 run.appendOutput(str)
追加通用输出。此输出可以使用“测试:显示输出”在终端中显示,并通过 UI 中的各种按钮显示,例如测试资源管理器视图中的终端图标。
由于字符串是在终端中呈现的,因此您可以使用完整的 ANSI 代码,包括 ansi-styles npm 包中可用的样式。请记住,因为它位于终端中,因此必须使用 CRLF (\r\n
) 而不仅仅是 LF (\n
) 来包装行,这可能是某些工具的默认输出。
测试覆盖率
测试覆盖率通过 run.addCoverage()
方法与 TestRun
相关联。规范上,这应该由 TestRunProfileKind.Coverage
的配置文件的 runHandler
完成,但在任何测试运行期间都可以调用它。addCoverage
方法采用一个 FileCoverage
对象,它是该文件中覆盖率数据的摘要
async function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// ...
for await (const file of readCoverageOutput()) {
run.addCoverage(new vscode.FileCoverage(file.uri, file.statementCoverage));
}
}
FileCoverage
包含每个文件中语句、分支和声明的总体覆盖和未覆盖计数。根据您的运行时和覆盖率格式,您可能会看到语句覆盖率称为行覆盖率,或者声明覆盖率称为函数或方法覆盖率。您可以多次为单个 URI 添加文件覆盖率,在这种情况下,新信息将替换旧信息。
一旦用户打开带有覆盖率的文件或在“测试覆盖率”视图中展开文件,VS Code 将请求该文件的更多信息。它通过调用扩展定义的 loadDetailedCoverage
方法在 TestRunProfile
上执行此操作,该方法带有 TestRun
、FileCoverage
和 CancellationToken
。请注意,测试运行和文件覆盖率实例与 run.addCoverage
中使用的实例相同,这对于关联数据非常有用。例如,您可以创建从 FileCoverage
对象到您自己数据的映射
const coverageData = new WeakMap<vscode.FileCoverage, MyCoverageDetails>();
profile.loadDetailedCoverage = (testRun, fileCoverage, token) => {
return coverageData.get(fileCoverage).load(token);
};
async function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// ...
for await (const file of readCoverageOutput()) {
const coverage = new vscode.FileCoverage(file.uri, file.statementCoverage);
coverageData.set(coverage, file);
run.addCoverage(coverage);
}
}
或者,您可以使用包含该数据的实现对 FileCoverage
进行子类化
class MyFileCoverage extends vscode.FileCoverage {
// ...
}
profile.loadDetailedCoverage = async (testRun, fileCoverage, token) => {
return fileCoverage instanceof MyFileCoverage ? await fileCoverage.load() : [];
};
async function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// ...
for await (const file of readCoverageOutput()) {
// 'file' is MyFileCoverage:
run.addCoverage(file);
}
}
loadDetailedCoverage
预计返回一个 DeclarationCoverage
和/或 StatementCoverage
对象数组的 Promise。这两个对象都包含一个 Position
或 Range
,指示它们可以在源文件中找到的位置。DeclarationCoverage
对象包含被声明的事物(例如函数或方法名称)的名称,以及该声明被输入或调用的次数。语句包括它们被执行的次数,以及零个或多个关联的分支。有关详细信息,请参阅 vscode.d.ts
中的类型定义。
在许多情况下,您可能会有测试运行中残留的持久文件。最佳做法是将此类覆盖率输出放在系统的临时目录中(您可以通过 require('os').tmpdir()
检索),但您也可以通过侦听 VS Code 的提示来立即清理它们,即不再需要保留测试运行
import { promises as fs } from 'fs';
async function runHandler(
shouldDebug: boolean,
request: vscode.TestRunRequest,
token: vscode.CancellationToken
) {
// ...
run.onDidDispose(async () => {
await fs.rm(coverageOutputDirectory, { recursive: true, force: true });
});
}
测试标签
有时测试只能在某些配置下运行,或者根本不能运行。对于这些用例,您可以使用测试标签。TestRunProfile
可以选择性地将标签与其关联,如果它们这样做,则只有具有该标签的测试才能在该配置文件下运行。再次强调,如果没有符合条件的配置文件可以从中运行、调试或收集特定测试的覆盖率,则这些选项将不会在 UI 中显示。
// Create a new tag with an ID of "runnable"
const runnableTag = new TestTag('runnable');
// Assign it to a profile. Now this profile can only execute tests with that tag.
runProfile.tag = runnableTag;
// Add the "runnable" tag to all applicable tests.
for (const test of getAllRunnableTests()) {
test.tags = [...test.tags, runnableTag];
}
用户还可以在测试资源管理器 UI 中按标签进行筛选。
仅发布控制器
运行配置文件的存在是可选的。允许控制器创建测试,在 runHandler
之外调用 createTestRun
,并在没有配置文件的情况下更新测试在运行中的状态。这种情况的常见用例是控制器从外部来源(如 CI 或摘要文件)加载其结果。
在这种情况下,这些控制器通常应将可选的 name
参数传递给 createTestRun
,并将 false
传递给 persist
参数。在此处传递 false
指示 VS Code 不要保留测试结果,就像编辑器中的运行一样,因为这些结果可以从外部来源重新加载。
const controller = vscode.tests.createTestController(
'myCoverageFileTests',
'Coverage File Tests'
);
vscode.commands.registerCommand('myExtension.loadTestResultFile', async file => {
const info = await readFile(file);
// set the controller items to those read from the file:
controller.items.replace(readTestsFromInfo(info));
// create your own custom test run, then you can immediately set the state of
// items in the run and end it to publish results:
const run = controller.createTestRun(
new vscode.TestRunRequest(),
path.basename(file),
false
);
for (const result of info) {
if (result.passed) {
run.passed(result.item);
} else {
run.failed(result.item, new vscode.TestMessage(result.message));
}
}
run.end();
});
从测试资源管理器 UI 迁移
如果您有一个使用测试资源管理器 UI 的现有扩展,我们建议您迁移到原生体验,以获得额外的功能和效率。我们整理了一个仓库,其中包含测试适配器示例迁移的示例,可以在其 Git 历史记录 中查看。您可以通过选择提交名称来查看每个步骤,从 [1] 创建一个原生 TestController
开始。
总而言之,一般步骤如下:
-
不要使用测试资源管理器 UI 的
TestHub
来检索和注册TestAdapter
,而是调用const controller = vscode.tests.createTestController(...)
。 -
不要在发现或重新发现测试时触发
testAdapter.tests
,而是创建测试并将其推送到controller.items
中,例如,通过调用controller.items.replace
并传入一个由调用vscode.test.createTestItem
创建的已发现测试数组。请注意,随着测试的更改,您可以修改测试项上的属性并更新其子项,并且更改将自动反映在 VS Code 的 UI 中。 -
要初始加载测试,不要等待
testAdapter.load()
方法调用,而是设置controller.resolveHandler = () => { /* 发现测试 */ }
。有关测试发现如何工作的更多信息,请参阅 发现测试。 -
要运行测试,您应该创建一个具有处理函数的 运行配置文件,该处理函数调用
const run = controller.createTestRun(request)
。不要触发testStates
事件,而是将TestItem
传递给run
上的方法来更新其状态。
其他贡献点
可以使用 testing/item/context
菜单贡献点 将菜单项添加到测试资源管理器视图中的测试。将菜单项放在 inline
组中,使其内联显示。所有其他菜单项组将显示在使用鼠标右键单击访问的上下文菜单中。
其他 上下文键 可在菜单项的 when
子句中使用:testId
、controllerId
和 testItemHasUri
。对于更复杂的 when
场景,其中您希望操作可选择地用于不同的测试项,请考虑使用 in
条件运算符。
如果您想在资源管理器中显示测试,可以将测试传递给命令 vscode.commands.executeCommand('vscode.revealTestInExplorer', testItem)
。