Testing API

Testing API 允許 Visual Studio Code 擴充功能在工作區中探索測試並發布結果。使用者可以在「測試」總管檢視(Test Explorer view)、裝飾項目(decorations)以及指令內執行測試。透過這些新的 API,Visual Studio Code 支援比以往更豐富的輸出與差異比對顯示。

注意:Testing 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);
  }
  // ...
});

與診斷(Diagnostics)類似,何時探索測試主要取決於擴充功能本身。簡單的擴充功能可能會在啟用時監控整個工作區並解析所有檔案中的測試。然而,對於大型工作區而言,立即解析所有內容可能會很慢。取而代之,您可以採取兩件事:

  1. 當檔案在編輯器中開啟時,透過監控 vscode.workspace.onDidOpenTextDocument 來主動探索該檔案的測試。
  2. 設定 item.canResolveChildren = true 並設定 controller.resolveHandler。如果使用者採取行動要求探索測試(例如在「測試」總管中展開項目),就會呼叫此 resolveHandler

以下是此策略在延遲(lazily)解析檔案的擴充功能中可能呈現的樣子:

// 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: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 讓使用者設定測試執行,並在使用者執行此動作時呼叫該處理函式。在此,您可以開啟檔案、顯示快速挑選器(Quick Pick),或執行任何適合您的測試架構的動作。

VS Code 有意將測試設定與除錯或工作設定(task configuration)以不同的方式處理。這些傳統上屬於編輯器或 IDE 的核心功能,並在 .vscode 資料夾中的特殊檔案中進行設定。然而,測試傳統上是透過命令列執行的,大多數測試架構都有現有的設定策略。因此,在 VS Code 中,我們避免重複設定,而是交由擴充功能來處理。

測試輸出

除了傳遞給 TestRun.failedTestRun.errored 的訊息外,您還可以使用 run.appendOutput(str) 來附加一般輸出。此輸出可以透過「測試:顯示輸出」(Test: Show Output)以及 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 包含每個檔案中語句(statements)、分支(branches)與宣告(declarations)的總覆蓋與未覆蓋計數。根據您的執行階段與覆蓋率格式,您可能會看到語句覆蓋率被稱為「行覆蓋率」,或宣告覆蓋率被稱為「函式或方法覆蓋率」。您可以為單一 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 預期回傳一個 Promise,其結果為 DeclarationCoverage 與/或 StatementCoverage 物件陣列。這兩個物件都包含可在原始碼檔案中找到它們的 PositionRangeDeclarationCoverage 物件包含被宣告項目的名稱(例如函式或方法名稱)以及該宣告被進入或呼叫的次數。語句物件包含它們被執行的次數,以及零個或多個相關聯的分支。請參考 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 });
  });
}

測試標籤

有時測試只能在特定的設定下執行,或者根本無法執行。對於這些使用案例,您可以使用測試標籤(Test Tags)。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 遷移

如果您現有的擴充功能使用測試總管(Test Explorer)UI,我們建議您遷移至原生體驗以獲得更多功能與效率。我們整理了一個儲存庫,其中包含了「測試轉接器(Test Adapter)」範例在 Git 歷史紀錄中的遷移範例。您可以從 [1] Create a native TestController 開始,透過選擇提交名稱來檢視每個步驟。

總結來說,一般步驟為:

  1. 呼叫 const controller = vscode.tests.createTestController(...),而不是從測試總管 UI 的 TestHub 擷取並註冊 TestAdapter

  2. 在探索或重新探索測試時,不要觸發 testAdapter.tests,而是建立測試並將其推入 controller.items,例如透過呼叫 controller.items.replace 傳入一個陣列,其中包含呼叫 vscode.test.createTestItem 所建立的已探索測試。請注意,隨著測試變更,您可以修改測試項目的屬性並更新其子項,變更將會自動反映在 VS Code 的 UI 中。

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

  4. 若要執行測試,您應該建立一個 執行設定檔(Run Profile),並搭配一個呼叫 const run = controller.createTestRun(request) 的處理函式。不要觸發 testStates 事件,請將 TestItem 傳遞給 run 上的方法來更新其狀態。

額外的貢獻點

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

額外的 內容鍵(context keys)可用於選單項目的 when 子句:testIdcontrollerIdtestItemHasUri。對於更複雜的 when 場景(例如您希望針對不同的測試項目選擇性提供動作),請考慮使用 in 條件運算子

如果您想在總管中顯示某個測試,可以將該測試傳遞給指令 vscode.commands.executeCommand('vscode.revealTestInExplorer', testItem)

© . This site is unofficial and not affiliated with Microsoft.