
みゅーとんです.
SEO は非機能的要件でありながら, 検索優位性として重要な観点であるため, フロントエンド開発においてはなるべく劣化させたくない概念です.
Lighthouse Score を定常的にチェックして劣化しないように・・などは大変面倒に感じたので, これを CI でチェックできる仕組みを考えました.
複数の機能の組み合わせで実現しています. それぞれ細かく紹介してから, 最後にこれらを統合した, Lighthouse Score の悪化を防ぐテストコードの完全版をまとめます.
前提条件
- Vitest でテストを実施する
- Playwright を使って Vitest 上で E2E テストを実行する
対象読者
- Vitest, Playwright が分かることを前提とする.
- Snapshot を使う Regression Test の概念を知っている前提とする.
1. Vitest Custom Matcher で Snapshot Test を自作する
Vitest の expect.extend() を使うと独自のマッチャーを定義できます. マッチャー内部では this.snapshotState.match() にアクセスでき, スナップショットテストの自作ができます.
参考
Custom Matcher の実装
import { expect } from 'vitest' expect.extend({ toMatchMySnapshot(received: unknown) { const result = this.snapshotState.match({ testName: this.currentTestName ?? '', received, isInline: false, }) return { pass: result.pass, message: () => result.pass ? 'スナップショットと一致しました' : `スナップショットと不一致です\n期待値: ${result.expected}\n実際値: ${result.actual}`, actual: result.actual, expected: result.expected, } }, })
snapshotState.match() は toMatchSnapshot() の内部でも使われている仕組みそのもので、 received に渡した値が .snap ファイルにシリアライズされて保存されます. vitest --update によるスナップショット更新も自動的にサポートしてくれます.
つまり、 スナップショットの保存・比較ロジックは Vitest が提供していて, その前後に独自のロジックを差し込むことができます.
テストコードでの呼び出し
作ったスナップショットテストは以下のような呼び出しが可能です.
import { test, expect } from 'vitest' test('ユーザー情報のスナップショット', () => { const user = { name: 'Alice', role: 'admin' } expect(user).toMatchMySnapshot() })
生成されるスナップショットファイル
custom matcher の第1引数 (received) には, テストコードで expect に渡した値がわたってきます. 初回実行時は *.snap ファイルが自動生成されます.
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot exports['ユーザー情報のスナップショット 1'] = ` { "name": "Alice", "role": "admin", } `;
2 回目以降の実行では, このスナップショットと received の値が比較され, 不一致であれば fail します. 通常の toMatchSnapshot() と挙動は同じです.
2. 閾値付き Snapshot Test を作る
通常のスナップショットテストは完全一致を検証します. しかし Lighthouse
スコアのように実行ごとに揺らぐ数値に対しては, 完全一致だとテストは安定しません.
そこでスナップショットを「基準値」として保存し, 現在値が基準値から閾値の範囲内であることを検証するマッチャーを作ります.
汎用サンプル
現時点でスナップショットされている値は, this.snapshotState.match の返り値の .expected から文字列で取得できます. 比較したい値を number にパースし, received と比較することで, 閾値範囲内かどうかのテストを実装することができます.
import { expect } from 'vitest' type Scores = Record<string, number> expect.extend({ toRetainScoreSnapshot(received: Scores, threshold = 0.05) { const result = this.snapshotState.match({ testName: this.currentTestName ?? '', received, isInline: false, }) // スナップショットと完全一致 → pass if (result.pass) { return { pass: true, message: () => 'スコアは基準値と一致しています' } } // 初回はスナップショットがないので保存のみ if (result.expected === undefined) { return { pass: true, message: () => '基準値を保存しました' } } const baseline: Scores = JSON.parse(result.expected) const degraded: string[] = [] const improved: string[] = [] for (const [key, score] of Object.entries(received)) { const base = baseline[key] if (base === undefined) continue if (base - score > threshold) { degraded.push(`${key}: ${base} → ${score}`) } else if (score > base) { improved.push(`${key}: ${base} → ${score}`) } } // しきい値を超えて劣化 → fail if (degraded.length > 0) { return { pass: false, message: () => `スコアが基準値からしきい値 (${threshold}) を超えて劣化しました:\n${degraded.join('\n')}`, actual: result.actual, expected: result.expected, } } // 改善 → fail(--update で基準値を引き上げる) if (improved.length > 0) { return { pass: false, message: () => `スコアが改善しました。--update で基準値を更新してください:\n${improved.join('\n')}`, actual: result.actual, expected: result.expected, } } // しきい値以内の変動 → pass return { pass: true, message: () => 'スコアはしきい値以内です' } }, })
テストコードでの呼び出し
閾値をテスト実装側で指定可能であると利便性があると思います.
import { test, expect } from 'vitest' test('API レスポンスタイムが劣化していない', () => { const scores = measurePerformance() // { api: 0.95, rendering: 0.88 } expect(scores).toRetainScoreSnapshot(0.05) })
スナップショットの中身
// Vitest Snapshot v1, <https://vitest.dev/guide/snapshot>
exports['API レスポンスタイムが劣化していない 1'] = `
{
"api": 0.95,
"rendering": 0.88,
}
`;
判定の振る舞い
| 変動 | 結果 | 意図 |
|---|---|---|
| 完全一致 | pass | 問題なし |
| しきい値以内の揺らぎ | pass | 許容範囲 |
| 改善 | fail + --update を促す |
基準値を引き上げ、後戻りさせない |
| しきい値を超えて劣化 | fail | 品質の低下を防ぐ |
スコアが改善された場合もテストは fail し, --update による基準値の更新を求めます. これにより基準値が引き上げられ, 次回以降はその水準を下回れなくなります. テストを回し続けるだけで, 継続的な改善が強制される仕組みになります.
3. Lighthouse スコアを headless browser で取得する
Playwright で起動した Chromium に対して, CDP(Chrome DevTools Protocol)ポート経由で Lighthouse を実行します.
参考:
基本的な実行
import { chromium } from 'playwright' import lighthouse from 'lighthouse' const port = 9222 const browser = await chromium.launch({ args: [`--remote-debugging-port=${port}`], }) const result = await lighthouse('<https://example.com>', { port, output: 'json', onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'], logLevel: 'silent', }) const scores = Object.fromEntries( Object.entries(result!.lhr.categories).map( ([key, category]) => [key, category.score ?? 0] ) ) // => { performance: 0.92, accessibility: 1, 'best-practices': 0.96, seo: 0.91 } await browser.close()
Lighthouse はナビゲーションを自分で制御するため, Playwright の Page オブジェクトとは独立して動作します. スコアは lhr.categories.<name>.score に 0〜1 の数値で格納されています.
複数回実行して中央値を取る
Lighthouse の結果はブラウザ負荷やネットワーク状態で揺らぎます. CI は長くなりますが, 複数回実行して中央値を採用することで安定化ができます.
function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) const mid = Math.floor(sorted.length / 2) return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] } const runs = 3 const allScores: Record<string, number[]> = {} for (let i = 0; i < runs; i++) { const result = await lighthouse(url, flags) for (const [key, category] of Object.entries(result!.lhr.categories)) { (allScores[key] ??= []).push(category.score ?? 0) } } const medianScores = Object.fromEntries( Object.entries(allScores).map(([key, scores]) => [key, median(scores)]) )
4. 統合サンプル
1〜3 を組み合わせ, Lighthouse スコアの劣化を防ぐテストをまとめます.
マッチャーの実装
マッチャーの内部では, ここまで紹介した 3 つの技術が組み合わさって動いています.
expect.extend({ async toRetainLighthouseScoreSnapshot(page, options) { const { port, runs = 3, tolerance = 0.05, categories } = options // (3) Lighthouse を複数回実行し中央値を算出 const medianScores = await runLighthouseAndCalcMedian(page, { port, runs, categories }) // (1) スナップショット(基準値)と照合 const result = this.snapshotState.match({ testName: this.currentTestName ?? '', received: medianScores, isInline: false, }) if (result.pass) { return { pass: true, message: () => '基準値と一致' } } if (result.expected === undefined) { return { pass: true, message: () => '基準値を保存しました' } } // (2) しきい値で判定(劣化 → fail、改善 → fail + --update 促進) const comparison = compareScores(result.expected, medianScores, tolerance) if (comparison.hasDegraded) { return { pass: false, message: () => '基準値から劣化しました' } } if (comparison.hasImproved) { return { pass: false, message: () => '改善されました。--update で基準値を更新してください' } } return { pass: true, message: () => 'しきい値以内です' } }, })
テストコード
Lighthouse 実行 → 中央値算出 → スナップショット比較 → しきい値判定がマッチャーに隠蔽されているため, テストコードは宣言的に「このページのスコアが劣化していないこと」を表現するだけで済みます.
スコアを測りたいページへの遷移までを beforeAll で済ませておき, テストコードではカスタムマッチャーを呼び出します.
import { test, expect } from 'vitest' import { chromium, type Page } from 'playwright' let page: Page let cdpPort: number // ブラウザの起動と CDP ポートの確保 beforeAll(async () => { cdpPort = 9222 const browser = await chromium.launch({ args: [`--remote-debugging-port=${cdpPort}`], }) page = await browser.newPage() await page.goto('<https://example.com>') }) test('トップページの Lighthouse スコアが劣化していない', async () => { await expect(page).toRetainLighthouseScoreSnapshot({ port: cdpPort, runs: 3, tolerance: 0.05, categories: ['accessibility', 'best-practices', 'seo'], }) })
スナップショット
// Vitest Snapshot v1, <https://vitest.dev/guide/snapshot>
exports['トップページの Lighthouse スコアが劣化していない 1'] = `
{
"accessibility": 0.92,
"best-practices": 1,
"seo": 0.91,
}
`;
このスナップショットが基準値となります. 次回以降のテスト実行で:
- スコアがこの基準値と一致, またはしきい値以内なら pass
- しきい値を超えて劣化していれば fail(品質低下の検知)
- スコアが改善されていれば fail +
--updateを促す(基準値の引き上げ)
- スコアが改善されていれば fail +
5. Lighthouse CI との違い
Lighthouse スコアの継続的な監視には、Google Chrome 公式の Lighthouse CI がすでに存在します. 本アプローチと実現していることは重なる部分もあるため, ここでは何が違うのかを整理します.
Lighthouse CI の運用モデル
Lighthouse CI の標準的な運用は, 大きく 2 つのパターンに分かれます.
- 下限値(assertion)を設定ファイルに宣言する
lighthouserc.jsのassertフィールドで「performance は 0.9 以上」「accessibility は 1.0」といったしきい値を書き, それを下回ったら fail させる. 基準は人が決めた固定値. - LHCI Server をセルフホストしてレポートを長期保存する
実行結果をサーバに蓄積し, ダッシュボード上で推移を可視化する. スコアの長期的な傾向を人間が閲覧して判断するための運用.
どちらも「あらかじめ決めた下限値を割らないこと」または「人が結果を見て判断すること」が中心にあります.
本アプローチの立ち位置
一方、本アプローチは「前回のスナップショットから悪化していないこと」をテストとしてアサートします. 基準値はサーバではなく .snap ファイルに置かれ, Git で管理されます.
この違いが効くのは, これから SEO を改善していくフェーズです.
- まだスコアが高くない段階で固定のしきい値を宣言しても, 「現状維持ライン」としてしか機能しない. 改善してもテストの表示上は変わらない.
- スナップショット方式なら, 改善するたびに
-updateで基準値が引き上がり, 以後その水準を下回れなくなる. テストを回すこと自体が品質を単調増加させるループになる. - LHCI Server のホスティングや設定ファイルのメンテナンスが不要で, Vitest テストスイートに同居できる.
toHaveSEOPropertyやtoBeListedInSitemapといった他の SEO マッチャーと同じファイルで書ける.
ただし, スコアの長期的な推移を人が閲覧したい, 複数プロジェクトを横断してダッシュボードで見たい, といったニーズには Lighthouse CI + LHCI Server のほうが向いています. 長期運用で高スコアを維持するフェーズでは Lighthouse CI, 改善フェーズで後戻りを防ぎながら押し上げていくフェーズでは本アプローチ, という棲み分けになります.
まとめ
- Vitest の
snapshotState.match()を使えば, スナップショットの保存・比較を自前で制御するカスタムマッチャーが作れる - スナップショットを基準値とし, しきい値で比較することで、数値の揺らぎを許容しつつ劣化を検知できる
- スコアが改善されたときも fail にすることで, 基準値を引き上げ, 継続的な改善を強制できる
- Playwright + CDP + Lighthouse の組み合わせで, テストから Lighthouse スコアを取得できる
これらを組み合わせれば、expect(page).toRetainLighthouseScoreSnapshot() の 1 行で Lighthouse スコアの劣化防止と継続的改善の両方を実現できます.
また, 閾値のある Snapshot Test の応用としては, ビルド結果となる HTML, CSS, JS のバンドルサイズのむやみな増加を防ぐアイデアもあります. 他にも様々な応用性がありそうですね.
📖関連記事