在 VS Code 中试用

测试 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 集合的子项。TestItemTestItem 接口中测试 API 的基础,它是一种通用类型,可以描述代码中存在的测试用例、套件或树状项。它们反过来也可以有自己的 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);
  }
  // ...
});

与诊断类似,何时发现测试主要由扩展控制。一个简单的扩展可能会在激活时监视整个工作区并解析所有文件中的所有测试。然而,对于大型工作区来说,立即解析所有内容可能会很慢。你可以做两件事:

  1. 当文件在编辑器中打开时,通过监视 vscode.workspace.onDidOpenTextDocument 主动发现该文件的测试。
  2. 设置 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...
}

discoverAllFilesInWorkspace 的实现可以使用 VS Code 现有的文件监视功能来构建。当 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 map 中获取该项会正常工作。

对于这个示例,我们只存储每个项的类型

enum ItemType {
  File,
  TestCase
}

const testData = new WeakMap<vscode.TestItem, ItemType>();

const getType = (testItem: vscode.TestItem) => testData.get(testItem)!;

运行测试

测试通过 TestRunProfile 执行。每个配置文件属于一个特定的执行 kind:运行、调试或覆盖。大多数测试扩展在每个组中最多只有一个配置文件,但允许有更多。例如,如果你的扩展在多个平台上运行测试,你可以为平台和 kind 的每种组合设置一个配置文件。每个配置文件都有一个 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,并传递原始请求。请求包含要在测试运行中 include 的测试(如果用户要求运行所有测试则省略)以及可能要从运行中 exclude 的测试。扩展应使用生成的 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.failedTestRun.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 就会请求该文件的更多信息。它通过在 TestRunProfile 上调用扩展定义的 loadDetailedCoverage 方法来完成此操作,该方法带有 TestRunFileCoverageCancellationToken。请注意,测试运行和文件覆盖率实例与 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 数组。这两个对象都包含一个 PositionRange,表示它们在源文件中的位置。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,并将 persist 参数设为 false。在这里传递 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] Create a native TestController 开始。

总而言之,一般步骤如下:

  1. 与其使用测试资源管理器 UI 的 TestHub 检索和注册 TestAdapter,不如调用 const controller = vscode.tests.createTestController(...)

  2. 当发现或重新发现测试时,不要触发 testAdapter.tests,而是创建测试并将其推送到 controller.items 中,例如通过调用 controller.items.replace 并传入一个由 vscode.test.createTestItem 创建的已发现测试数组。请注意,随着测试的更改,你可以修改测试项的属性并更新其子项,更改将自动反映在 VS Code 的 UI 中。

  3. 要初始加载测试,不要等待 testAdapter.load() 方法调用,而是设置 controller.resolveHandler = () => { /* discover tests */ }。有关测试发现如何工作的更多信息,请参阅 发现测试

  4. 要运行测试,你应该创建一个 运行配置文件,其中包含一个调用 const run = controller.createTestRun(request) 的处理函数。不要触发 testStates 事件,而是将 TestItem 传递给 run 上的方法来更新它们的状态。

其他贡献点

testing/item/context 菜单贡献点可用于向测试资源管理器视图中的测试添加菜单项。将菜单项放在 inline 组中以使其内联显示。所有其他菜单项组将显示在通过鼠标右键单击可访问的上下文菜单中。

在菜单项的 when 子句中可以使用其他 上下文键testIdcontrollerIdtestItemHasUri。对于更复杂的 when 场景,如果你希望操作可选择地用于不同的测试项,请考虑使用 in 条件运算符

如果你想在资源管理器中显示一个测试,可以将该测试传递给命令 vscode.commands.executeCommand('vscode.revealTestInExplorer', testItem)