Skip to content

Latest commit

 

History

History
388 lines (318 loc) · 14.8 KB

File metadata and controls

388 lines (318 loc) · 14.8 KB

@vscode-phpunit/phpunit

解析與執行 PHPUnitPest 測試的核心函式庫。

PHPUnit & Pest Test Explorer VS Code 擴充套件使用。

功能

  • 測試解析 — 透過 tree-sitter(WASM)和 php-parser 靜態分析 PHPUnit/Pest 測試檔案
  • Data Provider 解析 — 從 #[DataProvider]#[TestWith]->with() 等語法萃取 dataset 名稱(詳情);靜態分析無法解析的 dataset 會在執行時從 testStarted 事件動態補齊
  • PHPUnit XML — 解析 phpunit.xml / phpunit.xml.dist 的 testsuite、coverage 與設定
  • 指令建構 — 建構 PHPUnit/Pest 命令列,支援 filter encoding、Xdebug、路徑映射
  • 測試輸出解析 — 將 Teamcity 格式輸出解析為結構化測試結果
  • 格式化輸出 — 可設定的 Printer,提供格式字串 preset(progresscollisionpretty)與 ANSI 色彩支援
  • 測試集合 — 管理測試階層(suite / file / class / method / dataset)
  • Coverage — 解析 Clover XML 覆蓋率報告
  • 執行檔偵測 — 從 composer.json 自動偵測 vendor/bin/phpunitvendor/bin/pest

安裝

npm install @vscode-phpunit/phpunit
#
pnpm add @vscode-phpunit/phpunit

架構

                        ┌──────────────┐
                        │  PHP 原始碼   │
                        └──────┬───────┘
                               │
                        ┌──────▼───────┐
                        │  AstParser   │
                        │ (TreeSitter  │
                        │  / PhpParser)│
                        └──────┬───────┘
                               │ AstNode
                        ┌──────▼───────┐
                        │  Interpreter │
                        │  (Visitors + │
                        │   Resolvers) │
                        └──────┬───────┘
                               │ FileInfo
               ┌───────────────┼───────────────┐
               │               │               │
        ┌──────▼───────┐         ┌──────▼───────┐
        │ TestExtractor │         │ TestCollection│
        │ → TestDef[]   │         │ (檔案變更追蹤 │
        └──────┬───────┘         │  + 繼承解析)  │
               │                 └──────────────┘
        ┌──────▼──────────────────┐
        │    TestDefinition[]    │
        │  (樹狀結構: namespace → │
        │   class → method →     │
        │   dataset)             │
        └────────────┬───────────┘
                     │
          ┌──────────┼──────────┐
          │                     │
   ┌──────▼───────┐     ┌──────▼───────┐
   │ ProcessBuilder│     │ TestHierarchy│
   │ + FilterStrat.│     │ Builder<T>   │
   │ → 命令列      │     │ → UI 樹      │
   └──────┬───────┘     └──────────────┘
          │
   ┌──────▼───────┐
   │  TestRunner   │──── 事件 ─────┐
   │  (子程序執行)  │                │
   └──────┬───────┘         ┌──────▼───────┐
          │ stdout          │  Observers   │
   ┌──────▼───────┐         └──────┬───────┘
   │ TestOutput   │                │
   │ Parser       │         ┌──────▼───────┐
   │ (Teamcity)   │         │   Printer    │
   └──────────────┘         │ (格式化 +    │
                            │  ANSI 色彩)  │
                            └──────┬───────┘
                            ┌──────▼───────┐
                            │ OutputWriter │
                            │ (輸出目標)    │
                            └──────────────┘

使用方式

1. 解析與追蹤測試檔案

TestCollection 是主要進入點。它將 PHP 測試檔案解析為 TestDefinition 樹、內部處理類別繼承與 trait 解析,並維護依 testsuite 分組的持久化註冊表。

import {
  initTreeSitter,
  ChainAstParser,
  TreeSitterAstParser,
  PhpParserAstParser,
  PHPUnitXML,
  TestParser,
  TestCollection,
} from '@vscode-phpunit/phpunit';
import { URI } from 'vscode-uri';

// 1. 初始化 tree-sitter WASM(只需一次)
await initTreeSitter();

// 2. 載入設定
const phpUnitXML = new PHPUnitXML();
await phpUnitXML.loadFile('/path/to/phpunit.xml');

// 3. 建立解析器,使用 AST 鏈(tree-sitter → php-parser 備援)
const astParser = new ChainAstParser([
  new TreeSitterAstParser(),
  new PhpParserAstParser(),
]);
const testParser = new TestParser(phpUnitXML, astParser);

// 4. 建立集合(內部處理繼承解析)
const testCollection = new TestCollection(phpUnitXML, testParser);

