Claude CodeでWordPressのPHPUnitを回す — wp-envとStop hookで失敗を戻す
WordPressプラグインのWP_UnitTestCaseをClaude Codeに書かせ、wp-envでPHPUnitを実行します。Stop hookでテスト失敗をClaudeに戻す設定までの手順です。
WordPressプラグインのテストは、普通のPHPUnitとは前提が違います。WP_UnitTestCaseを継承したテストは、WordPress本体を読み込んだデータベース付きの環境で動くからです。この環境を手元で用意する手間が、Claude Codeにテストを書かせる最初の壁になります。
wp-envを使えば壁は低くなります。Dockerの中にWordPressとテスト用ファイルが揃うので、phpunitをコマンド1つで呼べます。さらにStop hookを足すと、Claudeが作業を終えようとした瞬間にPHPUnitが走り、失敗があれば続きの作業として本人に戻ります。
WP_UnitTestCaseのテストとは何か
WP_UnitTestCaseは、WordPressのテストスイートが提供するテストの基底クラスです。公式ハンドブックによると、コアのテストクラスはすべてこれを継承します。基底クラスがテストごとにデータベースを既定の状態へ戻し、投稿タイプやタクソノミー、登録済みのフックなどを片付けます。
つまり、プラグインのテストも同じクラスを継承すれば、wp_insert_post()のような関数を本物のWordPressに対して呼べます。モックを組む必要が減る代わりに、実行にはWordPressとデータベースが要ります。
全体の流れは次のとおりです。
PHPUnitを回すまでの流れ
- 1
wp-envを起動する
プラグインのフォルダで
wp-env startを実行します。プラグインが有効化されたWordPressが立ち上がります。 - 2
tests/bootstrap.phpを用意する
テスト実行時にプラグインを読み込ませるファイルです。
phpunit.xml.distと対で置きます。 - 3
Claude Codeにテストを書かせる
WP_UnitTestCaseのルールをCLAUDE.mdに書いておき、機能とテストを同時に作らせます。 - 4
Stop hookで結果を戻す
Claudeが止まる直前にPHPUnitを実行し、失敗の出力を本人に読ませます。
wp-envでPHPUnitを実行する
wp-envの公式READMEによると、環境にはWordPress本体に対応するPHPUnitのテストファイルが最初から入っています。その場所は環境変数WP_TESTS_DIRが指します。自分でテストライブラリを取得して配置する作業は要りません。
Composer、PHPUnit、WP-CLIもcliコンテナで使えます。実行はwp-env run <container> <tool>の形です。既定の作業ディレクトリはWordPressのルートなので、プラグインのテストを走らせるには--env-cwdでプラグインのフォルダを指定します。
wp-env start
wp-env run cli --env-cwd=wp-content/plugins/my-plugin composer install
wp-env run cli --env-cwd=wp-content/plugins/my-plugin phpunitmy-pluginは例です。プラグインのフォルダ名に置き換えます。--env-cwdの値は、先頭にスラッシュがなければWordPressのルートからの相対パスとして扱われます。
phpunitに引数を渡したいときは、wp-envが解釈する前に渡す必要があります。READMEは、競合するオプションをダッシュ2つの後ろに置く書き方を案内しています。
毎回長いコマンドを打たせない
Claude Codeに毎回この長い行を組み立てさせると、書き間違いが出ます。READMEは、package.jsonのscriptsにまとめる方法を例に挙げています。
{
"scripts": {
"test:php": "wp-env run cli --env-cwd=wp-content/plugins/my-plugin phpunit"
}
}こうしておけば、Claudeへの指示はnpm run test:phpだけで済みます。後で出すhookのスクリプトも同じコマンドを使い回せます。
tests/bootstrap.phpを用意する
プラグインのテストには、テスト実行時にそのプラグインを有効にするブートストラップが要ります。WP-CLIのwp scaffold plugin-testsは、そのための一式を生成するコマンドです。生成物は次のとおりです。
phpunit.xml.dist: PHPUnitの設定ファイルtests/bootstrap.php: 現在のプラグインをテスト実行中に有効にするファイルtests/test-sample.php: テストのサンプルbin/install-wp-tests.sh: テストスイートとテスト用データベースを設定するスクリプト- CI用の設定ファイルと
.phpcs.xml.dist
生成されたtests/bootstrap.phpはWP_TESTS_DIR環境変数を探します。wp-envの環境にはテストファイルとWP_TESTS_DIRが最初から入っているため、テストスイートを取得するbin/install-wp-tests.shの出番は、この構成では見込めません。phpunit.xml.distとtests/の側を使う形です。
ブートストラップを一から書かせるなら、次のような形になります。以下は構造を示す例で、公式のサンプルそのものではありません。
<?php
// tests/bootstrap.php(構造を示す例)
$tests_dir = getenv( 'WP_TESTS_DIR' );
if ( ! $tests_dir ) {
echo "WP_TESTS_DIR が設定されていません。wp-env の中で実行してください。\n";
exit( 1 );
}
require_once $tests_dir . '/includes/functions.php';
tests_add_filter( 'muplugins_loaded', function () {
require dirname( __DIR__ ) . '/my-plugin.php';
} );
require $tests_dir . '/includes/bootstrap.php';wp-tests-config.phpを自前のものに差し替えたい場合、READMEはWP_TESTS_CONFIG_FILE_PATH定数をbootstrap.phpで定義する方法を案内しています。環境が用意する既定の設定で足りるなら、触りません。
WP_UnitTestCaseのテストをClaude Codeに書かせる
テストの書き方には、WordPress流の決まりがあります。PHPUnit一般の知識だけで書かせると、動くけれど作法に外れたテストになります。決まりはCLAUDE.mdに固定しておくと、毎回の指示を省けます。
# PHPUnitテストの約束
## コマンド
- テスト実行: `npm run test:php`
- 1クラスだけ: `npm run test:php -- --filter クラス名`
## WP_UnitTestCaseのルール
- テストクラスは `WP_UnitTestCase` を継承する
- フィクスチャ用の関数は snake_case の `set_up()` / `tear_down()` を使う
- `set_up()` の最初の行で `parent::set_up()`、`tear_down()` の最後の行で `parent::tear_down()` を呼ぶ
- テスト用のユーザーや投稿は `self::factory()` で作る
- テストメソッド名は `test_` で始める
- 複数のアサーションがあるときは、失敗時のメッセージ引数を付けるこれらの決まりは、WordPressコアのハンドブックに書かれた内容に沿っています。camelCaseのsetUp()ではなくsnake_caseを使う理由は、ハンドブックによればPHPUnitのバージョン間の互換性を保つためです。
書かせるテストの形
プラグインに「ユーザーが公開した投稿の数を返す関数」があるとします。名前はmy_plugin_published_count()で、関数もこの記事の例です。テストは次のようになります。
<?php
// tests/test-published-count.php(構造を示す例)
class Tests_Published_Count extends WP_UnitTestCase {
public function test_should_count_only_published_posts() {
$user_id = self::factory()->user->create( array( 'role' => 'author' ) );
self::factory()->post->create_many( 2, array(
'post_author' => $user_id,
'post_status' => 'publish',
) );
self::factory()->post->create( array(
'post_author' => $user_id,
'post_status' => 'draft',
) );
$this->assertSame(
2,
my_plugin_published_count( $user_id ),
'下書きは数に含めない'
);
}
}ここで見てほしいのは、テストの中で必要なデータを自分で作っている点です。
フィクスチャの作らせ方で外しやすい点
ハンドブックには、テストメソッドは空のWordPress環境から始まると書かれています。前のテストが作った投稿やユーザーは残りません。Claude Codeが「どこかにある既存の投稿」を前提にテストを書くと、単独では通っても全体では落ちます。
フィクスチャの指示で効くのは、次の3点です。
| 状況 | 使うもの | 注意 |
|---|---|---|
| 1件だけ作る | 使うものself::factory()->post->create() | 注意返るのはIDです。オブジェクトが要るならcreate_and_get() |
| 同種を複数作る | 使うものcreate_many( 3, array(...) ) | 注意連番でユニークな値が入ります。メールアドレスの重複などが避けられます |
| クラス内で共有する | 使うものwpSetUpBeforeClass( $factory ) | 注意staticで宣言し、self::factory()は呼ばずに渡された$factoryを使います |
3つ目の注意は見落としやすい点です。ハンドブックは、wpSetUpBeforeClass()の中でself::factory()を呼ぶとユニーク性を担保するイテレーターが壊れる、と説明しています。Claudeに共有フィクスチャを書かせるときは、CLAUDE.mdにこの1行を足しておくと安全です。
同じ入力を少しずつ変えたテストを何本も書かせたくなったら、データプロバイダーを使うよう指示します。ハンドブックは、コピーしたテストメソッドよりデータプロバイダーを勧めています。
Stop hookでphpunitの失敗をClaudeに戻す
テストを書かせても、Claudeが「通りました」と報告するだけでは信用できません。実際に走らせた結果を、本人に突き返す仕組みにします。
Stopは、メインのエージェントが応答を終えたときに動くイベントです。ユーザーが中断した場合は動きません。このhookが終了コード2で終わると、Claudeは標準エラー出力の内容を「続けるべき理由」として受け取ります。つまりPHPUnitの失敗出力を標準エラーに出して終了コード2で抜ければ、Claudeはそれを読んで直しに戻ります。
スクリプトを書く
.claude/hooks/run-phpunit.shを作ります。
#!/bin/bash
# Docker が動いていなければ何もしない
docker info >/dev/null 2>&1 || exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
# PHP ファイルに変更がなければ実行しない
git status --porcelain | grep -q '\.php$' || exit 0
OUT=$(wp-env run cli --env-cwd=wp-content/plugins/my-plugin phpunit 2>&1)
STATUS=$?
if [ "$STATUS" -ne 0 ]; then
echo "PHPUnit が失敗しました(終了コード $STATUS)。出力の末尾です。" >&2
echo "$OUT" | tail -n 60 >&2
exit 2
fi
exit 0コマンド型のhookには、CLAUDE_PROJECT_DIRなどが環境変数として渡されます。実行権限を付けておきます。
chmod +x .claude/hooks/run-phpunit.sh最初の2つのexit 0には理由があります。Dockerが止まっているとwp-envは失敗し、そのたびにClaudeへ「失敗」を戻してしまいます。Claudeにはどうにもできない原因なので、環境の問題は黙って通します。PHPファイルに変更がないターンも同様で、質問に答えただけの応答のたびに、数十秒かかるテストを走らせる理由がありません。
settings.jsonに登録する
.claude/settings.jsonに次を足します。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/run-phpunit.sh",
"timeout": 300
}
]
}
]
}
}timeoutの既定値は、command型のhookで600秒です。wp-envの初回起動が絡むとテストが長引くことがあるので、ここでは300秒を明示しました。テストが重いプラグインなら、実測に合わせて調整します。
止まらない場合の歯止め
失敗が直らないテストでhookが毎回ブロックすると、無限に続きそうに見えます。公式の仕様にはその歯止めがあります。
- 入力JSONの
stop_hook_activeは、すでにStop hookの結果で続行中のときにtrueになる - Stop hookが連続で8回続行させると、次のブロックは上書きされてターンが終わる
- 連続回数は、Claudeがツールを呼ぶたびにリセットされる
- 上限は環境変数
CLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられる
公式は、永遠に解決しない条件でブロックし続けないよう、stop_hook_activeか会話履歴を確認することを案内しています。PHPUnitのように「直せば通る」条件なら、このスクリプトのように毎回実行して構いません。環境の故障のような直せない原因は、先頭のexit 0で逃がしています。
動作を確かめる
hookが正しく働くか、わざと壊して確かめます。
- 関数の戻り値を書き換えるなど、テストが落ちる変更をClaudeにさせる
- 応答の終わりに、PHPUnitの失敗出力がClaudeへ戻ることを確かめる
- Claudeが直し、再度テストが通ることを確かめる
hookの設定が反映されていない場合は、/hooksメニューで登録内容を見ます。
特定のテストだけ実行したいときは、--filterやグループで絞れます。WordPressのハンドブックでは、クラス名を--filterに渡す方法と、@groupで分けて--groupを指定する方法が紹介されています。
npm run test:php -- --filter Tests_Published_Count
npm run test:php -- --group my-plugin出力の記号は、.が成功、Fが失敗、EがPHPのエラーや警告による失敗、Sがスキップ、Rがリスキー、Iが未実装です。EはPHP側のエラー、Fはアサーションの不一致なので、出力を読むときに見分けられます。
よくあるつまずき
テストファイルが実行されない
メソッド名がtest_で始まらないと、PHPUnitは実行しません。ハンドブックにある仕様です。Claudeがit_counts_posts()のような名前で書いたら、ここで落とし穴にはまっています。CLAUDE.mdの約束に入れておく理由はこれです。
set_upを書くと他のテストが壊れる
set_up()の最初にparent::set_up()を呼び忘れると、基底クラスのデータベース初期化などが走りません。ハンドブックは、set_up()の先頭とtear_down()の末尾で親を呼ぶことを必須としています。
wp-envの環境が前回の状態を引きずる
wp-env cleanupで環境をリセットできます。消えるのはデータベースだけではなく、コンテナ、ボリューム、ローカルファイルも含みます。イメージまで消して作り直すならwp-env destroyです。どちらも、まずClaudeではなく人が実行を確認してから使う操作にしておくと安全です。
まとめ
WordPressのPHPUnitをClaude Codeに任せる構成は、3つの部品で成り立ちます。wp-envがWordPressとテストファイルを用意し、CLAUDE.mdがWP_UnitTestCaseの作法を固定し、Stop hookが実行結果を本人に戻します。
最初に整えるのは、npm run test:phpのような1コマンドです。これがあるだけで、CLAUDE.mdの指示もhookのスクリプトも短く書けます。
ブロック開発の側からwp-envを使う場合はClaude CodeでGutenbergブロックを作る、プラグイン自体の書き方はClaude CodeでWordPressプラグイン開発を始めるにまとめています。PHPUnitからPestへ移すならPHPUnitからPestへの移行手順が参考になります。Stop hookの再発火が気になる場合は/goal Stop hookが止まらない症状と回避策を見てください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。