🚀 在 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 集合。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);
  }
  // ...
});

與診斷類似,何時探索測試主要由擴充功能控制。簡單的擴充功能可能會監看整個工作區,並在啟動時剖析所有檔案中的所有測試。但是,立即剖析所有內容對於大型工作區來說可能會很慢。相反地,您可以執行兩件事

  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:run、debug 或 coverage。大多數測試擴充功能在這些群組中的每一個群組中最多只有一個設定檔,但允許更多。例如,如果您的擴充功能在多個平台上執行測試,您可以針對平台和 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 就會要求該檔案的更多資訊。它透過使用 TestRunFileCoverageCancellationToken,在 TestRunProfile 上呼叫擴充功能定義的 loadDetailedCoverage 方法來執行此操作。請注意,測試執行和檔案覆蓋率執行個體與 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,並將 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 開始。

總之,一般步驟如下

  1. 不要使用測試總管 UI 的 TestHub 檢索和註冊 TestAdapter,而是呼叫 const controller = vscode.tests.createTestController(...)

  2. 不要在探索或重新探索測試時觸發 testAdapter.tests,而是建立測試並將其推送到 controller.items 中,例如透過使用由呼叫 vscode.test.createTestItem 建立的探索測試陣列呼叫 controller.items.replace。請注意,隨著測試變更,您可以變更測試項目上的屬性並更新其子項目,變更將自動反映在 VS Code 的 UI 中。

  3. 若要最初載入測試,不要等待 testAdapter.load() 方法呼叫,而是設定 controller.resolveHandler = () => { /* 探索測試 */ }。請參閱 探索測試 中有關測試探索如何運作的更多資訊。

  4. 若要執行測試,您應該使用處理常式函數建立 執行設定檔,該函數呼叫 const run = controller.createTestRun(request)。不要觸發 testStates 事件,而是將 TestItem 傳遞給 run 上的方法以更新其狀態。

其他貢獻點

testing/item/context 選單貢獻點可用於將選單項目新增至測試總管檢視中的測試。將選單項目放在 inline 群組中以使其內嵌。所有其他選單項目群組將顯示在使用滑鼠右鍵可存取的內容選單中。

其他內容金鑰可在選單項目的 when 子句中使用:testIdcontrollerIdtestItemHasUri。對於更複雜的 when 情境,您希望動作對於不同的測試項目是可選的,請考慮使用 in 條件運算子

如果您想要在總管中顯示測試,您可以將測試傳遞至命令 vscode.commands.executeCommand('vscode.revealTestInExplorer', testItem)