PHPUnit 10 以降で withConsecutive を使いたい場合の 2 つの方法

こんにちは。やまゆです。

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

PHPUnit 9 でモック用の `withConsecutive` メソッドが deprecated になり、 10 で削除されました。

このメソッドは、同じメソッドを連続して呼ぶ場合のモック定義として有効でした。

例えば、このような実装コードがあるとします。

```php
<?php

declare(strict_types=1);

/**
 * @license Apache-2.0
 */

namespace App;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use RuntimeException;

final readonly class ValidateJsonHeaderMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // Accept は "application/json" か "application/json charset=utf-8"
        if ($request->hasHeader('Accept')) {
            $acceptHeader = $request->getHeaderLine('Accept');

            if (!\str_starts_with($acceptHeader, 'application/json')) {
                throw new RuntimeException('Accept header must be application/json');
            }
        }

        // Content-Type は "application/json" か "application/json; charset=utf-8"
        if ($request->hasHeader('Content-Type')) {
            $contentTypeHeader = $request->getHeaderLine('Content-Type');

            if (!\str_starts_with($contentTypeHeader, 'application/json')) {
                throw new RuntimeException('Content-Type header must be application/json');
            }
        }
        return $handler->handle($request);
    }
}
```

これに対して、 PHPUnit 9 では下記のテストコードが有効でした。

```php
<?php

declare(strict_types=1);

/**
 * @license Apache-2.0
 */

use App\ValidateJsonHeaderMiddleware;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * @covers \App\ValidateJsonHeaderMiddleware
 */
final class ValidateJsonHeaderMiddlewareTest extends TestCase
{
    public function testInvalidAccept(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::once())
            ->method('hasHeader')
            ->with('Accept')
            ->willReturn(true);
        $request->expects(self::once())
            ->method('getHeaderLine')
            ->with('Accept')
            ->willReturn('text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Accept header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testInvalidContentType(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::exactly(2))
            ->method('hasHeader')
            ->withConsecutive(['Accept'], ['Content-Type'])
            ->willReturnOnConsecutiveCalls(true, true);

        $request->expects(self::exactly(2))
            ->method('getHeaderLine')
            ->withConsecutive(['Accept'], ['Content-Type'])
            ->willReturnOnConsecutiveCalls('application/json', 'text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Content-Type header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testValid(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::exactly(2))
            ->method('hasHeader')
            ->withConsecutive(['Accept'], ['Content-Type'])
            ->willReturnOnConsecutiveCalls(true, true);

        $request->expects(self::exactly(2))
            ->method('getHeaderLine')
            ->withConsecutive(['Accept'], ['Content-Type'])
            ->willReturnOnConsecutiveCalls('application/json', 'application/json');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::once())
            ->method('handle')
            ->with($request)
            ->willReturn($expected = $this->createMock(ResponseInterface::class));

        $middleware = new ValidateJsonHeaderMiddleware();
        $actual = $middleware->process($request, $handler);

        self::assertSame($expected, $actual);
    }
}
```

```sh
$ vendor/bin/phpunit test
PHPUnit 9.6.20 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.11
Configuration: /path/to/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.011, Memory: 6.00 MB

OK (3 tests, 11 assertions)
```

これは、 PHPUnit 10 以降では失敗します。

```sh
$ vendor/bin/phpunit test
PHPUnit 10.5.33 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.11
Configuration: /path/to/phpunit.xml

EE.                                                                 3 / 3 (100%)

Time: 00:00.013, Memory: 8.00 MB

There were 2 errors:

1) ValidateJsonHeaderMiddlewareTest::testInvalidContentType
Error: Call to undefined method PHPUnit\Framework\MockObject\Builder\InvocationMocker::withConsecutive()

/path/to/test/ValidateJsonHeaderMiddlewareTest.php:47

2) ValidateJsonHeaderMiddlewareTest::testValid
Error: Call to undefined method PHPUnit\Framework\MockObject\Builder\InvocationMocker::withConsecutive()

/path/to/test/ValidateJsonHeaderMiddlewareTest.php:70

ERRORS!
Tests: 3, Assertions: 4, Errors: 2, PHPUnit Deprecations: 1.
Script phpunit handling the test event returned with error code 2
```

https://stackoverflow.com/questions/75389000/replace-phpunit-method-withconsecutive-abandoned-in-phpunit-10

この StackOverflow などが詳しいですが、公式で代替するメソッドは用意されていないため、何かしらの手法を使ってワークアラウンドする必要があります。

## 代替案1: CakePHP によって用意された代替メソッドを利用する

https://book.cakephp.org/5/ja/appendices/phpunit10.html

CakePHP 5 から PHPUnit 10 を使うようになったため、この代替メソッドが用意されています。

https://github.com/cakephp/cakephp/blob/5.x/src/TestSuite/PHPUnitConsecutiveTrait.php

この trait を use することで、 `static withConsecutive` メソッドが生えるので、以下のように使用できます。

