mizdra's blog

ぽよぐらみんぐ

最近書いた風変わりなテストコード 7 連発

テスト書く腕力鍛えるため & 個人開発のメンテナンスを楽にするために、最近なんでもテストコードを書きまくってます。あらゆるものをテストするぞという気概を持って手を動かした結果、ちょっと変わったテストコードを書いたりしてました。というわけで、この記事ではそれらを紹介していきます。

CLIツールの E2Eテスト

「eslint-interactive」という ESLint の error/warning を高速修正する CLI ツールがあります。

www.mizdra.net

このツールには、E2Eテストを導入しています。

E2E テストと聞くと、「GUI アプリケーションに対してタップ/クリックイベントをエミュレートして、アプリが期待通りの挙動をするかを検証するアレ」を思い浮かべることが多いと思います。しかし元の意味から考えると「E2E テスト」は「End-to-End テスト」のことで、つまりは「全部のレイヤーを結合した上でユーザが実際に利用するインターフェイスからソフトウェアを操作し、その振る舞いを検証するテスト」を意味しています。E2E テストは、GUI であっても CUI であっても適用できるテスト手法なのです。

実際に eslint-interactive ではどんな E2E テストを書いているかといいますと...

  1. npm install -g packed.zipnpm packしたパッケージをグローバルにインストール
  2. eslint-interactive ./fixtureでインストールした eslint-interactive を起動
  3. 標準入力から適当な入力を与えて eslint-interactive を操作
  4. 標準出力が期待通りかを assertion

spawnでプロセスを起動すると、stdin/stdout への binding が取れるので、それ経由で入出力のハンドリングをしてます。

eslint-interactive は諸事情あってローカルインストールされた時は上手く動きますが、グローバルインストールされた時は動かない、ということが起きやすい作りになっています。そのため、手間を掛けてグローバルインストールした時の E2E テストを整備しています。実際にこのテストがあったことで、グローバルインストールした時にだけ再現するバグなどが検出できたりしていて、あって良かったなと思ってます。

dotfiles のインストール可能性のテスト

バニラな macOS/Ubuntu に dotfiles がインストールできることを GitHub Actions でテストしてます。 sh -c "$(curl -fsLS chezmoi.io/get)" -- init --apply -S .がミソです。

github.com

id:mizdra は dotfiles は書くために、chezmoi というツールを利用してます。template という機能でマシンごとの設定の差分を上手く表現できて、簡単にマルチプラットフォームな dotfiles を作れます。

www.mizdra.net

また chezmoi はバイナリが配布されているため、インストールの待ち時間もほとんど掛かりません。CI 上でも高速にインストールでき、快適にテストできます。

ブラウザ拡張機能の content script の E2E テスト

now-playing-for-google-play-music という YouTube Music から NowPlaying ツイートできるブラウザ拡張機能があります *1

www.mizdra.net

content script で右下にボタンを挿入していて、これを押すと https://twitter.com/intent/tweet?text=ギターと孤独と蒼い惑星%20%2F%20結束バンド&hashtags=NowPlaying という URL が新しいタブで開かれてツイートできます (Twitter Web Intent という仕組みに乗っかっています)。

右下の矢印ボタンを押すと、NowPlaying ツイートができる。

この content script に以下のような E2E テスト を用意しています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. YouTube Music の表示言語が日本語になるよう、それっぽい cookie をセットしておく
  3. YouTube Music の任意の曲の再生ページを chrome で開く
  4. page.waitForSelectorを使い、右下に拡張機能のボタンが挿入されるまで待機
  5. ボタンをクリックして、新しいタブで Twitter Intent が開かれることを assertion

--load-extensionで拡張機能を読み込み、context.waitForEvent('page')で新しく開かれたタブの binding を取ってきているところがミソです。

Scrapbox の UserScript の E2E テスト

風変わりな E2E テスト第3弾。Scrapbox というドキュメンテーションサービスには UserScript という仕組みがあり、ユーザが書いた JavaScript をページ表示完了時に実行できます。

scrapbox.io scrapbox.io

id:mizdra が作ったアイコン記法高速挿入ツール「icon-suggestion」も、UserScript として実装してます。

scrapbox.io

これにも E2E テスト があって、以下のようなことをやっています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. Content Security Policy (CSP) を無効化する
  3. Scrapbox のページにアクセス
  4. page.waitForSelectorを使い、scrapbox 側のDOMの組み立てが終わるまで待機
  5. page.addScriptTagでUserScriptの<script>タグを挿入
    • 挿入した瞬間にブラウザが UserScript を実行してくれる
  6. page.keyboardで icon-suggestion を操作
  7. 結果が期待通りか assertion