// 5. 檔案變更時:
const result = await testCollection.change(URI.file('/path/to/tests/ExampleTest.php'));
// result.parsed — [{uri, tests: TestDefinition[]}]  (新增/更新)
// result.deleted — [File]                             (已移除)

// 查詢既有測試
testCollection.has(uri);        // 檢查檔案是否被追蹤
testCollection.get(uri);        // 取得檔案的測試
testCollection.gatherFiles();   // 遍歷所有追蹤的檔案
testCollection.reset();         // 清除全部

輸出結構:

TestDefinition[]
├─ { type: namespace, label: "App\\Tests", children: [...] }
│  ├─ { type: class, label: "ExampleTest", children: [...] }
│  │  ├─ { type: method, label: "test_add", annotations: { dataset: [...] } }
│  │  └─ { type: method, label: "test_subtract" }

2. 執行測試與解析輸出

TestDefinition 建構命令列,執行 PHPUnit/Pest,透過 observer 模式接收結構化結果。

import {
  ProcessBuilder,
  FilterStrategyFactory,
  TestRunner,
  TestRunnerEvent,
  TeamcityEvent,
} from '@vscode-phpunit/phpunit';

// 1. 建構命令
const builder = new ProcessBuilder(configuration, { cwd: projectRoot }, pathReplacer);
const filter = FilterStrategyFactory.create(testDefinition);
builder.setArguments(filter.getFilter());
// → php vendor/bin/phpunit --filter="^ExampleTest::test_add" --teamcity --colors=never

// 2. 建立 runner 並監聽事件
const runner = new TestRunner();

// Teamcity 事件 — 結構化測試結果
runner.on(TeamcityEvent.testStarted, (result) => {
  console.log('開始:', result.name, result.id);
});
runner.on(TeamcityEvent.testFailed, (result) => {
  console.log('失敗:', result.name, result.message);
  // result.details — [{file, line}] 堆疊追蹤
  // result.actual / result.expected — 比較失敗時
});
runner.on(TeamcityEvent.testFinished, (result) => {
  console.log('通過:', result.name, `${result.duration}ms`);
});
runner.on(TeamcityEvent.testResultSummary, (result) => {
  console.log(`測試: ${result.tests}, 失敗: ${result.failures}`);
});

// Runner 生命週期事件
runner.on(TestRunnerEvent.run, (builder) => {
  console.log('命令:', builder.toString());
});
runner.on(TestRunnerEvent.close, (code) => {
  console.log('結束代碼:', code);
});

// 3. 執行
const process = runner.run(builder);
await process.run();

事件流程:

啟動子程序
  │
  ├─ TestRunnerEvent.run          (命令已啟動)
  │
  ├─ TeamcityEvent.testVersion    (PHPUnit 11.5.0)
  ├─ TeamcityEvent.testRuntime    (PHP 8.3.0)
  ├─ TeamcityEvent.testCount      (總計: 42)
  │
  ├─ TeamcityEvent.testSuiteStarted
  │  ├─ TeamcityEvent.testStarted
  │  ├─ TeamcityEvent.testFinished   (或 testFailed / testIgnored)
  │  └─ ...
  ├─ TeamcityEvent.testSuiteFinished
  │
  ├─ TeamcityEvent.testDuration      (時間: 0.123s, 記憶體: 24MB)
  ├─ TeamcityEvent.testResultSummary (測試: 42, 失敗: 1)
  │
  └─ TestRunnerEvent.close           (結束代碼)

各測試類型的 filter 策略:

TestDefinition type 產生的 filter
workspace (執行全部)
testsuite --testsuite=Unit
namespace --filter="^(App\\Tests\\Unit.*)"
class tests/ExampleTest.php
method --filter="^ExampleTest::test_add"
dataset --filter="^...with data set \"one\""

3. 建構 UI 樹(泛型)

TestHierarchyBuilder<T> 將扁平的 TestDefinition[] 轉換為巢狀樹以供顯示。處理 namespace 拆分、dataset 展開、多 suite 分組 — 全部與編輯器無關。

import {
  TestHierarchyBuilder,
  type ItemCollection,
  type TestRange,
  type TestTreeItem,
} from '@vscode-phpunit/phpunit';

// 為你的 UI 框架實作介面
class MyItem implements TestTreeItem<MyItem> {
  id: string;
  children: ItemCollection<MyItem>;
  canResolveChildren = false;
  sortText?: string;
  range?: TestRange;
  tags: Array<{ id: string }> = [];
  constructor(id: string, public label: string) {
    this.id = id;
    this.children = new MyCollection();
  }
}

// 繼承抽象 builder
class MyBuilder extends TestHierarchyBuilder<MyItem> {
  protected createItem(id: string, label: string, uri?: string): MyItem {
    return new MyItem(id, label);
  }
  protected createTag(id: string) { return { id }; }
  protected createRange(def: TestDefinition) { /* ... */ }
}