```php
<?php

declare(strict_types=1);

/**
 * @license Apache-2.0
 */

use App\ValidateJsonHeaderMiddleware;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

include 'PHPUnitConsecutiveTrait.php';

/**
 * @covers \App\ValidateJsonHeaderMiddleware
 */
final class ValidateJsonHeaderMiddlewareTest extends TestCase
{
    use \Cake\TestSuite\PHPUnitConsecutiveTrait;

    public function testInvalidAccept(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::once())
            ->method('hasHeader')
            ->with('Accept')
            ->willReturn(true);
        $request->expects(self::once())
            ->method('getHeaderLine')
            ->with('Accept')
            ->willReturn('text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Accept header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testInvalidContentType(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::exactly(2))
            ->method('hasHeader')
            //->withConsecutive(['Accept'], ['Content-Type'])
            ->with(...self::withConsecutive(['Accept'], ['Content-Type']))
            ->willReturnOnConsecutiveCalls(true, true);

        $request->expects(self::exactly(2))
            ->method('getHeaderLine')
            //->withConsecutive(['Accept'], ['Content-Type'])
            ->with(...self::withConsecutive(['Accept'], ['Content-Type']))
            ->willReturnOnConsecutiveCalls('application/json', 'text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Content-Type header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testValid(): void
    {
        $request = $this->createMock(ServerRequestInterface::class);
        $request->expects(self::exactly(2))
            ->method('hasHeader')
            //->withConsecutive(['Accept'], ['Content-Type'])
            ->with(...self::withConsecutive(['Accept'], ['Content-Type']))
            ->willReturnOnConsecutiveCalls(true, true);

        $request->expects(self::exactly(2))
            ->method('getHeaderLine')
            //->withConsecutive(['Accept'], ['Content-Type'])
            ->with(...self::withConsecutive(['Accept'], ['Content-Type']))
            ->willReturnOnConsecutiveCalls('application/json', 'application/json');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::once())
            ->method('handle')
            ->with($request)
            ->willReturn($expected = $this->createMock(ResponseInterface::class));

        $middleware = new ValidateJsonHeaderMiddleware();
        $actual = $middleware->process($request, $handler);

        self::assertSame($expected, $actual);
    }
}
```

## 代替案2: Mockery を使用する

PHPUnit の MockObject ではなく、 Mockery という別のモックを使うというテクニックもあります。

```sh
$ composer require --dev mockery/mockery
```

```php
<?php

declare(strict_types=1);

/**
 * @license Apache-2.0
 */

use App\ValidateJsonHeaderMiddleware;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

#[CoversClass(ValidateJsonHeaderMiddleware::class)]
final class ValidateJsonHeaderMiddlewareTest extends TestCase
{
    use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;

    public function testInvalidAccept(): void
    {
        $request = \Mockery::mock(ServerRequestInterface::class);
        $request->shouldReceive('hasHeader')
            ->once()
            ->with('Accept')
            ->andReturn(true);
        $request->shouldReceive('getHeaderLine')
            ->once()
            ->with('Accept')
            ->andReturn('text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Accept header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testInvalidContentType(): void
    {
        $request = \Mockery::mock(ServerRequestInterface::class);
        $request->shouldReceive('hasHeader')
            ->once()
            ->with('Accept')
            ->andReturn(true);
        $request->shouldReceive('getHeaderLine')
            ->once()
            ->with('Accept')
            ->andReturn('application/json');
        $request->shouldReceive('hasHeader')
            ->once()
            ->with('Content-Type')
            ->andReturn(true);
        $request->shouldReceive('getHeaderLine')
            ->once()
            ->with('Content-Type')
            ->andReturn('text/html');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::never())
            ->method('handle');

        $middleware = new ValidateJsonHeaderMiddleware();
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('Content-Type header must be application/json');
        $middleware->process($request, $handler);
    }

    public function testValid(): void
    {
        $request = \Mockery::mock(ServerRequestInterface::class);
        $request->shouldReceive('hasHeader')
            ->once()
            ->with('Accept')
            ->andReturn(true);
        $request->shouldReceive('getHeaderLine')
            ->once()
            ->with('Accept')
            ->andReturn('application/json');
        $request->shouldReceive('hasHeader')
            ->once()
            ->with('Content-Type')
            ->andReturn(true);
        $request->shouldReceive('getHeaderLine')
            ->once()
            ->with('Content-Type')
            ->andReturn('application/json');

        $handler = $this->createMock(RequestHandlerInterface::class);
        $handler->expects(self::once())
            ->method('handle')
            ->with($request)
            ->willReturn($expected = $this->createMock(ResponseInterface::class));

        $middleware = new ValidateJsonHeaderMiddleware();
        $actual = $middleware->process($request, $handler);

        self::assertSame($expected, $actual);
    }
}
```

```sh
> phpunit
PHPUnit 11.3.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.11
Configuration: /path/to/phpunit.xml

...                                                                 3 / 3 (100%)

Time: 00:00.024, Memory: 10.00 MB

OK (3 tests, 18 assertions)
```

※ついでに Annotation から Attribute に変えています

Mockery の方が分かりやすくはありますね。