PestPHPは、PHPUnitの上に関数ベースの記法をかぶせたPHPのテストフレームワークです。テストクラスを定義せずit()やexpect()だけでテストを書けるため記述量が減り、PHPUnitの実行基盤とアサーションはそのまま使えます。この記事は2026年9月10日時点の現行安定版v5.1.4をローカルへ導入し、コマンドの出力を確認しながら書いています。導入でつまずく箇所、PHPUnitとの使い分け、Pest 5の新機能の効きどころまで、実行結果を根拠に説明します。
まとめ:PestPHPの要点
- 現行安定版はv5.1.4(Packagist上の公開は2026年9月7日)。PHP
^8.4とPHPUnit^13.3.2を要求します。PHP 8.3以下の環境ではv4系が上限です。 - PestはPHPUnitを置き換えるのではなく包みます。アサーションの実体もテストランナーもPHPUnitで、Pestが足すのは記法・CLI・プラグイン群です。
- Laravel以外の素のPHPプロジェクトでは、公式手順どおりに
./vendor/bin/pest --initを打っても2つの条件で失敗します。回避手順は後述の実測を参照してください。 - アサーションの検出力は、カバレッジに加えてミューテーションスコアで確認できます。本記事の検証では、カバレッジ100%のまま設定を変えずにアサーションを1つ緩めただけで、ミューテーションスコアが100%から91.67%へ落ち、どの行のどの変異が検出できていないかまで表示されました。
- Pest 5の目玉はTia Engine(テスト影響分析)で、
--tiaを付けると変更に関係しないテストはキャッシュから再生されます。ただしpcovまたはXdebugが有効でないと、記録が行われず全件実行に戻ります。
PestPHPの基本|PHPUnitを包む関数ベースのテストフレームワーク
Pestのテストファイルにはクラス宣言がありません。ファイルの中でグローバル関数を呼ぶだけで、PestがそれをPHPUnitのテストケースへ変換します。実行時のスタックトレースにPHPUnitのクラス名が出るのはこのためです。
test()・it()・describe()の書き分け
test()が基本形で、it()は出力の先頭に「it」が付く別名です。describe()は関連するテストを入れ子にまとめます。実際に次のファイルを実行すると、describe()の中のテストは親子関係が矢印で表示されます。
<?php
use App\Cart;
beforeEach(function () {
$this->cart = new Cart();
});
it('adds an item', function () {
$this->cart->add('SKU-1');
expect($this->cart->count())->toBe(1);
});
test('same sku is merged', function () {
$this->cart->add('SKU-1', 2);
$this->cart->add('SKU-1', 3);
expect($this->cart->items())->toBe(['SKU-1' => 5]);
});
describe('count', function () {
it('starts at zero', function () {
expect($this->cart->count())->toBe(0);
});
});
v5.1.4での実行結果は次のとおりです。describe()で囲んだテストは「count → it starts at zero」という形で出力されます。
PASS Tests\Unit\CartTest
✓ it adds an item 0.01s
✓ same sku is merged
✓ count → it starts at zero
Tests: 3 passed (3 assertions)
Duration: 0.25s
メジャー版とPHP・PHPUnit要件の対応
PestはPHPUnitのメジャー版に追随するため、Pestのメジャー版を上げるとPHPの下限も同時に上がります。既存プロジェクトへ入れる前に、動かしているPHPの版で入るPestの上限を確認してください。次の表は、Packagistのpestphp/pestに登録された各版のrequireを読んだ値です(2026年9月10日取得)。
| Pest | Packagist公開日(UTC) | PHP要件 | PHPUnit要件 |
|---|---|---|---|
| v1.0.0 | 2021-01-03 | ^7.3 || ^8.0 | >= 9.3.7 <= 9.5.0 |
| v2.0.1 | 2023-03-20 | ^8.1.0 | ^10.0.16 |
| v3.0.0 | 2024-09-09 | ^8.2.0 | ^11.3.4 |
| v4.0.0 | 2025-08-20 | ^8.3.0 | ^12.3.5 |
| v5.1.4 | 2026-09-07 | ^8.4 | ^13.3.2 |
v5系の初版v5.0.0は2026年7月24日公開です。PHP 8.3の環境にv5系を入れようとするとcomposerが依存解決に失敗するので、版を上げる前にPHPの版を確認してください。
導入手順|composer requireから最初のテスト実行まで
公式ドキュメントが示す手順は3行です。--with-all-dependenciesは、PHPUnitを含む依存パッケージの版もまとめて引き上げるためのオプションで、これを省くとロック済みの版に固定されたまま依存解決に失敗することがあります。なおv5.1.4は^13.3.2を要求しつつ、composer.jsonのconflictで13.3.2より新しいPHPUnitを除外しています。
composer remove phpunit/phpunit
composer require pestphp/pest --dev --with-all-dependencies
./vendor/bin/pest --init
pest --initが失敗する2つの条件と回避手順
Laravel以外の素のPHPプロジェクトで上の手順をそのまま実行すると、v5.1.4では2回続けて失敗します。実際に空のプロジェクトへ入れて確認した内容を順に示します。
1つ目はtestsディレクトリの不在です。--initはテストディレクトリを作るためのコマンドですが、その前段のブートストラップがディレクトリの存在を確かめるため、先に落ちます。終了コードは255でした。
$ ./vendor/bin/pest --init
Pest\Exceptions\FatalException
The test directory [/private/tmp/pestlab/tests/] does not exist.
at vendor/pestphp/pest/src/Bootstrappers/BootFiles.php:40
$ echo $?
255
回避はmkdir testsを先に打つだけです。これでphpunit.xml、tests/Pest.php、tests/TestCase.php、tests/Unit/ExampleTest.php、tests/Feature/ExampleTest.phpの5ファイルが生成され、終了コードは0になります。
2つ目は、生成されたtests/Pest.phpがTests\TestCaseを参照しているのに、--initがcomposer.jsonへTests\名前空間を登録しない点です。初期化直後に./vendor/bin/pestを実行すると、テストが1件も走らずクラス解決で止まります。
Pest\Exceptions\TestCaseClassOrTraitNotFound
The class or trait [Tests\TestCase] could not be found. Please check the name, and make sure it is autoloadable.
これはcomposer.jsonに次を足してcomposer dump-autoloadを実行すれば解消します。Laravelのスケルトンはアプリケーション側のcomposer.jsonに最初からTests\の登録を持つため発生せず、素のPHPプロジェクト固有のつまずきになります。
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
ここまでで初期化は完了です。次は、初期化後にカート用のテストを追加し、そのファイルだけを実行した結果です。--initが生成するのは空のサンプルテストなので、この7件は初期化だけでは作られません。
$ ./vendor/bin/pest tests/Unit/CartTest.php
PASS Tests\Unit\CartTest
✓ it adds an item 0.02s
✓ same sku is merged
✓ it rejects zero or negative quantity with (0)
✓ it rejects zero or negative quantity with (-1)
✓ count → it starts at zero
✓ count → it chains expectations
✓ it is tagged
Tests: 7 passed (12 assertions)
Duration: 0.28s
基本構文|expect・フック・データセット・実行対象の絞り込み
Pestの記法はグローバル関数test、it、describe、expect、beforeEach、afterEach、beforeAll、afterAll、dataset、uses、pest、covers、mutatesで構成されます。v5.1.4のsrc/Functions.phpにこれらがそのまま定義されています。
expect()のチェーンとカスタム期待値
expect()は値を受け取り、期待値メソッドを連結できるオブジェクトを返します。toBe()、toBeTrue()、toBeArray()、toHaveCount()、toHaveKey()、toBeInstanceOf()などが標準で使えます。例外の検証は、実行対象のクロージャをexpect()へ渡し、toThrow()に例外クラスとメッセージを指定します。
expect($this->cart->items())
->toBeArray()
->toHaveCount(2)
->toHaveKey('SKU-2', 2);
expect(fn () => $this->cart->add('SKU-1', 0))
->toThrow(InvalidArgumentException::class, '数量は1以上を指定してください');
プロジェクト固有の判定はexpect()->extend()で足せます。値を渡さないexpect()に連結するのが決まりで、--initが作るtests/Pest.phpにもtoBeOneという雛形が最初から入っています。
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
フックとtests/Pest.phpの役割
beforeEach()とafterEach()は同一ファイル内の各テストの前後で動き、クロージャの$thisはテストケースへ束縛されます。beforeAll()とafterAll()はファイル単位で1回ずつ動きますが、こちらでは$thisを使えません。共有したい状態はbeforeEach()でプロパティへ入れてください。
tests/Pest.phpはテストスイート全体の設定ファイルです。v5が生成する雛形では、基底テストクラスの割り当てにpest()設定APIを使います。旧来のuses()もv5.1.4に関数として残っていますが、新規に書くならpest()側に寄せるほうが公式の雛形と揃います。
pest()->extend(Tests\TestCase::class)->in('Feature');
データセットによる複数入力の検証
インラインデータセットはテスト定義に->with([...])を連結します。複数のテストファイルで使い回す場合は、公式が案内するとおりtests/Datasets配下のファイルへdataset()で定義し、テスト側から->with('名前')で参照します。実行結果には入力値がそのまま出るので、どの組み合わせで落ちたかが一目で分かります。
// tests/Datasets/Skus.php
dataset('skus', ['SKU-1', 'SKU-2']);
// tests/Unit/DsTest.php
it('uses a shared dataset', function (string $sku) {
$cart = new App\Cart();
$cart->add($sku);
expect($cart->items())->toHaveKey($sku);
})->with('skus');
PASS Tests\Unit\DsTest
✓ it uses a shared dataset with ('SKU-1') 0.01s
✓ it uses a shared dataset with ('SKU-2')
Tests: 2 passed (2 assertions)
Duration: 0.29s
groupによる絞り込みとtodoによるタスク管理
テストに->group('smoke')を連結すると、./vendor/bin/pest --group=smokeでそのグループだけを実行できます。登録済みのグループは--list-groupsで確認でき、アーキテクチャテストは自動的にarchグループへ入ります。
$ ./vendor/bin/pest --list-groups
INFO Available test groups:
- arch (2 tests)
- default (8 tests)
- smoke (1 test)
Pest 3で入ったチーム管理の記法として、->todo()、->wip()、->done()も連結できます。->todo()は担当者とissue番号を名前付き引数で受け取り、--todosで未着手のテストだけを一覧にできます。実装のないテストを先に置いておく用途に使えます。
it('is planned')->todo(assignee: 'taro', issue: 42);
PHPUnitとの違いと、Pestを選ばない方がよい場面
PHPUnitを実行基盤として共有しているため、アサーションの判定そのものに差はありません。速度を比べるならテストと条件をそろえた計測が要ります。選定で実際に効いてくる比較軸は、記法と出力の読みやすさ、Pest側が足すCLIとプラグイン、対応するPHPとPHPUnitの版、既存ツールとの互換性、そして移行と保守の負担です。
driftプラグインによるPHPUnitテストの変換
既存のPHPUnitのテストの多くは自動変換できます。ただし公式の移行ガイドは、一部のテストは手作業での変換が必要になると断っています。公式の変換手段はpestphp/pest-plugin-driftで、導入後に./vendor/bin/pest --driftを実行するとクラスベースのテストが関数ベースへ変換されます。--initは変換コマンドではなく設定ファイルの生成コマンドなので、ここを取り違えないでください。
composer require pestphp/pest-plugin-drift --dev
./vendor/bin/pest --drift
Pestを避けたほうがよい条件
次の3つのいずれかに当てはまるなら、PHPUnitのまま運用するほうが安全です。既存のテスト資産と周辺ツールが整っているほど、記法や追加機能で得るものより移行と保守の負担が上回りやすくなります。
第一に、PHPが8.4未満で当面上げられない場合です。v5系はPHP ^8.4を要求するため、8.3ならv4系、8.2ならv3系で止まり、新機能を追えなくなります。第二に、PHPUnitの属性(#[DataProvider]や#[CoversClass]など)に依存した独自の静的解析やレポート生成を組んでいる場合です。関数ベースへ移すとこれらの属性が消え、周辺ツールを作り直すことになります。第三に、テストコードを書く人がPHPUnit以外を触ったことがなく、レビュー体制も含めて統一されている場合です。この状況では記法の乗り換えが純粋な学習コストになります。
ミューテーションテストによるアサーションの検出力評価
Pest 3で追加されたミューテーションテストは、対象コードに小さな変異を加えたうえでテストを走らせ、その変異をテストが検出できるかを測ります。行カバレッジが100%でも、アサーションが甘ければ変異は素通りします。
ミューテーション対象の指定とカバレッジドライバの要件
まず前提として、ミューテーションテストにはpcovまたはXdebugが必要です。カバレッジドライバがない環境では次のように止まります。
$ ./vendor/bin/pest --mutate
Pest\Exceptions\InvalidOption
Mutation testing requires code coverage to be enabled.
ドライバを入れたあとは、テストファイルにcovers()かmutates()のいずれかで対象を指定します。指定しないまま実行すると、対象クラスを指定するよう促されて終了します。全クラスを対象にしたいときは--everythingで回避できますが、対象を絞ったほうが実行時間は短くなります。テストファイルの先頭に1行足すのが最も手軽です。
covers(App\Cart::class);
アサーション変更前後のミューテーションスコア比較
以降の測定に使った実装が次のsrc/Cart.phpです。テストは前掲のtests/Unit/CartTest.phpに、例外・チェーン・グループの検証を足した7件を使いました。
<?php
declare(strict_types=1);
namespace App;
final class Cart
{
/** @var array<string, int> */
private array $items = [];
public function add(string $sku, int $qty = 1): void
{
if ($qty < 1) {
throw new \InvalidArgumentException('数量は1以上を指定してください');
}
$this->items[$sku] = ($this->items[$sku] ?? 0) + $qty;
}
public function count(): int
{
return array_sum($this->items);
}
/** @return array<string, int> */
public function items(): array
{
return $this->items;
}
}
composer.jsonのautoloadにApp\からsrc/への対応を書いておきます。カバレッジ対象は--initが生成したphpunit.xmlのsource要素がsrcを含むため、追加設定は不要です。測定環境はmacOS上のPHP 8.5.8、Composer 2.10.3、Pest v5.1.4、pcov(ソースからビルドしてphp.iniで有効化)で、各コマンドを1回ずつ実行しました。実行時間はマシンとテスト内容で変わるので目安として読んでください。
この構成で実行した結果、12個の変異がすべて検出され、スコアは100%でした。所要時間は通常実行の0.36秒に対して8.94秒です。この重さのため、ミューテーションテストは変更のあったクラスに絞って回すのが現実的です。
Mutating application files...
12 Mutations for 1 Files created
RUN src/Cart.php
✓ Line 12: DecrementInteger
✓ Line 12: IncrementInteger
✓ Line 14: IfNegated
✓ Line 14: SmallerToGreaterOrEqual
✓ Line 14: SmallerToSmallerOrEqual
✓ Line 14: DecrementInteger
✓ Line 14: IncrementInteger
✓ Line 17: PlusToMinus
✓ Line 17: CoalesceRemoveLeft
✓ Line 17: DecrementInteger
✓ Line 17: IncrementInteger
✓ Line 28: AlwaysReturnEmptyArray
Mutations: 12 tested
Score: 100.00%
Duration: 8.94s
ここで、数量をまとめる処理を検証していたアサーションをtoBe(['SKU-1' => 5])からtoHaveKey('SKU-1')へ緩めます。テストは全件通り、行カバレッジも100%のままです。ところがミューテーションスコアは91.67%へ落ち、どの行のどの変異が検出できていないかが差分付きで表示されました。
UNTESTED src/Cart.php > Line 17: CoalesceRemoveLeft - ID: ce4195fed63a77ee
if ($qty < 1) {
throw new \InvalidArgumentException('数量は1以上を指定してください');
}
- $this->items[$sku] = ($this->items[$sku] ?? 0) + $qty;
+ $this->items[$sku] = 0 + $qty;
}
public function count(): int
{
Mutations: 1 untested, 11 tested
Score: 91.67%
既存の値に加算する処理を「常に0から加算する」へ書き換えても、キーの存在だけを見るテストは落ちません。カバレッジの数字が動かないまま品質が下がったことを検出できたのがミューテーションスコアだけ、という状況が再現できています。カバレッジ100%を達成しているのに不具合が漏れるチームは、この指標を一度測る価値があります。--mutate --min=90のように下限を設ければCIで落とせます。
Pest 5のTia Engine|変更影響に基づく実行と結果の再生
Pest 5の中心的な追加機能がTia Engine(Test Impact Analysis)です。テストがどのファイルに依存しているかのグラフを記録しておき、以降の実行では変更されたファイルに関係するテストだけを走らせ、残りはキャッシュされた結果を再生します。公式は、10分かかっていたLaravelのスイートが約4秒で再生されるようになった例を挙げています。
Tia Engineの依存グラフ記録とキャッシュ再生の実測
pcovを有効にした状態で--tia --freshを実行するとグラフが記録され、~/.pest/tia/配下にプロジェクトごとのgraph.jsonが作られます。2回目以降は変更が無ければ全件が再生扱いになり、実行済みのテストは0件になります。
$ ./vendor/bin/pest --tia
─ Experimental TIA mode enabled.
Tests: 11 passed (17 assertions, 11 replayed)
次にsrc/Cart.phpを1行書き換えてから同じコマンドを打つと、影響するテストファイルが特定され、そこだけが実行されます。11件のうち9件が実行、2件が再生という内訳まで表示されます。
$ ./vendor/bin/pest --tia
─ Experimental TIA mode enabled / 2 affected test files (from 1 changed file).
Tests: 11 passed (17 assertions, 9 affected, 2 replayed)
カバレッジドライバ不在時の通知と全件実行
注意点として、pcovもXdebugも有効でない環境では依存グラフを記録できません。この場合Pestはエラーで止まらず、次のメッセージを1行出したうえで通常どおり全件を実行します。ローカルで--tiaを付けても短縮されないときは、まずカバレッジドライバが有効かを確認してください。
─ Running in TIA mode, however TIA is skipped as it needs ext-pcov or Xdebug.
実装上も、依存グラフの収集はPHPUnitのコードカバレッジが有効なときだけ動く作りになっています。使いどころにも注意が要ります。公式ドキュメントは、Tia Engineはローカル開発のためのもので、CIでテストスイートを走らせるコマンドに--tiaを加えるべきではないと明記しています。CIは常に全件を実行し、--tiaを使うのはベースラインを記録する専用ジョブだけ、という切り分けです。ローカルでのみ自動的に有効化したい場合は--locallyが用意されています。なお出力に「Experimental」と付くとおり、v5.1.4時点では実験的な位置づけです。
並列実行・カバレッジ・CIの実務設定
並列実行は--parallelを付けるだけで有効になります。Pest 2以降は本体に含まれており、v5.1.4の依存を確認するとbrianium/paratestが直接の依存に入っているため、別途プラグインを入れる必要はありません。Pest 1系の記事にある並列プラグインの導入手順は、現在は不要です。
$ ./vendor/bin/pest --parallel
Tests: 11 passed (17 assertions)
Duration: 1.39s
Parallel: 16 processes
逐次実行と16プロセス並列実行の比較
前掲の測定環境で、同じ11件のスイートを逐次と並列で1回ずつ実行して比べた結果が次です。1件あたりが1ミリ秒未満の軽いテストばかりのため、プロセスの起動コストが検証時間を上回り、この構成では並列のほうが3倍近く遅くなりました。効き方は件数だけでなく1件あたりの実行時間とプロセス数にも左右されるため、逐次実行と実際に比べてから採用を決めてください。プロセス数は--parallel --processes=4のように指定できます。
| 実行方法 | テスト件数 | 所要時間 |
|---|---|---|
| 逐次実行 | 11 | 0.49秒 |
| 並列実行(16プロセス) | 11 | 1.39秒 |
どのテストが遅いかを調べるには--profileを使うと上位10件が所要時間付きで出ます。失敗だけを見たい場合は--compactで出力を圧縮できます。
CIでの実行と静的解析の併用
カバレッジは--coverageで標準出力に出し、--coverage --min=90で下限を割ったときにCIを失敗させられます。ドライバが無い環境では「No code coverage driver is available.」というエラーになるため、実行環境にpcovかXdebugを入れておく必要があります。GitHub Actionsでの組み方はGitHub Actionsでビルド・自動テストを設定する方法で扱っています。
テストで拾えるのは実行時の振る舞いだけなので、型の不整合や到達不能コードは静的解析と併せて潰すのが定石です。Pest 5にはpestphp/pest-plugin-phpstanが用意されており、it()やexpect()といったPestの関数APIをPHPStanへ認識させられます。解析ツールの選定はPsalmとPHPStanの違いを比較した記事を参照してください。
なお、Pest本体をインストールしただけで入るプラグインはpest-plugin-arch、pest-plugin-mutate、pest-plugin-profanityの3つです。ブラウザテスト、PHPStan連携、Rector連携、型カバレッジの各プラグインはPest本体の開発用依存に入っているだけで、利用側には配布されません。エージェント連携も含め、必要なものはcomposer requireで個別に追加します。--helpには--aiが並びますが、エージェントプラグイン未導入のまま実行すると「Unknown option」で弾かれます。
Laravelでの利用とブラウザテストの守備範囲
Laravel 13の公式ドキュメントは、新規アプリケーションでPestとPHPUnitの双方をそのまま使えると記載しています。Laravel向けのヘルパはpestphp/pest-plugin-laravelが提供し、これを入れるとartisanコマンドの検証やget()によるHTTPテストがPestの記法で書けるようになります。スケルトンがtestsディレクトリとTests\の登録を最初から持つため、前述した--initの2つのつまずきはLaravelでは起こりません。
ブラウザを起動して画面を操作する範囲は本記事の対象外です。Pest 4で追加されたブラウザテスト、スモークテスト、ビジュアルリグレッションテストはPest v4の新機能を解説した記事で扱っています。同じE2Eでも、Laravel公式が長く提供してきたDuskを使う構成についてはLaravelのテストをDuskで自動化する手順にまとめました。単体テストと機能テストはPest本体、画面の操作を伴う検証はブラウザ系の記事、と読み分けてください。
よくある質問
PestPHPとPHPUnitはどちらを選ぶべきですか?
新規プロジェクトでPHP 8.4以上を使えて、チームがPestの記法を受け入れられるならPestを推奨します。PHPUnitの実行基盤をそのまま使うため失うものが少なく、記述量が減るぶんテストを書く心理的な負担が下がります。逆にPHPの版を上げられない場合や、PHPUnitの属性に依存した独自ツールを組んでいる場合はPHPUnitのままにしてください。
既存のPHPUnitのテストはPestへ移行できますか?
できます。前述のpestphp/pest-plugin-driftでまとめて変換するほか、変換せずに共存させることもできます。同じスイート内にPHPUnitのテストクラスとPestのテストファイルが混在していても、Pestは両方を実行します。
Laravelでpestphp/pest-plugin-laravelは必要ですか?
Pest本体だけでもテストは動きますが、Laravel固有のヘルパを使うには必要です。2026年9月時点の最新はv5.0.1です。Laravel Installerでテストフレームワークを選ぶ流れで導入した場合は既に入っているため、composer showで確認してから追加してください。
beforeEachで用意した値がテストから見えないのはなぜですか?
ローカル変数へ代入していないか、フックの対象範囲がテストと一致しているかを順に確認してください。値は$this->プロパティ名へ代入する必要があります。次に多いのが前述のbeforeAll()との取り違えで、こちらは$thisを使えません。
Pest 5へ上げるときに確認することは何ですか?
PHPが8.4以上であること、そして利用中のプラグインがv5系に対応していることの2点です。composer.jsonの制約を^4.0から^5.0へ変えるだけで済むと公式は案内していますが、Pest本体はPHPUnit ^13.3.2へ依存が変わるため、PHPUnitの属性を直接使っているテストがあれば挙動を確認してください。Tia Engineを使う予定なら、あわせてpcovかXdebugを実行環境へ入れておきます。