テスト書く腕力鍛えるため & 個人開発のメンテナンスを楽にするために、最近なんでもテストコードを書きまくってます。あらゆるものをテストするぞという気概を持って手を動かした結果、ちょっと変わったテストコードを書いたりしてました。というわけで、この記事ではそれらを紹介していきます。
- CLIツールの E2Eテスト
- dotfiles のインストール可能性のテスト
- ブラウザ拡張機能の content script の E2E テスト
- Scrapbox の UserScript の E2E テスト
- VSCode のコードジャンプの振る舞いのテスト
- レンダリングパフォーマンスのリグレッションテスト
- メモリリークのリグレッションテスト
- おわりに
CLIツールの E2Eテスト
「eslint-interactive」という ESLint の error/warning を高速修正する CLI ツールがあります。
このツールには、E2Eテストを導入しています。
E2E テストと聞くと、「GUI アプリケーションに対してタップ/クリックイベントをエミュレートして、アプリが期待通りの挙動をするかを検証するアレ」を思い浮かべることが多いと思います。しかし元の意味から考えると「E2E テスト」は「End-to-End テスト」のことで、つまりは「全部のレイヤーを結合した上でユーザが実際に利用するインターフェイスからソフトウェアを操作し、その振る舞いを検証するテスト」を意味しています。E2E テストは、GUI であっても CUI であっても適用できるテスト手法なのです。
実際に eslint-interactive ではどんな E2E テストを書いているかといいますと...
npm install -g packed.zip
でnpm pack
したパッケージをグローバルにインストールeslint-interactive ./fixture
でインストールした eslint-interactive を起動- 標準入力から適当な入力を与えて eslint-interactive を操作
- 標準出力が期待通りかを assertion
spawn
でプロセスを起動すると、stdin/stdout への binding が取れるので、それ経由で入出力のハンドリングをしてます。
eslint-interactive は諸事情あってローカルインストールされた時は上手く動きますが、グローバルインストールされた時は動かない、ということが起きやすい作りになっています。そのため、手間を掛けてグローバルインストールした時の E2E テストを整備しています。実際にこのテストがあったことで、グローバルインストールした時にだけ再現するバグなどが検出できたりしていて、あって良かったなと思ってます。
dotfiles のインストール可能性のテスト
バニラな macOS/Ubuntu に dotfiles がインストールできることを GitHub Actions でテストしてます。 sh -c "$(curl -fsLS chezmoi.io/get)" -- init --apply -S .
がミソです。
id:mizdra は dotfiles は書くために、chezmoi というツールを利用してます。template という機能でマシンごとの設定の差分を上手く表現できて、簡単にマルチプラットフォームな dotfiles を作れます。
また chezmoi はバイナリが配布されているため、インストールの待ち時間もほとんど掛かりません。CI 上でも高速にインストールでき、快適にテストできます。
ブラウザ拡張機能の content script の E2E テスト
now-playing-for-google-play-music という YouTube Music から NowPlaying ツイートできるブラウザ拡張機能があります *1。
content script で右下にボタンを挿入していて、これを押すと https://twitter.com/intent/tweet?text=ギターと孤独と蒼い惑星%20%2F%20結束バンド&hashtags=NowPlaying という URL が新しいタブで開かれてツイートできます (Twitter Web Intent という仕組みに乗っかっています)。
この content script に以下のような E2E テスト を用意しています。
- playwright を使い、chrome をヘッドレスモードで起動
--load-extension
オプションを使い、拡張機能が読み込まれた状態で起動している https://github.com/mizdra/now-playing-for-google-play-music/blob/71c1d1e6d80dd70cc9efd6a02c982ea16442ab23/jest.config.js#L32
- YouTube Music の表示言語が日本語になるよう、それっぽい cookie をセットしておく
- YouTube Music の任意の曲の再生ページを chrome で開く
- https://github.com/mizdra/now-playing-for-google-play-music/blob/71c1d1e6d80dd70cc9efd6a02c982ea16442ab23/e2e-test/ext-real.test.ts#L60-L61 YouTube Music はゲストユーザでも再生できるので、Google アカウントでログインせずにそのままテストしてる
page.waitForSelector
を使い、右下に拡張機能のボタンが挿入されるまで待機- ボタンをクリックして、新しいタブで Twitter Intent が開かれることを assertion
--load-extension
で拡張機能を読み込み、context.waitForEvent('page')
で新しく開かれたタブの binding を取ってきているところがミソです。
Scrapbox の UserScript の E2E テスト
風変わりな E2E テスト第3弾。Scrapbox というドキュメンテーションサービスには UserScript という仕組みがあり、ユーザが書いた JavaScript をページ表示完了時に実行できます。
id:mizdra が作ったアイコン記法高速挿入ツール「icon-suggestion」も、UserScript として実装してます。
これにも E2E テスト があって、以下のようなことをやっています。
- playwright を使い、chrome をヘッドレスモードで起動
- Content Security Policy (CSP) を無効化する
- これがミソで、無効化しておかないと UserScript をインストールする時に CSP 違反で失敗してしまう
- https://github.com/mizdra/scrapbox-userscript-icon-suggestion/blob/d67942acafe33df855cf3c379302b755f449dd87/e2e-test/index.test.ts#L13
- Scrapbox のページにアクセス
page.waitForSelector
を使い、scrapbox 側のDOMの組み立てが終わるまで待機page.addScriptTag
でUserScriptの<script>
タグを挿入- 挿入した瞬間にブラウザが UserScript を実行してくれる
page.keyboard
で icon-suggestion を操作- 結果が期待通りか assertion
Scrapbox では、許可されたドメイン以外で配信されている JavaScript が読み込めないよう、CSP で制限をしてます。そのため、素朴に <script>
タグでローカルにあるビルド済みの.js
を挿入しただけでは上手く行きません (file:///
な.js
は CSP 違反によりブロックされてしまう)。そこで CSP を無効化するべくbypassCSP: true
を渡してます。
CSP 環境下で E2E テストするためにアレコレしてるのが面白いと思います。
VSCode のコードジャンプの振る舞いのテスト
happy-css-modules という CSS Modules で.tsx
=> .module.css
へのコードジャンプを実現するツールがあります。
.module.css.d.ts
と.module.css.d.ts.map
という2つのファイルをツールでコード生成しておくと、VSCode がそれをヒントに.tsx
=> .module.css
へとコードジャンプできるという仕組みです。
.module.css.d.ts
と .module.css.d.ts.map
を駆使してコードジャンプする様子。
このツールには様々な面白テストがありますが、特に面白いのは「VSCode で.tsx
上のstyles.button
を押した時に .module.css
上の.button { ... }
にコードジャンプすること」のテストです。
VSCode では TypeScript の Language Server に「tsserver)」が使われているのですが、それを使ってコードジャンプの挙動のテストをしています。
tsserver とのやり取りは標準入出力を介して行います。しかし、自前で書こうとすると中々骨が折れます。そこで id:mizdra は @typescript/server-harness
という wrapper ライブラリを使ってます。
以下のようにserver.message(...)
で tsserver に渡したい入力を渡すと、tsserver からの応答がその返り値で得られます。
import serverHarness from '@typescript/server-harness'; import url from 'node:url'; import path from 'node:path'; import _glob from 'glob'; import util from 'node:util'; import fs from 'node:fs'; import assert from 'node:assert/strict'; import type { UpdateOpenRequest, DefinitionResponse, DefinitionRequest } from 'typescript/lib/protocol.js'; const glob = util.promisify(_glob); const server = serverHarness.launchServer( url.fileURLToPath(await path.resolve('typescript/lib/tsserver.js', import.meta.url)), // よくわからないけど、おまじないっぽい ['--disableAutomaticTypingAcquisition'], ); // tsserver に読み込ませたい .ts ファイル一覧を準備。 // fixtures ディレクトリの中に `.ts` / `.module.css.d.ts` / `.module.css.d.ts.map` が存在するという設定。 const fixtureFilePaths = await glob(path.resolve('./fixtures/**/*.ts'), { dot: true }); const openFiles: UpdateOpenRequest['arguments']['openFiles'] = fixtureFilePaths.map((filePath) => ({ file: filePath, fileContent: fs.readFileSync(filePath, 'utf-8'), projectRootPath: path.resolve('./fixtures'), scriptKindName: 'TS', })); // tsserver に .ts ファイルを読み込ませる await server.message({ seq: 0, type: 'request', command: 'updateOpen', arguments: { changedFiles: [], closedFiles: [], openFiles, }, } as UpdateOpenRequest); // index.ts の 2 行目 8列目の定義元 (ジャンプ先) の情報を取得 const response: DefinitionResponse = await server.message({ seq: 1, type: 'request', command: 'definition', arguments: { file: path.resolve('./fixtures/index.ts'), line: 2, offset: 8, }, } as DefinitionRequest); assert.deepEqual(response, ...); // コードジャンプ先が期待通りかを検証
本当は本物の VSCode を使ってテストしたかったのですが、CI 上でネイティブアプリを動かすのが大変そうなので諦めました。その点、Language Server を使うくらいであれば、標準入出力でプロセス間通信するだけなので、まあなんとか…みたいな感じです。
レンダリングパフォーマンスのリグレッションテスト
昔 rocketimer という 60fps で動作する高パフォーマンスなカウントダウンタイマーを作ってました (訳あって未完成のまま放置しちゃってますが)。
カウントダウンがどれくらいの速度で行えるかを検証するためのベンチマークがあって、これを CI で継続して走らせて、パフォーマンスのデグレが起きていないか検証してます。
ヘッドレス chrome を使って検証をしているのですが、いくつか面白いポイントがあって...
- Chrome を起動する時に
--disable-frame-rate-limit
オプションを渡してるrequestAnimationFrame
のコールバックは通常最大 60fps の間隔で呼ばれる- CPUにどんなに余裕があっても、最短で約 16ms に 1 回にスロットリングされる
--disable-frame-rate-limit
を渡すとこの上限がなくなり、CPUリソースが許す限りrequestAnimationFrame
のコールバックを次々呼び出す- つまり 60 fps 以上出せる
- ベンチマークスクリプトから
requestAnimationFrame
のコールバックを都度登録して、それが呼び出された回数を記録する- 1回レンダリングが走ったら、
requestAnimationFrame
のコールバックが1回呼び出されるはず - これでアプリケーションコードに手を加えることなく、ベンチマークスクリプトだけで fps を測定できる
- 1回レンダリングが走ったら、
60 fps で頭打ちになってしまうとパフォーマンスの変動が上手く測れないので、その制限を無効化し、真の fps でパフォーマンスを計測する、というのがミソです。
メモリリークのリグレッションテスト
rocketimer には長期間カウントダウンタイマーを稼働させた時に、メモリ使用量が増え続けていないかを検証するリグレッションテストもあります。
メモリの集計にはperformance.measureUserAgentSpecificMemory
という API を使ってます。performance.measureUserAgentSpecificMemory
は (ゴミがメモリ使用量に含まれないように) GC が実行された後にメモリ使用量を集計するようになっていますが、そのせいで GC が実行されるタイミングまで集計が遅延されてしまいます。それだと好きなタイミングで集計できなくて不便なので、chrome を起動する際に--enable-blink-features=ForceEagerMeasureMemory
オプションを渡しています。こうすると、performance.measureUserAgentSpecificMemory
を呼び出した瞬間に GC とメモリ使用量が集計されます。
ところで最近 Meta がメモリリークを検出する専用のテスティングライブラリをリリースしています。今だったらこれ使って作るのが簡単だと思います。
おわりに
これらのテストコードを書くのは大変でした…がやってみて良かったなと思っています。実際色々なテストコードを書いたことで自信と腕力が付きましたし、多くのバグの検出にも役立っていて、「テスト書く腕力鍛えるため」「個人開発のメンテナンスを楽にするため」という当初の目的は達成できたと考えてます。
また、テストタブルなインターフェースを設計するためのモデリング力・リファクタリング力が身についたり、"ミソ"を考えるための発想力がついたり、今後テストコードを書く上での手札が増えたりと、色々な副産物もありました。
一方で、メンテナンスしやすいテストコードを書く難しさをひしひしと感じました。テストコードはぐちゃぐちゃになったり、実行結果が安定しなかったり (flaky test)... また、テストに使っているツールや手法に馴染みがないと、キャッチアップが大変です。チーム開発で今回紹介したようなテストを導入するなら、こうした問題と向き合っていかなければなりません。
少しずつ趣味で学んだことを業務でも活用していきたいですね。
*1:Google Play Music はとうの昔に終了したサービスで、YouTube Music はその後継です。拡張機能の名前はつまりそういうことです...