解析與執行 PHPUnit 和 Pest 測試的核心函式庫。
供 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(
progress、collision、pretty)與 ANSI 色彩支援 - 測試集合 — 管理測試階層(suite / file / class / method / dataset)
- Coverage — 解析 Clover XML 覆蓋率報告
- 執行檔偵測 — 從
composer.json自動偵測vendor/bin/phpunit或vendor/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 │
│ (輸出目標) │
└──────────────┘
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" }
從 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\"" |
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"
當 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、純物件或任何其他型別使用。
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.wasm 和 tree-sitter-php.wasm 到 dist/,讓 tree-sitter 在執行時能找到它們。
pnpm test # vitest