PHPUnitでDDD実装パターンをテストしてみた

# はじめに

CakePHP4でDDDを意識して作成したAPIについて、テストコードを作成しました
こちらの記事の続きになります

<https://qiita.com/rowpure/items/f2aab86414ea7dac9c97#>

「ドメイン駆動設計 サンプルコード&FAQ | 松岡幸一郎」の本を大変参考にさせていただきました

<https://booth.pm/ja/items/3363104>

また、この記事で紹介するソースコードは、 GitHub にコミットしてあります

<https://github.com/q23isline/reinventing_the_wheel>

# バージョン

- PHP
    - 8.0.10
- CakePHP
    - 4.3.0
- PHPUnit
    - 9.5.10

# テストコード

代表して、「ユーザーを登録する」ユースケースの処理の流れのテストコードを記載します

## プレゼンテーション層

```php:tests/TestCase/Controller/Api/V1/UsersControllerTest.php
declare(strict_types=1);

namespace App\Test\TestCase\Controller\Api\V1;

// （略）useの設定

class UsersControllerTest extends TestCase
{
    use IntegrationTestTrait;

    public function setUp(): void
    {
        parent::setUp();

        // CSRF コンポーネントのトークンミスマッチによるテスト失敗を防ぐ
        $this->enableCsrfToken();

        // 管理者でログイン
        $this->session([
            'Auth' => [
                'User' => [
                    'id' => 'dbdc8d4c-cf7d-4833-b755-4b69e97561f3',
                    'role' => 'admin',
                ],
            ],
        ]);

        // JSON形式でアクセス
        $this->configRequest(['headers' => ['Accept' => 'application/json']]);
    }

    public function test_ユーザー追加で追加したユーザーをレスポンスすること(): void
    {
        // Arrange
        $requestData = [
            'loginId' => 'test1018',
            'password' => 'password',
            'roleName' => 'viewer',
            'firstName' => '斉藤',
            'lastName' => '太郎',
            'firstNameKana' => 'サイトウ',
            'lastNameKana' => 'タロウ',
            'mailAddress' => 'saito6@example.com',
            'sex' => '1',
            'birthDay' => '1990-01-01',
            'cellPhoneNumber' => '09011111116',
        ];

        $id = '00676011-5447-4eb1-bde1-001880663af3';
        $mockUserAddUseCase = $this->createMock(UserAddUseCase::class);
        $mockUserAddUseCase->expects($this->once())
            ->method('handle')
            ->will($this->returnValue(new UserId($id)));

        $this->overridePrivatePropertyWithMock('userAddUseCase', $mockUserAddUseCase);

        $expected = ['userId' => $id];
        $expected = json_encode($expected, JSON_PRETTY_PRINT);

        // Act
        $this->post('/api/v1/users', $requestData);

        // Assert
        $this->assertResponseCode(200);
        $this->assertEquals($expected, (string)$this->_response->getBody());
    }

    public function test_ユーザー追加でバリデーションエラーをレスポンスすること(): void
    {
        // Arrange
        $requestData = [];
        $expected = [
            'error' => [
                'message' => 'Bad Request',
                'errors' => [
                    [
                        'field' => 'loginId',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'password',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'roleName',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'firstName',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'lastName',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'firstNameKana',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'lastNameKana',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'mailAddress',
                        'reason' => '必須項目が不足しています。',
                    ],
                    [
                        'field' => 'sex',
                        'reason' => '必須項目が不足しています。',
                    ],
                ],
            ],
        ];

        $expected = json_encode($expected, JSON_PRETTY_PRINT);

        // Act
        $this->post('/api/v1/users', $requestData);

        // Assert
        $this->assertResponseCode(400);
        $this->assertEquals($expected, (string)$this->_response->getBody());
    }

    private function overridePrivatePropertyWithMock(string $propertyName, MockObject $mockObject): void
    {
        EventManager::instance()->on(
            'Controller.initialize',
            function (EventInterface $event) use ($propertyName, $mockObject) {
                $controller = $event->getSubject();
                $property = (new ReflectionClass($controller))->getProperty($propertyName);
                $property->setAccessible(true);
                $property->setValue($controller, $mockObject);
            }
        );
    }
}
```

