VS Codeのエージェントモードを拡張するには、を試してください!

テストAPI

テストAPIを使用すると、Visual Studio Code拡張機能はワークスペース内のテストを検出し、結果を公開できます。ユーザーは、テストエクスプローラービュー、デコレーション、およびコマンド内からテストを実行できます。これらの新しいAPIにより、Visual Studio Codeは、これまでよりもリッチな出力と差分表示をサポートします。

: テストAPIはVS Codeバージョン1.59以降で利用可能です。

VS Codeチームによって管理されているテストプロバイダーが2つあります。

テストの検出

テストは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);
  }
  // ...
});

診断機能と同様に、テストがいつ検出されるかを制御するのは主に拡張機能に委ねられます。単純な拡張機能は、ワークスペース全体を監視し、アクティベーション時にすべてのファイルのすべてのテストを解析するかもしれません。しかし、大規模なワークスペースでは、すべてをすぐに解析すると遅くなる可能性があります。その代わりに、次の2つのことができます。

  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マップからアイテムを取得できることが確実です。

この例では、各アイテムのタイプのみを格納しましょう。

enum ItemType {
  File,
  TestCase
}

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

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

テストの実行

テストはTestRunProfileを介して実行されます。各プロファイルは、特定の実行kind(実行、デバッグ、カバレッジ)に属します。ほとんどのテスト拡張機能では、これらの各グループに最大1つのプロファイルがありますが、複数持つことも可能です。例えば、拡張機能が複数のプラットフォームでテストを実行する場合、プラットフォームとkindの組み合わせごとに1つのプロファイルを持つことができます。各プロファイルには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に加えて、TestRunProfileconfigureHandlerを設定できます。これが存在する場合、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はそのファイルに関する詳細情報を要求します。これは、TestRunProfileで拡張機能が定義したloadDetailedCoverageメソッドを、TestRunFileCoverage、および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オブジェクトの配列へのプロミスを返すことが期待されます。どちらのオブジェクトも、ソースファイル内で見つかるPositionまたはRangeを含みます。DeclarationCoverageオブジェクトには、宣言されているもの(関数名やメソッド名など)の名前と、その宣言が入力または呼び出された回数が含まれます。ステートメントには、実行された回数と、0個以上の関連するブランチが含まれます。詳細については、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や概要ファイルなどの外部ソースから結果をロードするコントローラーです。

この場合、これらのコントローラーは通常、createTestRunにオプションのname引数を渡し、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のTestHubTestAdapterを取得および登録する代わりに、const controller = vscode.tests.createTestController(...)を呼び出します。

  2. テストを検出または再検出したときにtestAdapter.testsを発生させるのではなく、vscode.test.createTestItemを呼び出して作成された検出済みテストの配列でcontroller.items.replaceを呼び出すなどして、テストを作成し、controller.itemsにプッシュします。テストが変更されると、テストアイテムのプロパティを変更したり、その子を更新したりでき、変更はVS CodeのUIに自動的に反映されます。

  3. テストを最初にロードするには、testAdapter.load()メソッドの呼び出しを待つのではなく、controller.resolveHandler = () => { /* discover tests */ }を設定します。テスト検出の動作に関する詳細については、テストの検出を参照してください。

  4. テストを実行するには、const run = controller.createTestRun(request)を呼び出すハンドラー関数を持つ実行プロファイルを作成する必要があります。testStatesイベントを発生させる代わりに、TestItemrunのメソッドに渡して状態を更新します。

その他のコントリビューションポイント

testing/item/context メニューコントリビューションポイントは、テストエクスプローラービューのテストにメニュー項目を追加するために使用できます。メニュー項目をinlineグループに配置すると、インラインで表示されます。その他のすべてのメニュー項目グループは、マウスの右クリックでアクセスできるコンテキストメニューに表示されます。

メニュー項目のwhen句では、追加のコンテキストキーtestIdcontrollerIdtestItemHasUri)が利用できます。異なるテスト項目でアクションをオプションで利用可能にしたい、より複雑なwhenシナリオでは、in条件演算子の使用を検討してください。

エクスプローラーでテストを表示したい場合は、vscode.commands.executeCommand('vscode.revealTestInExplorer', testItem)コマンドにテストを渡すことができます。