PHPUnit mock-объекты

При тестировании объекта необходимо изолировать его, отключить от внешней системы (См. Модульное тестирование). Для этого нужно подменить (дублировать) реальные объекты, с которыми взаимодействует тестируемый объект, на их тестовые дубликаты (Test Doubles). Эти дубликаты не должны вести себя также как реальные объекты, но они должны иметь такой же API.

Дубликат может быть просто заглушкой (stub), чтобы заглушить какую-то внешнюю связь. Но может быть и объектом-пародией (mock-object), который также подменяет связь как заглушка, но ещё и имитирует поведение объекта, к тому же mock-объект ещё проверяет как с ним взаимодействует тестируемый объект, то есть mock-объект также проверяет косвенные выходы.

В PHPUnit для этого есть метод createMock(). Этот метод создаёт дубликат объекта, в котором по умолчанию все методы заменяются фиктивными, возвращающими NULL. При этому оригинальные методы дублируемого объекта не вызываются. createMock() не может дублировать final, private, static методы и сохраняют своё поведение.

Приведём пример.
Код тестируемого класса:

<?php
// src/ExampleProject/SomeController.php
namespace ExampleProject;

class SomeController
{
    /**
     * @var StorageInterface
     */
    protected $storage;

    /**
     * @param StorageInterface $storage
     */
    public function __construct(StorageInterface $storage)
    {
        $this->storage = $storage;
    }

    /**
     * @param array $data
     * @return array
     * @throws \Exception
     */
    public function saveAction(array $data): array
    {
        if (!$this->storage->save($data)) {
            throw new \Exception();
        }

        return [];
    }
}
            

 

Интерфейс хранилища (storage):

<?php
// src/ExampleProject/StorageInterface.php
namespace ExampleProject;

interface StorageInterface
{
    /**
     * @param array $data
     * @return bool
     */
    public function save(array $data): bool;
}
            

 

Контроллер имеет конструктор и один публичный метод, который сохраняет полученные данные в хранилище. Хранилище, передаваемое в конструктор контроллера, является сторонним объектом. Взаимодействие контроллера с объектом хранилища - это внешняя связь для объекта контроллера. При модульном тестировании контроллера нам не нужно тестировать объект хранилища, поэтому на связь с ним надо поставить заглушку (stub), а лучше сделать из него mock-объект и проверить правильно ли контроллер взаимодействует с объектом хранилища.

Код теста:

<?php
// tests/ExampleProject/SomeControllerTest.php
namespace Tests\ExampleProject;

use PHPUnit\Framework\TestCase;
use ExampleProject\SomeController;
use ExampleProject\StorageInterface;

class SomeControllerTest extends TestCase
{
    /**
     * @throws \Exception
     */
    public function testSaveAction()
    {
        $data = ['a', 'b'];

        /** @var StorageInterface | TestCase $storageMock */
        $storageMock = $this->createMock(StorageInterface::class);
        $storageMock->expects($this->once())
            ->method('save')
            ->with($data)
            ->willReturn(true);

        $someController = new SomeController($storageMock);
        $this->assertInternalType('array', $someController->saveAction($data));
    }
}
            

 

В тесте мы создаём mock-объект хранилища. Далее настраиваем поведение и проверки для mock-объекта.
Методы настройки:

  • expects() задаёт сколько раз должен быть вызван настраиваемый метод.
  • once() возвращает, что метод должен быть вызван ровно один раз. Также есть методы:
    • any() - любое количество раз
    • never() - ни разу
    • exactly($count) - задаёт точное количество вызовов
    • at($index) - номер вызова
  • method() задаёт название настраиваемого метода.
  • with() задаёт аргументы, с которыми должен быть вызван настраиваемый метод. Также есть:
    • withConsecutive(...$arguments) - задаёт наборы аргументов для последовательности вызовов (пример ниже)
  • willReturn() задаёт значение, которое вернёт настраиваемый метод при данном вызове. Также есть:
    • will($stub) - общий метод, обычно вызывается подобным образом: will($this->returnValue('foo'))
      • returnValue($value) - вернёт заданное значение
      • returnValueMap(array $valueMap) - настраиваемый метод будет возвращать разные значение в соответсвии с передаваемыми аргументами (пример ниже)
      • returnArgument($argumentIndex) - вернёт аргумент, передаваемый в настраиваемый метод
      • returnCallback($callback) - возвращает то, что вернёт $callback
      • returnSelf() - вернут сам mock-объект
      • throwException(Throwable $exception) - при вызове настраиваемого метода выбросит исключение $exception
      • onConsecutiveCalls() - возвращает значения на последовательность вызовов (пример ниже)

