CakePHP3.7未満のEmailをSMTPなしにユニットテストする方法

本稿ではCakePHP3のEmailをSMTPサーバなしにPHPUnitで単体テストする方法を紹介する。

## 最新のCakeなら`Cake\TestSuite\EmailTrait`を使うといい

[CakePHPの公式ドキュメント](https://book.cakephp.org/3.0/ja/core-libraries/email.html#email-testing)によれば、メールのテストは`Cake\TestSuite\EmailTrait`を使えば良いらしい。しかし、これはCakePHP3.7以降の話だ。3.7は2018/12/09にリリースされたばかりなので、手元の環境ではこれが使えない。

## CakePHP3.7未満の対処法

代わりに使えるのはCakePHP3.3.3で追加された`Cake\TestSuite\EmailAssertTrait`なわけだが、これは`Email`のモックを生成するものなので、`Email`を`new`しているコードのテストはできない。

ではテストできないのかというとそうではなく、`EmailTransport`をSMTPからテスト用のものに差し替えれば、`new Email`しているコードもテストすることができる。参照: [Cakephp3のTestでメールを送りたくない - Qiita](https://qiita.com/geerpm/items/ffd1342f7a21571c37a2)

### `CakeMailerTestCase`を作る

トランスポートの差し替えは、基本的に`setUp`で

```php
Email::dropTransport('default');
Email::setConfigTransport('default', $テスト用のTransport);
```

し、`tearDown`でもとのトランスポートに戻してあげればいいわけだ。

だが、テストクラスごとに毎回`setUp`と`tearDown`を書くのは面倒なので、次の`CakeMailerTestCase`のようなメールテスト機能を持ったテストクラスを作っておくと便利だ:

```php:CakeMailerTestCase.php
<?php

declare(strict_types=1);

namespace App\Test;

use Cake\Mailer\AbstractTransport;
use Cake\Mailer\Email;
use Cake\TestSuite\EmailAssertTrait;
use Cake\TestSuite\TestCase;

abstract class CakeMailerTestCase extends TestCase
{
    use EmailAssertTrait;

    /**
     * @var array
     */
    private $originalEmailTransport;

    public function setUp(): void
    {
        parent::setUp();
        $this->originalEmailTransport = Email::getConfigTransport('default');
        Email::dropTransport('default');
        Email::setConfigTransport('default', $this->getTestTransport());
    }

    public function tearDown(): void
    {
        parent::tearDown();
        Email::dropTransport('default');
        Email::setConfigTransport('default', $this->originalEmailTransport);
    }

    private function getTestTransport(): AbstractTransport
    {
        return new class($this->getSentEmailSetter()) extends AbstractTransport {
            /**
             * @var callable
             */
            private $setSentEmail;

            public function __construct(callable $setSentEmail)
            {
                parent::__construct();
                $this->setSentEmail = $setSentEmail;
            }

            public function send(Email $email): void
            {
                ($this->setSentEmail)($email);
            }
        };
    }

    private function getSentEmailSetter(): callable
    {
        return function (Email $email): void {
            $this->_email = $email;
        };
    }
}
```

`CakeMailerTestCase`を使ったテストコードは次のようになる:

```php
<?php

declare(strict_types=1);

namespace App\Test;

use Cake\Mailer\Email;

final class CakePhpEmailSenderTest extends CakeMailerTestCase
{
    public function test_send_email(): void
    {
        $email = new Email();
        $email->setFrom(['alice@example.com' => 'Alice Brown'])
            ->setTo('bob@example.com')
            ->setSubject('Hi')
            ->send('Hi there.');
        $this->assertEmailFrom('alice@example.com', 'Alice Brown');
        $this->assertEmailTo('bob@example.com');
        $this->assertEmailSubject('Hi');
        $this->assertEmailTextMessageContains('Hi there.');
    }
}
```

`CakeMailerTestCase`は`Cake\TestSuite\EmailAssertTrait`を使っているので、`assertEmailFrom`などのメール系アサーションも使えるようになっている。