💨

PHP の Trait の使いどころと、テストパターン

2024/09/17に公開

TL;DR

Trait の使いどころは以下の 4 つのパターン。

  1. インターフェースのデフォルト実装
  2. クラスの分割実装
  3. 基底インターフェースの実装を共通化
  4. 偶然同じ機能を持つクラスの実装の共通化

Trait のテストの書き方は以下の 4 つのパターン。

  1. Trait を use したクラスをそのままテストする
  2. Trait を use したクラスを Trait 単位でテストする
  3. テスト専用のクラスを作成してテストする
  4. 無名クラスを作成してテストする

それぞれのパターンの対応表は以下のようになります。

Trait パターン対応表 Trait を use したクラスをそのままテストする Trait を use したクラスを Trait 単位でテストする テスト専用のクラスを作成してテストする 無名クラスを作成してテストする
インターフェースのデフォルト実装
クラスの分割実装
基底インターフェースの実装を共通化
偶然同じ機能を持つクラスの実装の共通化

はじめに

PHP ではコードを再利用するために Trait という仕組みがあります。

https://www.php.net/manual/ja/language.oop5.traits.php

自分はこの Trait の使いどころや、テストの書き方についてあまり理解していなかったので、この記事では OSS のコードを参考にしてパターンとして整理してみました。

また、 Trait 自体については以下の資料や RFC が参考になるので、合わせてご覧ください。

https://speakerdeck.com/sji/dao-kara-10-nian-php-no-trait-hamie-birubekinanoka-sonoshi-qie-nashi-idokorotoruo-dian-jiang-lai-nituite

https://wiki.php.net/rfc/constants_in_traits

Trait の使いどころ

上の資料や、 OSS のコードを参考にして、この記事では 4 つのユースケースについて紹介します。

※前提として、 Trait を使う場合の使い方について書いています。そもそも Trait を使うべきかは注意深く検討する必要があります。 DI で済むならそっちのほうがいいです。

1. インターフェースのデフォルト実装

use-case-1

インターフェースを複数のクラスで実装するときに、共通化できる実装を Trait で実装するパターンです。
Trait の優先順位は、現在のクラスでの実装よりも低いので、デフォルト実装として利用することが出来ます。

例としては、 aws/aws-sdk-phpAws\S3\S3ClientTrait があります。これは Aws\S3\S3ClientInterface を実装したクラスである Aws\S3\S3ClientAws\S3\S3MultiRegionClient で使用されています。

https://github.com/aws/aws-sdk-php/blob/master/src/S3/S3ClientTrait.php

2. クラスの分割実装

use-case-2

一つのクラスを実装するときに、そのコードを複数の Trait に分割して実装するパターンです。
一つのインターフェースを複数の Trait で実装することが特徴的で、技術的な関心でコードを分割します。

例としては briannesbitt/Carbon があります。ここには Carbon\Traits\ComparisonCarbon\Traits\Rounding などの技術的な関心によって分割された Trait があり、それらをクラスから use して Carbon\CarbonInterface を実装する Carbon\Carbon を作成しています。

https://github.com/briannesbitt/Carbon/blob/master/src/Carbon/Traits/Date.php

3. 基底インターフェースの実装を共通化

use-case-3

これは、パターン 1 とパターン 2 の組み合わせたようなパターンです。コードを分割する対象が技術的な関心ではなく、基底インターフェースの実装の共通化であることが特徴的です。

例としては、 guzzle/psr7 があります。まず PSR-7 では MessageInterface を基底インターフェースとして RequestInterfaceResponseInterface のインターフェースが継承しています。
GuzzleHttp\Psr7 ではこれらのインターフェースを実装した GuzzleHttp\Psr7\RequestGuzzleHttp\Psr7\Response を作成しています。これらは MessageInterface の実装を共通化することができるので、 GuzzleHttp\Psr7\MessageTrait を作成しています。

https://github.com/guzzle/psr7/blob/2.7/src/MessageTrait.php

4. 偶然同じ機能を持つクラスの実装の共通化

use-case-4

これは、オブジェクトやインターフェースに依存しない機能や振る舞いの実装を共通化するパターンです。
パターン 2 と似て技術的な関心でコードを分割しますが、こちらは複数のクラスで利用されることが特徴的です。

例としては、 laravel/frameworkIlluminate\Support\Traits\Macroable があります。これは「動的にメソッドを追加できる」という振る舞い(マクロ)を共通化しており、これを use したクラスはそれぞれがマクロを定義することができます。

https://github.com/laravel/framework/blob/11.x/src/Illuminate/Macroable/Traits/Macroable.php

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 のカバレッジが測定されることがポイントです。

参考実装

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());
    }
}

参考実装

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;
}

テスト専用クラスは、別ファイルに分けたり、同じファイルで書いたりします。基本的にはこのファイルでしか使わないので、同じファイルに書くほうが良いかもしれません。

参考実装

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());
    }
}

参考実装

テスト 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 で削除予定です。

https://speakerdeck.com/cocoeyes02/introduction-of-phpunit-11?slide=21

https://github.com/sebastianbergmann/phpunit/issues/5243

https://github.com/sebastianbergmann/phpunit/issues/5244

Trait の private / protected メソッドをテストする

https://shoulditestprivatemethods.com/

がすべて。

ただし、もし、どうしても書かなくてはいけない場合は 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 という単位でテストコードも分割することが望ましいだろうという考えです。

その他は基本的にどれても良いと思います。

GitHubで編集を提案

Discussion