- Controllerのプロパティにモックを持たせる方法は下の記事にあります

<https://qiita.com/rowpure/items/3973b45a1a1c1654794c>

## ユースケース層

```php:tests/TestCase/UseCase/Users/UserAddUseCaseTest.php
declare(strict_types=1);

namespace App\Test\TestCase\UseCase\Users;

// （略）useの設定

class UserAddUseCaseTest extends TestCase
{
    public function test_ユーザーが登録されること(): void
    {
        // Arrange
        $userRepository = new InMemoryUserRepository();
        $userService = new UserService($userRepository);
        $userAddUseCase = new UserAddUseCase($userRepository, $userService);

        $loginId = 'test';
        $inputData = new UserAddCommand(
            loginId: $loginId,
            password: 'p@ssw0rd',
            roleName: 'viewer',
            firstName: 'test1',
            lastName: 'test2',
            firstNameKana: 'テストイチ',
            lastNameKana: 'テストニ',
            mailAddress: 'test@example.com',
            sex: '1',
            birthDay: '1980-01-01',
            cellPhoneNumber: '09012345678',
        );

        // Act
        $userAddUseCase->handle($inputData);

        // Assert
        $createdLoginId = new LoginId($loginId);
        $createdUser = $userRepository->findByLoginId($createdLoginId);
        $this->assertNotNull($createdUser);
    }

    public function test_ユーザーが登録できないこと：登録済ログインID(): void
    {
        // Arrange
        $userRepository = new InMemoryUserRepository();
        $userService = new UserService($userRepository);
        $userAddUseCase = new UserAddUseCase($userRepository, $userService);

        $loginId = 'test';
        $user = (new TestUserFactory())->create(loginId: $loginId);
        $userRepository->save($user);
        $inputData = new UserAddCommand(
            loginId: $loginId,
            password: 'p@ssw0rd',
            roleName: 'viewer',
            firstName: 'test1',
            lastName: 'test2',
            firstNameKana: 'テストイチ',
            lastNameKana: 'テストニ',
            mailAddress: 'test@example.com',
            sex: '1',
            birthDay: '1980-01-01',
            cellPhoneNumber: '09012345678',
        );

        // Assert
        $this->expectException(ValidateException::class);

        // Act
        $userAddUseCase->handle($inputData);
    }
}
```

### テスト時に利用するインメモリリポジトリ

データベースへアクセスせず高速にテスト実行させることができます

```php:src/Infrastructure/InMemory/Users/InMemoryUserRepository.php
declare(strict_types=1);

namespace App\Infrastructure\InMemory\Users;

// （略）useの設定

class InMemoryUserRepository implements IUserRepository
{
    public $store = [];

    public function assignId(): UserId
    {
        $uuid = (string)mt_rand(0, 99999999) . '-3882-42dd-9ab2-485e8e579a8e';

        return new UserId($uuid);
    }

    public function findByLoginId(LoginId $loginId): ?User
    {
        foreach ($this->store as $elem) {
            if ($elem->getLoginId()->getValue() === $loginId->getValue()) {
                return $this->clone($elem);
            }
        }

        return null;
    }

    public function save(User $user): UserId
    {
        $this->store[$user->getId()->getValue()] = $this->clone($user);

        return $user->getId();
    }

    private function clone(User $user): User
    {
        $birthDay = null;
        if (!empty($user->getBirthDay())) {
            $birthDay = $user->getBirthDay();
        }

        $cellPhoneNumber = null;
        if (!empty($user->getCellPhoneNumber())) {
            $cellPhoneNumber = $user->getCellPhoneNumber();
        }

        return User::create(
            $user->getId(),
            $user->getLoginId(),
            $user->getPassword(),
            $user->getRoleName(),
            $user->getFirstName(),
            $user->getLastName(),
            $user->getFirstNameKana(),
            $user->getLastNameKana(),
            $user->getMailAddress(),
            $user->getSex(),
            $birthDay,
            $cellPhoneNumber,
        );
    }
}
```

### テスト用インスタンス生成オブジェクト

デフォルト値を設定することで、テスト時に、より強調したいパラメータを表すことができます

