PHP の Trait の使いどころと、テストパターン
TL;DR
Trait の使いどころは以下の 4 つのパターン。
- インターフェースのデフォルト実装
- クラスの分割実装
- 基底インターフェースの実装を共通化
- 偶然同じ機能を持つクラスの実装の共通化
Trait のテストの書き方は以下の 4 つのパターン。
- Trait を use したクラスをそのままテストする
- Trait を use したクラスを Trait 単位でテストする
- テスト専用のクラスを作成してテストする
- 無名クラスを作成してテストする
それぞれのパターンの対応表は以下のようになります。
Trait パターン対応表 | Trait を use したクラスをそのままテストする | Trait を use したクラスを Trait 単位でテストする | テスト専用のクラスを作成してテストする | 無名クラスを作成してテストする |
---|---|---|---|---|
インターフェースのデフォルト実装 | ○ | ○ | ○ | ○ |
クラスの分割実装 | △ | ○ | ○ | ○ |
基底インターフェースの実装を共通化 | △ | ○ | ○ | ○ |
偶然同じ機能を持つクラスの実装の共通化 | ✗ | ○ | ○ | ○ |
はじめに
PHP ではコードを再利用するために Trait という仕組みがあります。
自分はこの Trait の使いどころや、テストの書き方についてあまり理解していなかったので、この記事では OSS のコードを参考にしてパターンとして整理してみました。
また、 Trait 自体については以下の資料や RFC が参考になるので、合わせてご覧ください。
Trait の使いどころ
上の資料や、 OSS のコードを参考にして、この記事では 4 つのユースケースについて紹介します。
※前提として、 Trait を使う場合の使い方について書いています。そもそも Trait を使うべきかは注意深く検討する必要があります。 DI で済むならそっちのほうがいいです。
1. インターフェースのデフォルト実装
インターフェースを複数のクラスで実装するときに、共通化できる実装を Trait で実装するパターンです。
Trait の優先順位は、現在のクラスでの実装よりも低いので、デフォルト実装として利用することが出来ます。
例としては、 aws/aws-sdk-php の Aws\S3\S3ClientTrait
があります。これは Aws\S3\S3ClientInterface
を実装したクラスである Aws\S3\S3Client
と Aws\S3\S3MultiRegionClient
で使用されています。
2. クラスの分割実装
一つのクラスを実装するときに、そのコードを複数の Trait に分割して実装するパターンです。
一つのインターフェースを複数の Trait で実装することが特徴的で、技術的な関心でコードを分割します。
例としては briannesbitt/Carbon があります。ここには Carbon\Traits\Comparison
や Carbon\Traits\Rounding
などの技術的な関心によって分割された Trait があり、それらをクラスから use して Carbon\CarbonInterface
を実装する Carbon\Carbon
を作成しています。
3. 基底インターフェースの実装を共通化
これは、パターン 1 とパターン 2 の組み合わせたようなパターンです。コードを分割する対象が技術的な関心ではなく、基底インターフェースの実装の共通化であることが特徴的です。
例としては、 guzzle/psr7 があります。まず PSR-7 では MessageInterface
を基底インターフェースとして RequestInterface
や ResponseInterface
のインターフェースが継承しています。
GuzzleHttp\Psr7
ではこれらのインターフェースを実装した GuzzleHttp\Psr7\Request
や GuzzleHttp\Psr7\Response
を作成しています。これらは MessageInterface
の実装を共通化することができるので、 GuzzleHttp\Psr7\MessageTrait
を作成しています。
4. 偶然同じ機能を持つクラスの実装の共通化
これは、オブジェクトやインターフェースに依存しない機能や振る舞いの実装を共通化するパターンです。
パターン 2 と似て技術的な関心でコードを分割しますが、こちらは複数のクラスで利用されることが特徴的です。
例としては、 laravel/framework の Illuminate\Support\Traits\Macroable
があります。これは「動的にメソッドを追加できる」という振る舞い(マクロ)を共通化しており、これを use したクラスはそれぞれがマクロを定義することができます。
Trait をテストするパターン
次に Trait をテストするパターンを紹介します。
サンプルコードから use される Trait は、以下のものを使用します。
<?php
trait HogeTrait
{
public function hoge(): string
{
return 'hoge';
}
}
1. Trait を use したクラスをそのままテストする
このパターンでは、テストの対象をクラスやインターフェース単位で見ていて、テストからは Trait の存在を知ることはありません。実装の詳細に依存しないテストが書けるので、テストの保守性が高まるメリットがあります。
確認項目 | 内容 |
---|---|
テストを実行する対象 | Trait を use したクラス |
テストコードのファイル単位 | Trait を use したクラス単位 |
参考実装 | guzzle/psr7, aws/aws-sdk-php |
サンプルコード
<?php
class HogeFuga
{
use HogeTrait;
public function fuga(): string
{
return 'fuga';
}
}
<?php
/**
* @covers HogeFuga
* @covers HogeTrait
*/
class HogeFugaTest extends TestCase
{
public function testHoge(): void
{
$hogeFuga = new HogeFuga();
$this->assertSame('hoge', $hogeFuga->hoge());
}
public function testFuga(): void
{
$hogeFuga = new HogeFuga();
$this->assertSame('fuga', $hogeFuga->fuga());
}
}
@covers
アノテーションをつけることで、カバレッジを測定するときに Trait のカバレッジが測定されることがポイントです。
参考実装
- guzzle/psr7
- aws/aws-sdk-php
2. Trait を use したクラスを Trait 単位でテストする
このパターンでは、テストの対象をクラスやインターフェース単位で見ていますが、テストは Trait 単位でコードを分けて書いています。技術的な関心でクラスの分割実装している場合、そのクラスは巨大なことが多いので、テストも Trait 単位で分けると 1 ファイルの行数が減ってコードが読みやすくなります。
確認項目 | 内容 |
---|---|
テストを実行する対象 | Trait を use したクラス |
テストコードのファイル単位 | Trait 単位 |
参考実装 | briannesbitt/Carbon |
サンプルコード
<?php
class HogeFuga
{
use HogeTrait;
use FugaTrait;
}
<?php
/**
* @covers HogeTrait
*/
class HogeTraitTest extends TestCase
{
public function testHoge()
{
$hoge = new Hoge();
$this->assertSame('hoge', $hoge->hoge());
}
}
参考実装
- briannesbitt/Carbon
3. テスト専用のクラスを作成してテストする
テストの対象を Trait に絞ったパターンです。テストディレクトリに Trait のテスト専用となるクラスを作成して、それをテストコードから呼び出してテストします。
定義したテスト専用クラスを何回か呼び出す場合や、名前をつけたい場合に有効です。
確認項目 | 内容 |
---|---|
テストを実行する対象 | Trait を use したテスト専用のクラス |
テストコードのファイル単位 | Trait 単位 |
参考実装 | laravel/framework |
サンプルコード
<?php
class HogeTraitTest extends TestCase
{
public function testHoge(): void
{
$hoge = new EmptyTestHogeTrait();
$this->assertSame('hoge', $hoge->hoge());
}
public function testOverrideHoge(): void
{
$hoge = new OverrideTestHogeTrait();
$this->assertSame('hoge', $hoge->hoge());
}
}
class EmptyTestHogeTrait
{
use HogeTrait;
}
class OverrideTestFuga
{
public function hoge(): string
{
return 'fuga';
}
}
class OverrideTestHogeTrait extends OverrideTestFuga
{
use HogeTrait;
}
テスト専用クラスは、別ファイルに分けたり、同じファイルで書いたりします。基本的にはこのファイルでしか使わないので、同じファイルに書くほうが良いかもしれません。
参考実装
- laravel/framework
4. 無名クラスを作成してテストする
このパターンもパターン 3 と同様にテストの対象を Trait に絞ったパターンです。違いは Trait を use するクラスに名前をつけるかどうかだけです。
確認項目 | 内容 |
---|---|
テストを実行する対象 | Trait を use した無名クラス |
テストコードのファイル単位 | Trait 単位 |
参考実装 | symfony/symfony, googleapis/google-auth-library-php |
サンプルコード
<?php
/**
* @covers HogeTrait
*/
class HogeTraitTest extends TestCase
{
public function testHoge(): void
{
$hoge = new class {
use HogeTrait;
};
$this->assertSame('hoge', $hoge->hoge());
}
}
参考実装
- symfony/symfony
- https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/Cache/Traits/RedisTrait.php
- https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/Cache/Tests/Traits/RedisTraitTest.php
- https://github.com/symfony/symfony/blob/7.2/src/Symfony/Component/HttpClient/Tests/Exception/HttpExceptionTraitTest.php
- googleapis/google-auth-library-php
テスト NG パターン
Trait をテストコードから use する
<?php
class HogeTraitTest extends TestCase
{
use HogeTrait;
public function testHoge()
{
// Act
$result = $this->hoge(); // HogeTrait のメソッド
// Assert
$this->assertSame('hoge', $result);
}
}
このように Trait をテストコードから use することで、Trait の振る舞いをテストコードが持つことになり、テストクラスの責任が不明確になってしまいます。
ただし、テストコードからの Trait の使用を禁止するわけではなく、テスト専用 Utils のような使い方をしたい場合は問題ないです。
<?php
class HogeTest extends TestCase
{
use TestUtilsTrait;
public function testHoge()
{
// Arrange
$this->resetData(); // TestUtilsTrait のメソッド
// Act
$hoge = new Hoge();
$result = $hoge->hoge();
// Assert
$this->assertSame('hoge', $result);
}
}
TestCase::getMockForTrait, TestCase::getObjectForTrait を使う
PHPUnit 11 から TestCase::getMockForTrait
, TestCase::getObjectForTrait
は Deprecated になり、 PHPUnit 12 で削除予定です。
Trait の private / protected メソッドをテストする
がすべて。
ただし、もし、どうしても書かなくてはいけない場合は trait のアクセス修飾子を public に変更してテストすることができます。
<?php
class HogeTraitTest extends TestCase
{
public function testHoge(): void
{
$hoge = new class {
use HogeTrait {
hoge as public;
}
};
$this->assertSame('hoge', $hoge->hoge());
}
}
まとめ
改めて、 Trait の使いどころとテストのパターンの対応表を出してみます。
Trait パターン対応表 | Trait を use したクラスをそのままテストする | Trait を use したクラスを Trait 単位でテストする | テスト専用のクラスを作成してテストする | 無名クラスを作成してテストする |
---|---|---|---|---|
インターフェースのデフォルト実装 | ○ | ○ | ○ | ○ |
クラスの分割実装 | △ | ○ | ○ | ○ |
基底インターフェースの実装を共通化 | △ | ○ | ○ | ○ |
偶然同じ機能を持つクラスの実装の共通化 | ✗ | ○ | ○ | ○ |
「偶然同じ機能を持つクラスの実装の共通化」だけは、 Trait 単位でテストをすることが望ましいので「Trait を use したクラスをそのままテストする」が ✗ になっています。
「クラスの分割実装」と「基底インターフェースの実装を共通化」は、「Trait を use したクラスをそのままテストする」が △ になっています。これは実装を分割するほどコードが巨大になっている状態が予想できるので、テストコードも巨大になっているだろうし、 Trait という単位でテストコードも分割することが望ましいだろうという考えです。
その他は基本的にどれても良いと思います。
Discussion