Angular17からはデフォルトのバンドルツールがesbuildに変わったこと、Angular & istanbul形式だとjestのistanbul形式と正確にマージ出来ないことから、Angular16以下でPlaywrightのカバレッジだけ取りたい場合の内容です。
Angular17以降でJestカバレッジとマージするならV8で計測する方が正確にカバレッジ計測できます。
目次
環境
- Node.js 18.10.0
- angular 16.2.0
- playwright 1.43.1
事前準備
動作確認用の適当なAngularアプリをng newする
(説明の為簡単なAngular16+Playwrightのプロジェクトを作っておきます、実装が面倒なら以下からgit clone)
ボタン処理を一つ持つコンポーネントを二つ作成し、Angular Routerで遷移できるようにします。
$ ng new NgPlaywrightCoverageIstanbul
$ cd NgPlaywrightCoverageIstanbul
$ ng g c page1
$ ng g c page2
page1.component.html(page2.component.htmlも同じ)
<p>page1 works!</p>
<button (click)="doSomethingOnPage1()">do something on page1</button>
<p id="result">{{ message }}</p>
page1.component.ts(page2.component.tsも同じ)
import { Component } from '@angular/core';
@Component({
selector: 'app-page1',
templateUrl: './page1.component.html',
styleUrls: ['./page1.component.scss'],
})
export class Page1Component {
message = '';
doSomethingOnPage1() {
this.message = 'something on page1 done.';
}
}
app-routing.module.tsにpage1、page2へのルーティング定義を追加。
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Page1Component } from './page1/page1.component';
import { Page2Component } from './page2/page2.component';
const routes: Routes = [
{ path: 'page1', component: Page1Component },
{ path: 'page2', component: Page2Component },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
app.component.htmlに遷移リンク追加。
<p><a routerLink="page1">page1</a></p>
<p><a routerLink="page2">page2</a></p>
<router-outlet></router-outlet>
HTTPサーバを起動して動作確認します。
% npx ng serve --open
✔ Browser application bundle generation complete.
Initial Chunk Files | Names | Raw Size
vendor.js | vendor | 2.35 MB |
polyfills.js | polyfills | 332.10 kB |
styles.css, styles.js | styles | 229.84 kB |
main.js | main | 12.25 kB |
runtime.js | runtime | 6.55 kB |
| Initial Total | 2.92 MB
Build at: 2024-08-03T09:21:05.618Z - Hash: ba3e31f983dd4052 - Time: 1953ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
✔ Compiled successfully.
ページ遷移経路が2つあり、ボタンを押すだけのAngularアプリができました。
このWebアプリに対してplaywrightを実行してカバレッジを計測できるようにしていきます。
playwrightでテストコードを作成
まずはplaywrightインストール。プロジェクトルート/e2eディレクトリにE2E資源が生成されるようにしておきます。
$ npm init playwright@latest
Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
? Where to put your end-to-end tests? ・ e2e
? Add a GitHub Actions workflow? (y/N) ・ false
? Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) ・ true
Installing Playwright Test (npm install --save-dev @playwright/test)…
added 4 packages, and audited 1026 packages in 3s
(snip)
e2e/example.spec.tsを以下の内容で書き換えます。localhost:4200を開き、page1、page2へ遷移をしてボタンを押すテストケースを二つ定義しています。
import { test, expect } from '@playwright/test';
test('page1 test', async ({ page }) => {
await page.goto('http://localhost:4200/');
await page.getByRole('link', { name: 'page1' }).click();
await page.getByRole('button', { name: 'do something on page1' }).click();
await expect(page.locator('#result')).toHaveText(
'something on page1 done.'
);
});
test('page2 test', async ({ page }) => {
await page.goto('http://localhost:4200/');
await page.getByRole('link', { name: 'page2' }).click();
await page.getByRole('button', { name: 'do something on page2' }).click();
await expect(page.locator('#result')).toHaveText(
'something on page2 done.'
);
});
テストコードが出来たので実行してみます。
% npx playwright test --project chromium
Running 2 tests using 2 workers
2 passed (963ms)
To open last HTML report run:
npx playwright show-report
localhost:4200のAngularアプリに対して2つのテストケースが正常終了しました。
次に、このplaywright実行中にどれくらいのアプリコードを実行できたかカバレッジを取れるようにします。
istanbulでAngularアプリにinstrumentしてカバレッジを採り、ブラウザからダウンロードできるようにする
コードカバレッジを取得するにはアプリケーションに「ここのステップを通った」といった情報を記録する為の追跡コード実装(instrument)をする必要があります。
今回はistanbulを使ってinstrumentします。
webpack拡張設定を作成してバンドルにinstrumentする
Angular16まではデフォルトでwebpackを使用してバンドル生成しているので、
・webpackのビルドに割り込んで追加webpackコンフィグを設定できる「ngx-build-plus」
・Angularビルド中のbabel変換中にinstrument出来る「babel-plugin-istanbul」
をnpmインストールします。
$ npm install ngx-build-plus@16 -D // 使っているAngularのバージョンに合わせる
$ npm install babel-plugin-istanbul -D
次にangular.jsonを編集し、Angular(ngコマンド)がビルドを行う際のプログラムを@angular-devkit/build-angularから、ngx-build-plusに変更します。
これによってngコマンドに対して–extra-webpack-configオプションで追加のwebpack定義を与えられるようになります。
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
↓(変更)
"builder": "ngx-build-plus:browser”,
(中略)
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
↓(変更)
"builder": "ngx-build-plus:dev-server",
カスタムwebpack定義を食わせる準備ができたので、食わせる設定ファイルであるcoverage.webpack.jsをプロジェクトルートに作成します。
angularビルド中のbabel-loader実行時にbabel-plugin-istanbulで追跡コードをinstrumentする設定です。
module.exports = {
module: {
rules: [
{
test: /\.(ts)$/,
use: {
loader: "babel-loader",
options: {
plugins: ["babel-plugin-istanbul"],
},
},
enforce: "post",
include: require("path").join(__dirname, "src"),
exclude: [/node_modules/, /(ngfactory|ngstyle)\.js/],
},
],
},
};
ng serveの–extra-webpack-configオプションに上記coverage-webpack.jsを指定してHTTPサーバを起動します。
% npx ng serve --extra-webpack-config coverage.webpack.js
✔ Browser application bundle generation complete.
Initial Chunk Files | Names | Raw Size
vendor.js | vendor | 2.35 MB |
polyfills.js | polyfills | 332.10 kB |
styles.css, styles.js | styles | 229.84 kB |
main.js | main | 56.07 kB |
runtime.js | runtime | 6.55 kB |
| Initial Total | 2.96 MB
Build at: 2024-08-03T10:04:14.744Z - Hash: ba8e7e111aef8481 - Time: 1047ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
✔ Compiled successfully.
冒頭でng serveした時よりもmain.jsのサイズが増えました。これはistanbulがmain.jsに追跡コードをinstrumentした為です。
// ng serve
main.js | main | 12.25 kB |
// ng serve --extra-webpack-config coverage-webpack.js
main.js | main | 56.07 kB |
instrumentされたかどうかを目視確認するにはブラウザでlocalhost:4200を開き、デバッグコンソールで__coverage__オブジェクトが載っているか確認します。
branchMapやfnMapには通った分岐、エントリした関数などの情報が記録されます。詳細説明は公式を参照してください。
ブラウザ上のカバレッジ情報をファイルに落とす
Angularアプリを操作するとカバレッジデータがブラウザメモリ上に溜まるようになりました。しかしPlaywrightは1テストケースが終了する度にブラウザを落とす為、カバレッジデータが消えてしまいます。
この為、1テストケースでブラウザメモリに溜まったカバレッジデータをJSONファイルとして自動ダウンロードするようにします。
Playwrightのtest関数はextendして処理を追加することができます。
https://playwright.dev/docs/api/class-test#test-extend
これを利用してブラウザを閉じる際に__coverage__オブジェクトをローカルPCにダウンロードするようtest関数を拡張します。
https://playwright.dev/docs/api/class-browsercontext
exposeFunctionでブラウザに載せたcollectIstanbulCoverage関数が、ブラウザプロセスとNode.jsプロセスの架け橋になります。beforeUnloadタイミングで同関数に__coverage__を渡し、fs.writeFileSync()でHDDに保存しています。
import { test as baseTest } from '@playwright/test';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
// nyc(istanbulのCLI)がカバレッジ集計対象とするデフォルトディレクトリに出力(/.nyc_output)
const istanbulCLIOutput = path.join(process.cwd(), '.nyc_output');
// 1testケース毎のファイル名ランダムパート
const generateUUID = () => crypto.randomBytes(16).toString('hex');
// playwrightのtest関数を拡張してexport
export const test = baseTest.extend({
context: async ({ context }, use) => {
await context.addInitScript(() => {
window.addEventListener('beforeunload', () =>
(window as any).collectIstanbulCoverage(
JSON.stringify((window as any)['__coverage__'])
)
);
});
await fs.promises.mkdir(istanbulCLIOutput, { recursive: true });
await context.exposeFunction(
'collectIstanbulCoverage',
(coverageJSON: string) => {
if (coverageJSON) {
fs.writeFileSync(
path.join(
istanbulCLIOutput,
`playwright_coverage_${generateUUID()}.json`
),
coverageJSON
);
}
}
);
await use(context);
for (const page of context.pages()) {
await page.evaluate(() =>
(window as any).collectIstanbulCoverage(
JSON.stringify((window as any)['__coverage__'])
)
);
}
},
});
export const expect = test.expect;
上記で拡張したtest関数を、テストケース実装しているspec.tsでimportします。
// import { test, expect } from '@playwright/test';
import { test, expect } from './base-fixture';
カバレッジデータファイルをローカルPC保存する準備ができました。Playwrightテストを実行してみます。
$ npx playwright test --project chromium
2ケース分のカバレッジJSONファイルが出力されました。これでテストケースが幾つあってもカバレッジデータは保持されるようになります。
% ls .nyc_output
playwright_coverage_b57d16104864848d5bae043151d19e1c.json
playwright_coverage_b8b4823a9c3d88da6d943bcb94a57cd8.json
ダウンロードしたカバレッジ情報JSONをnycコマンドでHTML化する
カバレッジファイルが出力出来たので、集計、可視化をしてみます。出力されたカバレッジデータのフォーマットはistanbul形式なので、同じくistanbulのCLI「nyc」を使います。
nycと関連パッケージをインストールします。
% npm i -D nyc source-map-support ts-node @istanbuljs/nyc-config-typescript
nycの設定ファイル「.nycrc」を作成します。レポートは「coverage-e2e」ディレクトリに出力される設定です。読み込むカバレッジデータファイルの場所はnycのデフォルトである.nyc_outputに出力したので設定していません。
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true,
"report-dir": "coverage-e2e",
"exclude": ["**/coverage/**", "**/*.spec.ts"],
"reporter": ["html", "lcov", "text-summary", "text"]
}
ではnycコマンドでレポートを出力してみます。
% npx nyc report
出力されたcoverage-e2e/index.htmlを開いてみると視覚的にカバレッジが分かるようになりました。
留意点として、フレームワークの特性上どうしてもカバレッジを通せない場所があったりします。必ずしも通ったかどうかを確認する場合がないのであれば、カバレッジ取得の対象外にするのも一手です。(以下コメントで対象外になります)
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
/* istanbul ignore next */
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));
問題点
E2Eカバレッジが採れたら、UTカバレッジとマージしてカバレッジ率を相互補完したいところです。.nycrcで汎用的なカバレッジフォーマットで書かれたlcov.infoも出力してあるので一見すると簡単にマージ出来そうです。
しかしwebpackによってModule単位でバンドルした関数名と、単体ソースに対するJestの関数名が異なる為、同じ関数なのに違う関数として認識されるケースが発生します。
"fnMap": {
"0": { ← playwrightのlcov
"name": "(anonymous_40)", ← constructor(モジュール内で40番目の関数)
"decl": {
"start": {
"line": 103,
"column": 4
},
"end": {
"line": 103,
"column": null
}
},
(snip)
"16": { ← jestのlcov
"name": "(anonymous_0)", ← constructor(単体ファイルで0番目の関数)
"decl": {
"start": {
"line": 103,
"column": 4
},
"end": {
"line": 103,
"column": null
}
},
この為マージしても正常にがっちゃんこ出来ずカバレッジ率が下がってしまったりでうまく行きません。この問題はistanbulのissueでもまだopen状態のようです。
istanbul側としてはE2E対象ソース(webpackバンドルしたソース)と、UT対象ソース(jestが読み込んだソース)の違いを考慮する責任は無いですからしょうがないですね。
playwright単体のカバレッジを採取する分にはistanbulで問題ないですが、他カバレッジとマージする場合はこの問題を別途解決する必要がありそうです。
今回はwebpack + istanbulでカバレッジを取りました。次回はChromiumのV8でカバレッジを取ってみます。
- playwright(V8)
- Jest(V8)
でカバレッジフォーマットを合わせるとisutanbulで合わせた時よりも正確にカバレッジマージできるのでお勧めです。