```php:tests/TestCase/UseCase/Users/TestUserFactory.php
declare(strict_types=1);

namespace App\Test\TestCase\UseCase\Users;

// （略）useの設定

class TestUserFactory
{
    public function create(
        string $userId = '01509588-3882-42dd-9ab2-485e8e579a8e',
        string $loginId = 'test',
        string $password = 'p@ssw0rd',
        string $roleName = 'viewer',
        string $firstName = 'test1',
        string $lastName = 'test2',
        string $firstNameKana = 'テストイチ',
        string $lastNameKana = 'テストニ',
        string $mailAddress = 'test@example.com',
        string $sex = '1',
        ?string $birthDay = null,
        ?string $cellPhoneNumber = null,
    ): User {
        if (!is_null($birthDay)) {
            $birthDay = new BirthDay($birthDay);
        }

        if (!is_null($cellPhoneNumber)) {
            $cellPhoneNumber = new CellPhoneNumber($cellPhoneNumber);
        }

        return User::create(
            new UserId($userId),
            new LoginId($loginId),
            new Password($password),
            new RoleName($roleName),
            new FirstName($firstName),
            new LastName($lastName),
            new FirstNameKana($firstNameKana),
            new LastNameKana($lastNameKana),
            new MailAddress($mailAddress),
            new Sex($sex),
            $birthDay,
            $cellPhoneNumber,
        );
    }
}
```

## ドメインサービス層

```php:tests/TestCase/Domain/Services/UserServiceTest.php
declare(strict_types=1);

namespace App\Test\TestCase\Domain\Services;

// （略）useの設定

class UserServiceTest extends TestCase
{
    public function test_ユーザーインスタンスが自分自身であること(): void
    {
        // Arrange
        $user = User::create(
            new UserId('01509588-3882-42dd-9ab2-485e8e579a8e'),
            new LoginId('test'),
            new Password('p@ssw0rd'),
            new RoleName('viewer'),
            new FirstName('test1'),
            new LastName('test2'),
            new FirstNameKana('テストイチ'),
            new LastNameKana('テストニ'),
            new MailAddress('test@example.com'),
            new Sex('1'),
            new BirthDay('1980-01-01'),
            new CellPhoneNumber('09012345678'),
        );
        $userOther = $user;

        // Act
        $isMyself = $user->isMyself($userOther);

        // Assert
        $this->assertTrue($isMyself);
    }

    public function test_ユーザーインスタンスが自分自身でないこと(): void
    {
        // Arrange
        $user = User::create(
            new UserId('01509588-3882-42dd-9ab2-485e8e579a8e'),
            new LoginId('test'),
            new Password('p@ssw0rd'),
            new RoleName('viewer'),
            new FirstName('test1'),
            new LastName('test2'),
            new FirstNameKana('テストイチ'),
            new LastNameKana('テストニ'),
            new MailAddress('test@example.com'),
            new Sex('1'),
            new BirthDay('1980-01-01'),
            new CellPhoneNumber('09012345678'),
        );
        $userOther = User::create(
            new UserId('99999999-3882-42dd-9ab2-485e8e579a8e'),
            new LoginId('test1'),
            new Password('p@ssw0rd'),
            new RoleName('viewer'),
            new FirstName('test1'),
            new LastName('test2'),
            new FirstNameKana('テストイチ'),
            new LastNameKana('テストニ'),
            new MailAddress('test1@example.com'),
            new Sex('1'),
            new BirthDay('1980-01-01'),
            new CellPhoneNumber('09012345679'),
        );

        // Act
        $isMyself = $user->isMyself($userOther);

        // Assert
        $this->assertFalse($isMyself);
    }
}
```

## ドメイン層

