0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

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

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

<?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

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);
    }
}
$ 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 以降では失敗します。

$ 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

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

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

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

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

<?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 という別のモックを使うというテクニックもあります。

$ composer require --dev mockery/mockery
<?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);
    }
}
> 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 の方が分かりやすくはありますね。

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?