Scrapbox では、許可されたドメイン以外で配信されている JavaScript が読み込めないよう、CSP で制限をしてます。そのため、素朴に <script>タグでローカルにあるビルド済みの.jsを挿入しただけでは上手く行きません (file:///.jsは CSP 違反によりブロックされてしまう)。そこで CSP を無効化するべくbypassCSP: trueを渡してます。

CSP 環境下で E2E テストするためにアレコレしてるのが面白いと思います。

VSCode のコードジャンプの振る舞いのテスト

happy-css-modules という CSS Modules で.tsx => .module.cssへのコードジャンプを実現するツールがあります。

www.mizdra.net

.module.css.d.ts.module.css.d.ts.mapという2つのファイルをツールでコード生成しておくと、VSCode がそれをヒントに.tsx => .module.cssへとコードジャンプできるという仕組みです。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

.module.css.d.ts.module.css.d.ts.map を駆使してコードジャンプする様子。

このツールには様々な面白テストがありますが、特に面白いのは「VSCode で.tsx上のstyles.buttonを押した時に .module.css上の.button { ... }にコードジャンプすること」のテストです。

VSCode では TypeScript の Language Server に「tsserver)」が使われているのですが、それを使ってコードジャンプの挙動のテストをしています。

github.com

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 で動作する高パフォーマンスなカウントダウンタイマーを作ってました (訳あって未完成のまま放置しちゃってますが)。

github.com

カウントダウンがどれくらいの速度で行えるかを検証するためのベンチマークがあって、これを CI で継続して走らせて、パフォーマンスのデグレが起きていないか検証してます。

github.com

ヘッドレス chrome を使って検証をしているのですが、いくつか面白いポイントがあって...

  • Chrome を起動する時に--disable-frame-rate-limitオプションを渡してる
    • requestAnimationFrameのコールバックは通常最大 60fps の間隔で呼ばれる
      • CPUにどんなに余裕があっても、最短で約 16ms に 1 回にスロットリングされる
    • --disable-frame-rate-limitを渡すとこの上限がなくなり、CPUリソースが許す限りrequestAnimationFrameのコールバックを次々呼び出す
      • つまり 60 fps 以上出せる
    • ベンチマークスクリプトからrequestAnimationFrameのコールバックを都度登録して、それが呼び出された回数を記録する
      • 1回レンダリングが走ったら、requestAnimationFrameのコールバックが1回呼び出されるはず
      • これでアプリケーションコードに手を加えることなく、ベンチマークスクリプトだけで fps を測定できる

60 fps で頭打ちになってしまうとパフォーマンスの変動が上手く測れないので、その制限を無効化し、真の fps でパフォーマンスを計測する、というのがミソです。

メモリリークのリグレッションテスト

rocketimer には長期間カウントダウンタイマーを稼働させた時に、メモリ使用量が増え続けていないかを検証するリグレッションテストもあります。

github.com

メモリの集計にはperformance.measureUserAgentSpecificMemoryという API を使ってます。performance.measureUserAgentSpecificMemoryは (ゴミがメモリ使用量に含まれないように) GC が実行された後にメモリ使用量を集計するようになっていますが、そのせいで GC が実行されるタイミングまで集計が遅延されてしまいます。それだと好きなタイミングで集計できなくて不便なので、chrome を起動する際に--enable-blink-features=ForceEagerMeasureMemoryオプションを渡しています。こうすると、performance.measureUserAgentSpecificMemoryを呼び出した瞬間に GC とメモリ使用量が集計されます。

ところで最近 Meta がメモリリークを検出する専用のテスティングライブラリをリリースしています。今だったらこれ使って作るのが簡単だと思います。

engineering.fb.com

おわりに

これらのテストコードを書くのは大変でした…がやってみて良かったなと思っています。実際色々なテストコードを書いたことで自信と腕力が付きましたし、多くのバグの検出にも役立っていて、「テスト書く腕力鍛えるため」「個人開発のメンテナンスを楽にするため」という当初の目的は達成できたと考えてます。

また、テストタブルなインターフェースを設計するためのモデリング力・リファクタリング力が身についたり、"ミソ"を考えるための発想力がついたり、今後テストコードを書く上での手札が増えたりと、色々な副産物もありました。

一方で、メンテナンスしやすいテストコードを書く難しさをひしひしと感じました。テストコードはぐちゃぐちゃになったり、実行結果が安定しなかったり (flaky test)... また、テストに使っているツールや手法に馴染みがないと、キャッチアップが大変です。チーム開発で今回紹介したようなテストを導入するなら、こうした問題と向き合っていかなければなりません。

少しずつ趣味で学んだことを業務でも活用していきたいですね。

*1:Google Play Music はとうの昔に終了したサービスで、YouTube Music はその後継です。拡張機能の名前はつまりそういうことです...

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.