// 建構樹
const builder = new MyBuilder(rootCollection, phpUnitXML);
const itemMap = builder.build(tests);
// itemMap: Map<MyItem, TestDefinition>

樹轉換:

輸入(扁平):                       輸出(巢狀):
─────────────────                    ─────────────────
namespace: App\Tests\Unit            App
  class: ExampleTest                   └─ Tests
    method: test_add                       └─ Unit
      dataset: ["one","two"]                   └─ ExampleTest
                                                   └─ test_add
                                                       ├─ with data set "one"
                                                       └─ with data set "two"

4. 執行期間依 ID 反查測試項目(AliasMap

當 Teamcity 事件帶來測試結果時,你需要依 ID 找回對應的 UI 項目。AliasMap<T>Map<string, T> 的直接替代品,能自動處理 Pest v3 的一個已知 bug。

問題根源:Pest v3 的 Str::beforeLast() 混用了 mb_strrpos(字元偏移)與 substr(位元組偏移)。(U+2192)是 3 個 UTF-8 位元組但只算 1 個字元,導致 testSuiteStarted / testSuiteFinished 事件的 ID 每出現一個 就被截短 2 個位元組 — 直接用 Map.get() 就會找不到項目。

解法:改用 AliasMap。每次呼叫 set() 時,它會自動同時登錄截短版的別名 ID,讓 get() 無論收到哪個版本都能命中。

import { AliasMap } from '@vscode-phpunit/phpunit';

// 從測試項目建立 — 截短別名自動登錄
const testItemById = new AliasMap<MyItem>(
    items.map((item) => [item.id, item]),
);

// 無論完整 ID 或 Pest v3 截短 ID 都能查到
const fullId      = 'tests/Unit/Foo.php::`something` → it passes';
const truncatedId = 'tests/Unit/Foo.php::`something` → it pass';  // Pest v3 截短版

testItemById.get(fullId);      // → MyItem  ✓
testItemById.get(truncatedId); // → MyItem  ✓  (別名自動登錄)

AliasMap 與框架無關,可搭配 VS Code TestItem、純物件或任何其他型別使用。

5. 格式化測試輸出(Printer)

Printer 將結構化測試事件轉換為可讀的輸出,支援可設定的模板與 ANSI 色彩。輸出透過 OutputWriter 介面寫入,讓 Printer 與具體輸出目標解耦。

import {
  Printer,
  PHPUnitXML,
  PRESET_PROGRESS,
  PRESET_COLLISION,
  PRESET_PRETTY,
  resolveFormat,
  type OutputWriter,
} from '@vscode-phpunit/phpunit';

// 1. 選擇 preset
const phpUnitXML = new PHPUnitXML();
const printer = new Printer(phpUnitXML, PRESET_COLLISION);

// 2. 為輸出目標實作 OutputWriter
class ConsoleWriter implements OutputWriter {
  append(text: string) { process.stdout.write(text); }
  appendLine(text: string) { process.stdout.write(text + '\n'); }
}
const writer = new ConsoleWriter();

// 3. 接入 TestRunner observer 事件
printer.start(command);               // → "php vendor/bin/phpunit ..."
printer.testVersion(result);          // → "🚀 PHPUnit 11.5.0"
printer.testSuiteStarted(result);     // → "PASS  App\Tests\ExampleTest"
printer.testFinished(result);         // → "  ✓ test_add  3 ms"(失敗時為 "  ⨯ test_sub  5 ms")
printer.testResultSummary(result);    // → "Tests:  1 failed, 3 passed (12 assertions)"
printer.timeAndMemory(result);        // → "Duration: 0.05s"
printer.close();                      // 清空延遲的錯誤詳情

內建 preset:

Preset 風格 輸出範例
PRESET_PROGRESS PHPUnit 預設 dot 模式 ...F..S.
PRESET_COLLISION Collision 風格 ✓ test_name 3 ms
PRESET_PRETTY Collision 不含 icon test_name 3 ms

透過 resolveFormat 自訂:

const format = resolveFormat('collision', {
  colors: false,                    // 停用 ANSI 色彩
  icons: { passed: ['✔', 'OK'] },  // 覆寫特定 icon
  duration: false,                  // 隱藏時間行
});
const printer = new Printer(phpUnitXML, format);

格式模板變數:

模板使用 {variable} 佔位符。例如 finished: ' {icon} {name} {duration} ms' 產生 ✓ test_add 3 ms。可用變數依事件類型而異,完整列表請參考 PrinterFormat

建置

pnpm build     # tsup → dist/(ESM + CJS + .d.ts)

建置時會複製 tree-sitter.wasmtree-sitter-php.wasmdist/,讓 tree-sitter 在執行時能找到它們。

測試

pnpm test      # vitest

文件

授權

MIT