Помимо описанных методов настройки mock-объекта, есть также и другие, с которыми вы можете ознакомиться в официальной документации.
Далее передаём настроенный mock-объект хранилища в конструктор контроллера.
Вызываем метод saveAction() с тестовыми данными и проверяем, что этот метод вернёт массив.

 

Надо, конечно, ещё написать отдельный тест, который будет проверять, что если хранилище не сохранит данные (метод save() вернёт false), то метод saveAction() бросит исключение \Exception.

Допишим класс теста:

<?php
// tests/ExampleProject/SomeControllerTest.php
namespace Tests\ExampleProject;

use PHPUnit\Framework\TestCase;
use ExampleProject\SomeController;
use ExampleProject\StorageInterface;

class SomeControllerTest extends TestCase
{
    /**
     * @throws \Exception
     */
    public function testSaveAction()
    {
        $data = ['a', 'b'];

        /** @var StorageInterface | TestCase $storageMock */
        $storageMock = $this->createMock(StorageInterface::class);
        $storageMock->expects($this->once())
            ->method('save')
            ->with($data)
            ->willReturn(true);

        $someController = new SomeController($storageMock);
        $this->assertInternalType('array', $someController->saveAction($data));
    }

    /**
     * @expectedException \Exception
     */
    public function testSaveActionFailSaving()
    {
        /** @var StorageInterface | TestCase $storageMock */
        $storageMock = $this->createMock(StorageInterface::class);
        $storageMock->method('save')
            ->willReturn(false);

        $someController = new SomeController($storageMock);
        $someController->saveAction(['any data']);
    }
}
            

 

В примере специально убраны проверки, что метод save() вызывается ровно 1 раз, проверка передаваемых тестовых данных, проверка возвращаемого результата. Это сделано для того, чтобы акцентировать внимание на том, что проверяется в тесте testSaveActionFailSaving, остальное проверяется в тесте testSaveAction.

Пример возврата разных значений в зависимости от передаваемых аргументов в настраиваемый метод mock-объекта:

<?php

namespace Tests\ExampleProject;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function testSomething()
    {
        $testMap = [
            ['a', 'b', 'x'],
            ['c', 'd', 'y'],
        ];

        $mockObject = $this->createMock(DummyObject::class);
        $mockObject->expects($this->any())
            ->method('someMethod')
            ->will($this->returnValueMap($testMap));

        $this->assertEquals('x', $mockObject->someMethod('a', 'b'));
        $this->assertEquals('y', $mockObject->someMethod('c', 'd'));
    }
}

class DummyObject
{
    public function someMethod() {}
}
            

 

Пример последовательности вызовов:

<?php

namespace Tests\ExampleProject;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
    public function testSomething()
    {
        $mockObject = $this->createMock(DummyObject::class);
        $mockObject->expects($this->any())
            ->method('someMethod')
            ->withConsecutive(
                ['f', 'g'],
                ['h', 'i']
            )
            ->will($this->onConsecutiveCalls('a', 'b'));

        $this->assertEquals('a', $mockObject->someMethod('f', 'g'));
        $this->assertEquals('b', $mockObject->someMethod('h', 'i'));
    }
}

class DummyObject
{
    public function someMethod() {}
}
            

 

Здесь проверяется, что если метод someMethod() вызывается с набором аргументов ['f', 'g'] или ['h', 'i'], то вернуть на эти вызовы соответственно ‘a’ или ‘b’.