VSCode Extension Development, Run Single Test
隨著 VSCode 開發 Extension 愈趨複雜,相關的單元測試也越來越多。每次增加一個方法以及相對測試,整個套件的測試都得重新執行一遍。為了更有效率的進行開發,目前要解決幾個痛點:
- 無法執行單一檔案的測試。
- 無法執行單一測試案例的測試。
針對以上兩點,逐步說明解決方法。
無法執行單一檔案的測試
當在 VSCode 裡頭透過按下 F5
執行 Extension Tests
的時候,會透過 ./vscode/launch.json
發起另一個 VSCode 的主體並且執行 test/suite/index.js
的內容。而裡頭會將 test/suite
底下所有 .test.js
結尾的檔案,利用 Mocha 加入並執行測試。所以每次執行的時候才會重跑所有的測試。
// https://github.com/microsoft/vscode-extension-samples/blob/main/helloworld-test-sample/src/test/suite/index.ts
glob('**/**.test.js', { cwd: testsRoot }, (err, files) => {
// Add files to the test suite
files.forEach(f => mocha.addFile(path.resolve(testsRoot, f)));
為了執行單一檔案的測試,當前的解決思路:
./vscode/launch.json
添加一組設定,並將當前檔案名稱帶給去。test/suite/index.js
根據當前檔案名稱,只把該檔案加到 Mocha 的測試。- 紀錄這次檔案名稱到
.vscode-test/.previous
作為下次參考。 - 下次執行,如果檔名不是
.test.ts
結尾,透過.vscode-test/.previous
取得上次的測試檔名,重複上次的測試(有時候會一邊修改 source ,一邊執行測試,方便不需要跳回測試檔案,按 F5 也能執行上次的測試)。
首先在 ./vscode/launch.json
添加一組設定名稱為 Test Current/Previous File
。根據原本的 Extension Tests
複製一份,只是在 env
的欄位增加 "TEST_FILE": "{$file}"
。透過 env 環境變數將當前的檔名帶過去。
{
"name": "Test Current/Previous File",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
],
"outFiles": [
"${workspaceFolder}/out/test/**/*.js"
],
"env": {
"EXTENSION_PATH": "${workspaceFolder}",
"TEST_FILE": "${file}",
},
"preLaunchTask": "npm: watch"
}
接著在 test/suite/index.ts
添加相關判斷:
return new Promise(async (c, e) => {
// 保留預設的查找規則
let filePattern = '**/**.test.js';
if (process.env.TEST_FILE) {
const previous = process.env.EXTENSION_PATH + '/.vscode-test/.previous';
/**
* 如果是 test.ts 結尾表示是測試檔,將它寫入到 previous 作為下次參考,
* 反之 previous 存在則讀取出來,作為當前的測試檔案名稱。
*/
if (process.env.TEST_FILE.endsWith('test.ts')) {
filePattern = process.env.TEST_FILE;
fs.writeFile(previous, filePattern, err => null);
} else {
if (fs.existsSync(previous) ) {
filePattern = fs.readFileSync(previous, 'utf8');
}
}
// 由於 TypeScript 編譯後的目錄跟副檔名有變化,需要進行調整。
filePattern = filePattern.replace('src/test/', 'out/test');
filePattern = filePattern.replace('.ts', '.js');
filePattern = filePattern.replace(testsRoot, ''); // 下方會以 testsRoot 為當前目錄,所以須去掉
}
glob(filePattern, { cwd: testsRoot }, (err, files) => {
// ......
}
到這邊算是解決了。小遺憾是 /.vscode-test/.previous
這個臨時檔案,原本是希望 VSCode 關閉之後能自動刪除掉。但目前沒有具體做法,幸好影響也不大。
無法執行單一測試案例的測試
有了執行單一檔案測試的功能之後,接下來更近一步,希望能只執行單一測試案例。Mocha 本身支持使用 grep
參數進行過濾。所以接下來的問題是,找出該測試案例的名稱。
原本的想法是編輯器傳入行號,然後從行號上下檢索檔案所包含的測試案例區塊。但要識別區塊不容易。可能需要用到類似 AST(abstract syntax tree)。後來參考之前常用的套件 Better PHPUnit ,他也提供了類似的功能。他的解決思路很簡單,從目前的行號取得這一行的文字,然後用正規表達式檢查,符合的話回傳該方法名稱,否則往上一行查找,直到第一行。
// https://github.com/calebporzio/better-phpunit/blob/81447bd3c0478d6577d697b398578068f53c31d7/src/phpunit-command.js#L88
get method() {
let line = vscode.window.activeTextEditor.selection.active.line;
let method;
while (line > 0) {
const lineText = vscode.window.activeTextEditor.document.lineAt(line).text;
const match = lineText.match(/^\s*(?:public|private|protected)?\s*function\s*(\w+)\s*\(.*$/);
if (match) {
method = match[1];
break;
}
line = line - 1;
}
return method;
}
最後總結一下解決思路:
./vscode/launch.json
添加一組設定,傳遞當前檔案名稱跟行號。test/suite/index.js
判斷是否有傳遞行號,根據它檢索出測試案例名稱。- 將測試案例名稱加入到 Mocha 的 grep。
- 記錄行號到 previous,作為下次執行測試參考。
首先在 ./vscode/launch.json
添加一組設定名稱為 Test Current Case
。根據 Test Current/Previous File
複製一份,只是在 env
的欄位增加 "TEST_FILE_LINE": "{$lineNumber}"
。透過 env 環境變數將當前的檔名帶過去。
{
"name": "Test Current Case",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
],
"outFiles": [
"${workspaceFolder}/out/test/**/*.js"
],
"env": {
"EXTENSION_PATH": "${workspaceFolder}",
"TEST_FILE": "${file}",
"TEST_FILE_LINE": "${lineNumber}",
},
"preLaunchTask": "npm: watch"
}
test/suite/index.ts
增加方法檢索測試案例名稱:
async function getTestCase(filename: string, lineNumber: number) : Promise<string> {
const document = await vscode.workspace.openTextDocument(filename);
while (lineNumber > 0) {
const lineText = document.lineAt(lineNumber).text;
const match = lineText.match(/test\(\s*['"`]([^'"`]+)/);
if (match) {
return match[1];
}
--lineNumber;
}
return '';
}
接著添加到原本的執行單一測試檔案邏輯中:
return new Promise(async (c, e) => {
let filePattern = '**/**.test.js';
if (process.env.TEST_FILE) {
const previous = process.env.EXTENSION_PATH + '/.vscode-test/.previous';
let lineNumber = '';
if (process.env.TEST_FILE.endsWith('test.ts')) {
filePattern = process.env.TEST_FILE;
if (process.env.TEST_FILE_LINE) {
lineNumber = process.env.TEST_FILE_LINE;
}
// 用 : 區隔出檔名跟行號
fs.writeFile(previous, filePattern + ':' +lineNumber, err => null);
} else {
if (fs.existsSync(previous) ) {
// 根據 : 拆解出檔名跟行號
const content = fs.readFileSync(previous, 'utf8').split(':');
filePattern = content[0];
lineNumber = content[1];
}
}
// 檢索測試案例名稱,並加到 Mocha
const testCase = await getTestCase(filePattern, +lineNumber);
if (testCase) {
mocha.grep(testCase);
}
filePattern = filePattern.replace('src/test/', 'out/test');
filePattern = filePattern.replace('.ts', '.js');
filePattern = filePattern.replace(testsRoot, '');
}
glob(filePattern, { cwd: testsRoot }, (err, files) => {
// ....
}
到此順利解決,完整檔案可參考: