概要
Reactプロジェクトで、共有コンポーネント自身が margin を持たないというルールを、Playwrightのテストとコミット前チェックで自動的に検出できるようにした話です。
背景
AIによる開発が中心になってきたことで、ルールをプロンプトやドキュメントに継ぎ足し続けることの限界を感じるようになりました。
コンテキストが増えるほどLLMの出力は不安定になり、最終的に人間のレビューで拾う前提になりがちです。
機械的に検出できるものは仕組みに任せ、AIが迷う余地をなくしつつ人間の介在点も絞っていく、という方針で取り組んだのがこの実装です。構成はReact + Vite + Tailwind CSSです。
課題
共有コンポーネント(ボタン、入力フィールド、ヘッダーなど)に margin が含まれると、それを使う側のページに意図しないレイアウト崩れを起こす場合があります。そのため、外側の余白や配置はコンポーネントを使う側が担当するというのがこのプロジェクトの方針なのですが、このルールをプロンプトやドキュメントで管理すると、AIが見落とした箇所を人間のコードレビューで拾い続けることになります。
検出できる部分は機械的なチェックに任せ、人間の確認コストを絞りたいと判断しました。
方針
まずは「判定」を仕組み化することにしました。コードの静的解析で margin 系クラスを禁止する方法も検討しましたが、Tailwind CSSのクラス名による検出ではCSSファイルやインラインスタイルへの対応が難しく、判定漏れの起きる可能性もありました。
そのため、ブラウザー上で計算されたcomputed styleを見て判定する方針にしました。クラス名ではなく実際にブラウザーに効いている値を見るため、書き方を問わず網羅できます。
実装の流れ
NG / OK の基準
共有コンポーネントに margin を付けるのではなく、ラップする側の要素で余白を作ります。
// NG
<Button className="mt-4">保存</Button>
// OK
<div className="mt-4">
<Button>保存</Button>
</div>
要素間の間隔も同様で、片方の要素に margin を付けるのではなく、親の gap で間隔を作ります。
// NG
<span className="ml-2">テキスト</span>
// OK
<div className="flex items-center gap-2">
{icon}
<span>テキスト</span>
</div>
検証用ページの用意
テスト専用の共有コンポーネントページを用意しました。実行速度と認証回避のため、Playwrightから直接アクセスできる専用ページにしています。このコンポーネントは import.meta.env.DEV で開発環境のみ有効なルート(/dev/harness)として登録し、本番ビルドには含まれないようにしています。
export function ComponentHarness() {
return (
<div className="p-4">
<div data-testid="__canary_margin__">
<div className="mt-4">canary</div>
</div>
<div data-testid="Button"><Button>test</Button></div>
<div data-testid="Input"><Input /></div>
<div data-testid="Header"><Header /></div>
<div data-testid="NavBar"><NavBar /></div>
{/* 他の共有コンポーネントも同様に列挙する */}
</div>
);
}
__canary_margin__ は margin が検出できることの動作確認用です。テスト対象のコンポーネントはそれぞれ data-testid でラップし、Playwrightからは直下の要素をルートとして扱います。
const rootEl = page.getByTestId('Button').locator('> *').first();
computed style で margin を判定する
getComputedStyle で実際に効いている margin を取得し、すべて 0px であることを確認します。Tailwind CSSのクラス、CSSファイル、インラインスタイルなど、どの書き方でも最終的にcomputed styleに反映されるため、ここを見れば判定を網羅できます。
const margin = await rootEl.evaluate((el) => {
const s = getComputedStyle(el);
return {
top: s.marginTop,
right: s.marginRight,
bottom: s.marginBottom,
left: s.marginLeft,
};
});
テストコード
import { test, expect } from '@playwright/test';
const COMPONENTS = ['Button', 'Input', 'Header', 'NavBar'] as const;
const ZERO_MARGIN = {
top: '0px',
right: '0px',
bottom: '0px',
left: '0px',
};
test('共有コンポーネントのルート要素に margin がないこと', async ({ page }) => {
await page.goto('/dev/harness');
for (const name of COMPONENTS) {
const rootEl = page.getByTestId(name).locator('> *').first();
const margin = await rootEl.evaluate((el) => {
const s = getComputedStyle(el);
return {
top: s.marginTop,
right: s.marginRight,
bottom: s.marginBottom,
left: s.marginLeft,
};
});
expect(margin, `[${name}] ルート要素に margin があります`).toEqual(ZERO_MARGIN);
}
});
コミット前の自動チェック
毎回Playwrightを動かすとコストが高いため、Huskyの pre-commit を使って、共有コンポーネントに変更がある場合だけ実行するようにしました。
# .husky/pre-commit
# 共有コンポーネントに変更がある場合だけ margin チェックを実行する
if git diff --cached --name-only | grep -q "src/shared/components/"; then
if ! pnpm playwright test ui-tests/shared-no-margin.spec.ts --reporter=line; then
exit 1
fi
fi
やってみて分かったこと
computed styleを使う方針は、Tailwind CSSのクラス、CSSファイル、インラインスタイルのどれにも対応できるため、判定の網羅性を確保しやすくなりました。
一方で、ここまでの内容では ComponentHarness への登録漏れは検出できません。共有コンポーネントを追加した際には COMPONENTS 配列と ComponentHarness の両方に加える必要があり、この追加漏れを防ぐ仕組みは別の課題として検討する必要があります。
まとめ
共有コンポーネントへの margin 混入を、Playwrightのcomputed style判定とコミット前チェックで自動化しました。
小さなルールほど機械的なチェックで担保しておくと、プロンプトのコンテキストを増やさずにAIとの分業を安定させやすくなると考えています。対象コンポーネントの登録漏れの対策についても、いずれどこかでお話しするかもしれません。