ディレクトリ構造で実行範囲を最適化する 無理のないVRTの運用
はじめに
バッジください。うきおです。
レバテック開発部 Advent Calendar 2025トップバッターになりました。
本当はもっと色んな層に刺さる記事を書けるとアドベントカレンダーの1人目としては良かったのかなと思いつつ、フロントエンドをちょこちょこ触っているのでフロントエンド関連の記事に落ち着いてしまいました。
本記事では、私たちのチームでVRT(Visual Regression Testing)を導入する際に、ディレクトリ構造に基づいて実行範囲を最適化する仕組みを実装した話をまとめます。
チームの開発スタイルと解決したい課題
本記事を読み進める上で知っておいた方が良いことを取り上げます:
チームの開発スタイル
- ページ(機能)単位のリプレースプロジェクトを実施中
- 月1回のライブラリアップデートを実施
特に、(UI)ライブラリのアップデートは、担当者が検証環境でシナリオテストを通して問題がないことを確認していましたが、この確認作業のみではUIの表示崩れを網羅的に確認することは困難でした。
解決したい課題
上述のUIのリグレッションテストのコストを抑えたいことに加えて、VRTの実行時間の増加も課題としてありました。
以下にまとめています。
- UIライブラリのアップデート時や共通コンポーネントの修正時の表示崩れや予期せぬ影響を網羅的にチェックしたい
- とはいえ、ただVRTを導入するだけでは、プロジェクトの進捗に比例して実行時間が肥大化してしまうので開発体験が悪くなってしまう
これらの課題の解決のために、ディレクトリ構造でVRTの実行範囲を限定するような仕組みを取り入れたよ、と言う話をしていきます。
VRTとは?
簡単に(雑に)紹介しておきます。
ご存知の方は読み飛ばしてしまってください。
VRT(Visual Regression Testing)は、UIの見た目の変化を自動的に検出するテスト手法です。
基本的な仕組み
ざっくりこんな感じです。
- ベースラインの作成:正常な状態のUIスクリーンショットを保存
- 比較:コード変更後に同じ画面をスクリーンショット
- 差分検出:2つの画像をピクセル単位で比較
- レポート:差異があれば開発者に通知
ということで、実際にブラウザで表示される UI を元に、変更差分を検知してくれる仕組みとなっており、差分があればどのような差分があったのかをブラウザ上で確認できます。
プロジェクトの特性
工夫した点の前提として、チームの技術スタックやディレクトリ構成の説明を先にしておきます。
技術スタック
よくあるフロントエンドの技術構成です。
- Next.js App Router
- feature-based pattern のディレクトリ構成
- UIライブラリ(デザインシステム)を使用
- Storybook でコンポーネント開発
- Playwright でVRT実行
ディレクトリ構成の概要
私たちのプロジェクトでは、feature-based patternを採用しています。各ルーティング配下でのみ使うコンポーネントは_componentsにコロケーションし、汎用的なコンポーネントはappディレクトリと同階層に配置することで、各コンポーネントの利用範囲を明確にするようにしています。
コンポーネントの再利用性の低下に対しての所感
AtomicDesignのようなパターンと比較するとコンポーネントの再利用性は低下してしまいますが、Atomicなコンポーネントは内製のUIライブラリでカバーできているので問題はありませんし、コンポーネントの利用範囲を明確化できるので「ちょっとしたデザインの変更」のために、多数のファイルチェックや、optionalなpropsを減らせるので各コンポーネントの責務が明確になり、テストやメンテ性が高まっているように感じています。
簡単にサンプルを用意しましたので、このサンプルをもとに説明します。
簡略版(構造の概念):
project/
├─ app/
│ ├─ users/
│ │ └─ _components/ # users 内でのみ使用するコンポーネント
│ ├─ posts/
│ │ ├─ _components/ # posts/* 内でのみ使用するコンポーネント
│ │ └─ [id]/
│ │ └─ _components/ # posts/[id] ページ内でのみ使用するコンポーネント
│ └─ _components/ # アプリ全体で使用するコンポーネント
├─ components/ # 共通のコンポーネント
├─ package.json
└─ yarn.lock
ファイル配置までした詳細なディレクトリ構成を見る
各コンポーネントは、実装ファイル・スタイル・Story・テストをセットで管理しています。
project/
├─ app/
│ ├─ users/
│ │ └─ _components/
│ │ ├─ user-list/
│ │ │ ├─ user-list.tsx
│ │ │ ├─ user-list.module.scss
│ │ │ ├─ user-list.stories.tsx
│ │ │ └─ user-list.test.tsx
│ │ ├─ user-card/
│ │ │ ├─ user-card.tsx
│ │ │ ├─ user-card.module.scss
│ │ │ ├─ user-card.stories.tsx
│ │ │ └─ user-card.test.tsx
│ │ └─ etc/
│ ├─ posts/
│ │ ├─ _components/
│ │ │ ├─ post-list/
│ │ │ │ ├─ post-list.tsx
│ │ │ │ ├─ post-list.module.scss
│ │ │ │ ├─ post-list.stories.tsx
│ │ │ │ └─ post-list.test.tsx
│ │ │ ├─ post-form/
│ │ │ │ ├─ post-form.tsx
│ │ │ │ ├─ post-form.module.scss
│ │ │ │ ├─ post-form.stories.tsx
│ │ │ │ └─ post-form.test.tsx
│ │ │ └─ etc/
│ │ ├─ page.tsx
│ │ ├─ layout.tsx
│ │ └─ [id]/
│ │ └─ _components/
│ │ └─ etc/
│ ├─ _components/
│ ├─ page.tsx
│ └─ layout.tsx
├─ components/ # 共通コンポーネント
│ ├─ login-modal/
│ │ ├─ login-modal.tsx
│ │ ├─ login-modal.module.scss
│ │ ├─ .stories.tsx
│ │ └─ login-modal.test.tsx
│ └─ etc/
├─ package.json
└─ yarn.lock
工夫したポイント
VRT自体はPlaywright x storybookで実行しています。
storybookが内部的にvitest・Playwrightを使っているため、Playwrightのメソッドが使えるので、導入自体は難しくないです。(参考)
なので、導入部分は割愛して、早速工夫ポイントの紹介に移ります。
1. 影響範囲に基づく実行制御
VRTの実行時間はコンポーネント数に比例して増大します。そのため、プロジェクトが進むにつれて実行時間は伸びていき、テスト実行の煩わしさから開発者体験は低下していくことになります。
そこで、feature-based patternのディレクトリ構造を活用し、変更の影響範囲に応じてVRTの実行対象を制御する仕組みを実装しました。
基本的な考え方
ざっくりこんな感じです。
基本的には以下の条件で実行しています。
-
ライブラリ更新 (
yarn.lockの変更) → 全実行 -
共通コンポーネント更新 (
/components/配下の変更) → 全実行 -
feature配下のコンポーネント変更 (
/app/xxx/_components/) → そのfeature(xxx)配下のみ実行
実装の詳細
実装は Node.js スクリプトで行いました。以下、ポイントを抜粋して解説します。
差分ファイルの取得:
function getChangedFiles(): string[] {
try {
const localBranch = execSync("git rev-parse --abbrev-ref HEAD")
.toString()
.trim();
const remoteBranch = `origin/${localBranch}`;
// リモートブランチとの差分を取得
// 初回pushの場合は、ブランチ作成元との差分を取得
let diffBase = "HEAD";
try {
execSync(`git rev-parse ${remoteBranch}`);
diffBase = remoteBranch;
} catch (e) {
// 初回pushの場合の処理
const reflogOutput = execSync(`git reflog show ${localBranch}`)
.toString()
.split("\n");
const createdFromLine = reflogOutput.find((line) =>
line.includes("branch: Created from"),
);
if (!createdFromLine) return [];
const commitHash = createdFromLine.split(" ")[0];
return execSync(`git diff --name-only ${commitHash}`)
.toString()
.trim()
.split("\n");
}
const diff = execSync(`git diff --name-only ${diffBase}..HEAD`)
.toString()
.trim();
return diff ? diff.split("\n") : [];
} catch (e) {
console.error("❌ 差分取得中にエラーが発生しました:", e);
return [];
}
}
影響範囲の特定:
function findAffectedComponentRoots(changedFiles: string[]): Set {
const roots = new Set();
for (const file of changedFiles) {
// .tsx ファイルかつ _components/ 配下のもののみ対象
if (
!file.match(/\.tsx$/) ||
!COMPONENT_DIR_PATTERNS.some((pattern) => pattern.test(file))
) {
continue;
}
const relativeDirPath = path.relative("project/", file);
// _components ディレクトリのパスを抽出
const idx = relativeDirPath.lastIndexOf("_components/");
const testTargetPath = relativeDirPath.slice(0, idx);
// 同じfeature配下での重複を防ぐ
if (roots.has(testTargetPath)) continue;
roots.add(testTargetPath);
}
return roots;
}
例えば以下のように、feature配下で複数のコンポーネントに変更を加えた場合には、重複して実行されないように Set でまとめるようにしています。
-
/app/users/_components/user-card/user-card.tsx→/app/users/を抽出 -
/app/users/_components/user-list/user-list.tsx→/app/users/を抽出(重複なのでSetで1つに)
この仕組みにより、同じfeature配下で複数のコンポーネントが変更されても、VRTは1回だけ実行されます。
全体のコードを見る
import { execSync } from "child_process";
import * as path from "path";
/** コンポーネントを含むパスの正規表現パターン(複数パターン対応) */
const COMPONENT_DIR_PATTERNS = [
/projects\/components\//,
/projects\/app\/.*?_components\//,
];
/** git diff で変更ファイル一覧を取得(ローカルとリモートの差分を取得、初回 push 時は派生元のブランチのコミットハッシュと比較) */
function getChangedFiles(): string[] {
try {
const localBranch = execSync("git rev-parse --abbrev-ref HEAD")
.toString()
.trim();
const remoteBranch = `origin/${localBranch}`;
try {
execSync(`git fetch origin ${localBranch}`);
} catch (e) {
console.warn(`⚠️ fetch failed for ${remoteBranch}`);
}
let diffBase = "HEAD";
try {
execSync(`git rev-parse ${remoteBranch}`);
diffBase = remoteBranch;
} catch (e) {
const reflogOutput = execSync(`git reflog show ${localBranch}`)
.toString()
.split("\n");
const createdFromLine = reflogOutput.find((line) =>
line.includes("branch: Created from"),
);
if (!createdFromLine) return [];
const commitHash = createdFromLine.split(" ")[0];
const diff = execSync(`git diff --name-only ${commitHash}`)
.toString()
.trim();
return diff ? diff.split("\n") : [];
}
const diff = execSync(`git diff --name-only ${diffBase}..HEAD`)
.toString()
.trim();
return diff ? diff.split("\n") : [];
} catch (e) {
console.error("❌ 差分取得中にエラーが発生しました:", e);
return [];
}
}
/** package.json(yarn.lock) に変更が入ったかどうかを検知 */
function isCriticalLibraryUpdated(): boolean {
return changedFiles.some((f) => f === "yarn.lock");
}
/** 共通コンポーネントに変更が入ったかどうかを検知 */
function isCommonComponentUpdated() {
return changedFiles.some((f) => f.includes("project/components/"));
}
/** .tsx or .stories.ts(x) ファイルを見つけ、属する components/_components ディレクトリのファイル名を抽出 */
function findAffectedComponentRoots(changedFiles: string[]): Set {
const roots = new Set();
for (const file of changedFiles) {
// 変更ファイルが .tsx または .stories.tsx かつ COMPONENT_DIR_PATTERNS に一致するもののみ対象
if (
!file.match(/\.tsx$/) ||
!COMPONENT_DIR_PATTERNS.some((pattern) => pattern.test(file))
) {
continue;
}
const relativeDirPath = path.relative("project/", file);
// そのコンポーネントが属する components/ ディレクトリを特定してそのディレクトリを実行対象に加える
const idx = relativeDirPath.lastIndexOf("_components/");
const testTargetPath = relativeDirPath.slice(0, idx);
// 同じ階下ですでに追加されている場合は、テスト対象に追加せずにスキップする
if (roots.has(testTargetPath)) continue;
roots.add(testTargetPath);
}
return roots;
}
function runVRTForUpdated(testTargetPaths: Set) {
if (testTargetPaths.size === 0) {
console.log(
"✅ 変更されたコンポーネントはありません。VRTはスキップされました。",
);
return;
}
const grep = Array.from(testTargetPaths).join("&");
console.log(`🧪 実行対象: ${grep}`);
execSync(`VRT_TARGETS='${grep}' yarn storybook:vrt`, {
stdio: "inherit",
});
}
function runAllVRT() {
console.log("📦 重要ライブラリ or 共通コンポーネントの更新検知 → 全VRT実行");
execSync("yarn storybook:vrt", { stdio: "inherit" });
}
/** ==== script 実行開始 ==== */
const changedFiles = getChangedFiles();
/**
* NOTE: パッケージにアプデが加わった場合と共通コンポーネントに変更が加わった場合は全実行する
* 特定のモジュールに変更が加わった場合には、そのコンポーネントが属する _components ディレクトリのみ実行の対象とする
**/
if (isCriticalLibraryUpdated() || isCommonComponentUpdated()) {
runAllVRT();
} else {
const affectedRoots = findAffectedComponentRoots(changedFiles);
runVRTForUpdated(affectedRoots);
}
2. Storybookとの連携
VRTの実行にはStorybookを利用しています。元々、VRTの導入以前からStorybookを使用しており、開発段階で各コンポーネントのあらゆるパターンのStoryを作成していました。
なので、作成したStoryをそのままPlaywrightでスナップショットとして取得することで、VRTのためのテストコードを極力減らせるようにしています。
ソースコードはこんな感じです。
import { expect, test } from "@playwright/test";
import fetch from "sync-fetch";
// storybook の接続先と components のリストを取得
const url = "http://localhost:6006";
const json = fetch(`${url}/index.json`).json();
// テスト実行時に環境変数に渡すファイルパスを分割
const testTargets: string[] | undefined = process.env.VRT_TARGETS?.split("&");
// component がテストの対象として含まれるべきかを判定する
function isTestTargetsComponents(componentPath: string) {
if (!testTargets) return true; // 何も指定していない場合は全実行
if (!componentPath) return false; // ファイルパスがない場合は対象外
return testTargets.some((t) => componentPath.includes(t));
}
// test 対象のストーリーファイルを配列で保持
const stories = Object.values(json.entries).filter(
(entry: any) =>
entry.type === "story" &&
!entry.tags?.includes("no-vrt") &&
isTestTargetsComponents(entry.componentPath),
) as { id: string; componentPath: string }[];
test.describe.configure({ mode: "parallel" });
// テスト対象のファイルに対してループ処理を使って VRT を実行
stories.forEach((story) => {
test(story.id, async ({ page }) => {
await page.goto(`${url}/iframe.html?id=${story.id}`);
// CSS の読み込みを待つ
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${story.id}.png`, {
fullPage: true,
});
});
});
no-vrtタグの活用
インタラクションテストを実施しているStoryや、ローディングアイコンのようなflakyなコンポーネントにはno-vrtタグを付与することで、VRTをスキップするようにしています。
export default {
title: 'Components/LoadingSpinner',
component: LoadingSpinner,
tags: ['no-vrt'], // VRTをスキップ
};
3. ローカル実行という選択
VRTの実行環境として、CIではなく 各開発者のローカルマシン(pre-push) を選択しました。
なぜローカルなのか?
VRTの実行はマシンスペックに大きく依存します。GitHub ActionsやDevinのようなAIツールの実行環境では、並行して実行できるワーカー数が少なく、
- 実行時間が間延びする、
- タイムアウトして失敗する
といったことが多く、開発者体験が悪くなりそうだったので現時点ではhuskyを使ってpre-pushのタイミングでローカルマシンで実行するようにしています。
ローカルマシンで動くAIエージェントならいける
GitHub ActionsやDevinと紹介しましたが、VRTの導入時点では主にCIは
GitHub Actions、AIエージェントはDevinを利用していため問題視しておりました。
ただ、この記事の執筆時点ではClaudeCodeをメインで利用しているため、以前よりも課題感は減ったような気がしています。
導入の効果
実行時間の削減
ページ単位のリプレースプロジェクトという特性上、通常は該当ページ内のコンポーネントのみテストすれば良いため、全体実行が必要なケースはほとんど発生しません。
なので、ある機能のリプレース完了後、次の機能リプレースではそのファイルは実行対象外となるため、リプレースを続ける上でのVRTの煩わしさは軽減できたと思います。
記事執筆時点でのプロジェクトでは、20ファイル/20秒 程度で実行が完了します。
開発体験の向上
以前担当していたサービスでもVRTを導入していましたが、やはりのプロジェクトの進展に伴ってVRTの実行時間が顕著に伸びていました。それが本記事で紹介した仕組みを導入した現チームのVRTでは、VRT用のスナップショットを追加するコストも下がり、機能開発のタイミングではほとんど気にならないくらいの実行時間になりました。
VRTのテストケースを貯めやすくなったことで、UIライブラリや共通コンポーネントの修正をしてもVRTの実行によって表示崩れがないことを網羅性高く担保しやすくなり、検証環境での表示崩れの確認作業もかなり楽になりました。(今はほとんど意識してやってないくらいには楽になっています)
まとめ
まだまだ改善できる余地はありますが、プロジェクトを進める上で課題だった部分は一旦解消されたのが良かったです。
謝罪
本記事で掲載する仕組みを作成する上でとても参考にさせていただいた記事があったのですが、その記事のURLが見つからず、掲載することができませんでした。。。
まるで自身が一から組み立てたように書いてしまっているのですが、そんなことは全くございません。
大変申し訳ありません。
Discussion