```php:tests/TestCase/Domain/Models/User/UserTest.php
declare(strict_types=1);

namespace App\Test\TestCase\Domain\Models\User;

// （略）useの設定

class UserTest extends TestCase
{
    public function test_ユーザーインスタンスが作成されること(): void
    {
        // Arrange
        $id = new UserId('01509588-3882-42dd-9ab2-485e8e579a8e');
        $loginId = new LoginId('test');
        $password = new Password('p@ssw0rd');
        $roleName = new RoleName('viewer');
        $firstName = new FirstName('test1');
        $lastName = new LastName('test2');
        $firstNameKana = new FirstNameKana('テストイチ');
        $lastNameKana = new LastNameKana('テストニ');
        $mailAddress = new MailAddress('test@example.com');
        $sex = new Sex('1');
        $birthDay = new BirthDay('1980-01-01');
        $cellPhoneNumber = new CellPhoneNumber('09012345678');

        // Act
        $user = User::create(
            $id,
            $loginId,
            $password,
            $roleName,
            $firstName,
            $lastName,
            $firstNameKana,
            $lastNameKana,
            $mailAddress,
            $sex,
            $birthDay,
            $cellPhoneNumber,
        );

        // Assert
        $this->assertEquals($id, $user->getId());
        $this->assertEquals($loginId, $user->getLoginId());
        $this->assertEquals($password, $user->getPassword());
        $this->assertEquals($roleName, $user->getRoleName());
        $this->assertEquals($firstName, $user->getFirstName());
        $this->assertEquals($lastName, $user->getLastName());
        $this->assertEquals($firstNameKana, $user->getFirstNameKana());
        $this->assertEquals($lastNameKana, $user->getLastNameKana());
        $this->assertEquals($mailAddress, $user->getMailAddress());
        $this->assertEquals($sex, $user->getSex());
        $this->assertEquals($birthDay, $user->getBirthDay());
        $this->assertEquals($cellPhoneNumber, $user->getCellPhoneNumber());
    }
}
```

## インフラ層

```php:tests/TestCase/Infrastructure/CakePHP/Users/CakePHPUserRepositoryTest.php
declare(strict_types=1);

namespace App\Test\TestCase\Infrastructure\CakePHP\Users;

// （略）useの設定

class CakePHPUserRepositoryTest extends TestCase
{
    use IntegrationTestTrait;

    protected $fixtures = ['app.Users'];

    public function test_UUIDを取得すること(): void
    {
        // UUID の正規表現パターン
        $expectUserIdPattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/';

        // Act
        $userId = (new CakePHPUserRepository())->assignId();

        // Assert
        $this->assertMatchesRegularExpression($expectUserIdPattern, $userId->getValue());
    }

    public function test_ログインIDによってユーザー情報を取得すること(): void
    {
        // Arrange
        $loginId = 'admin';

        $expect = User::create(
            new UserId('41559b8b-e831-4972-8afa-21ee8b952d85'),
            new LoginId($loginId),
            new Password('password'),
            new RoleName('admin'),
            new FirstName('admin'),
            new LastName('管理者'),
            new FirstNameKana('アドミン'),
            new LastNameKana('カンリシャ'),
            new MailAddress('admin@example.com'),
            new Sex('1'),
            new BirthDay('2021-10-14'),
            new CellPhoneNumber('09012345678'),
        );

        // Act
        $actual = (new CakePHPUserRepository())->findByLoginId(new LoginId($loginId));

        // Assert
        $this->assertEquals($expect, $actual);
    }

    public function test_ログインIDに一致するユーザー情報が存在しない場合空を返すこと(): void
    {
        // Arrange
        $loginId = 'sample';

        // Act
        $actual = (new CakePHPUserRepository())->findByLoginId(new LoginId($loginId));

        // Assert
        $this->assertNull($actual);
    }

    public function test_ユーザー情報を保存すること(): void
    {
        // Arrange
        $userId = '01509588-3882-42dd-9ab2-485e8e579a8e';
        $user = User::reconstruct(
            new UserId($userId),
            new LoginId('test'),
            new Password('p@ssw0rd1'),
            new RoleName('editor'),
            new FirstName('test2'),
            new LastName('test3'),
            new FirstNameKana('テストニ'),
            new LastNameKana('テストサン'),
            new MailAddress('test1@example.com'),
            new Sex('2'),
            new BirthDay('1980-01-02'),
            new CellPhoneNumber('09012345679'),
        );

        // Act
        $actual = (new CakePHPUserRepository())->save($user);

        // Assert
        $this->assertEquals(new UserId($userId), $actual);
    }
}
```

# おわりに

どこまでの粒度でテストを実施するべきか、悩みます
メンテナンスがとてもしんどそうで。。。
この記事が他のエンジニアの助けになれば幸いです