Spec-Zone.ru › PHPUnit

Тестовые дублёры

Жерар Мезарош вводит понятие тестовых дублёров в своей книге «xUnit Test Patterns» следующим образом:

Иногда тестировать систему под тестированием (SUT) бывает очень сложно, поскольку она зависит от других компонентов, которые нельзя использовать в тестовой среде. Причиной может быть их недоступность, невозможность получить от них результаты, необходимые для теста, или нежелательные побочные эффекты при их выполнении. В других случаях наша стратегия тестирования требует большего контроля над внутренним поведением SUT или большей его наглядности.

Если мы пишем тест, в котором не можем (или не хотим) использовать реальный зависимый компонент (DOC), мы можем заменить его тестовым дублёром. Тестовый дублёр не обязан вести себя в точности как настоящий DOC; ему достаточно предоставлять тот же API, чтобы SUT считала его настоящим!

PHPUnit предоставляет мощный и гибкий API для создания и настройки тестовых заглушек и объектов-моков. Эти тестовые дублёры необходимы для модульного тестирования: они позволяют изолировать проверяемый код от его зависимостей и проверять его поведение, не выполняя код реальных взаимодействующих объектов.

Тестовая заглушка заменяет реальную зависимость и может быть настроена на возврат заранее определённых значений или выбрасывание исключений. Тестовые заглушки позволяют контролировать косвенные входные данные системы под тестированием, направляя её по определённым путям выполнения и проверяя различные сценарии без зависимости от внешних систем или служб.

Объект-мок — это разновидность тестовой заглушки, которую можно настроить с ожиданиями относительно того, как её будут вызывать. Объекты-моки служат точками наблюдения, позволяя проверять косвенные выходные данные и взаимодействие между системой под тестированием и её участниками. Ожидая вызова определённых методов с ожидаемыми аргументами, мы можем убедиться, что код правильно взаимодействует со своими зависимостями.

В этой главе рассматриваются исключительно тестовые заглушки и объекты-моки. Другие типы тестовых дублёров, такие как пустышки, подделки и шпионы, выходят за рамки этой документации.

Используйте тестовую заглушку, когда:

  • Вам нужно контролировать, что возвращает зависимость

  • Вы тестируете логику SUT

  • Взаимодействие с зависимостью не имеет значения

  • Вам нужно изолировать SUT от медленных или недоступных зависимостей

Используйте объект-мок, когда:

  • Вам нужно проверить, что методы вызываются

  • Вы тестируете взаимодействие между объектами

  • Количество вызовов метода имеет значение

  • Имеют значение аргументы, передаваемые методам

Подробное обсуждение концептуальных различий между тестовыми заглушками и объектами-моками, а также того, когда следует использовать каждый из них, см. в статье «Тестирование с зависимостями и без них».

Пример кода

В некоторых примерах этой главы используются интерфейс с именем Database и класс с именем Service. Они представлены здесь, чтобы мы были с ними знакомы, когда они появятся в примерах.

Пример 6.1 Интерфейс, определяющий контракт для операций с базой данных
<?php declare(strict_types=1);
interface Database
{
    /**
     * @throws DatabaseException
     */
    public function execute(string $sql, float|int|string ...$parameters): true;

    /**
     * @throws DatabaseException
     */
    public function query(string $sql, float|int|string ...$parameters): array;
}

Объяснение:

  • Этот интерфейс определяет контракт для операций с базой данных

  • execute() используется для операций записи (INSERT, UPDATE, DELETE), при успешном выполнении возвращает true, а при ошибке выбрасывает исключение

  • query() используется для операций чтения (SELECT), при успешном выполнении возвращает массив результатов, а при ошибке выбрасывает исключение

  • Оба метода принимают SQL-строки и переменное число аргументов для подготовленных выражений

Пример 6.2 Класс, зависящий от Database
<?php declare(strict_types=1);
final readonly class Service
{
    private Database $database;

    public function __construct(Database $database)
    {
        $this->database = $database;
    }

    /**
     * @throws ServiceException
     */
    public function doSomething(): bool
    {
        try {
            $rows = $this->database->query(
                'SELECT foo FROM bar WHERE baz = ?;',
                'value',
            );
        } catch (DatabaseException) {
            throw new ServiceException;
        }

        if ($rows !== []) {
            // ...

            return true;
        }

        return false;
    }

    /**
     * @throws ServiceException
     */
    public function doSomethingElse(): void
    {
        // ...

        $this->database->execute(
            'INSERT INTO bar (foo, baz) VALUES (?, ?);',
            'value',
            'another value',
        );
    }
}

Объяснение:

  • Service — это класс final readonly, зависящий от Database

  • Зависимость Database передаётся через конструктор, благодаря чему класс можно тестировать изолированно как от зависимости, так и от сервера базы данных

  • doSomething() выполняет запрос к базе данных и возвращает true, если найдены строки, и false в противном случае

  • doSomethingElse() выполняет выражение INSERT для добавления данных в базу данных

Тестовые заглушки

Что такое тестовые заглушки?

Тестовая заглушка заменяет реальный взаимодействующий объект (зависимость), с которым взаимодействует ваш код. Тестовые заглушки позволяют тестировать код изолированно, не выполняя фактическую реализацию зависимости.

Тестовая заглушка — это замена реального компонента, от которого зависит система под тестированием (SUT). Благодаря этому тест получает точку контроля над косвенными входными данными SUT. Управляя этими косвенными входными данными, можно направить SUT по определённым путям выполнения, которые требуется проверить в тесте.

Используйте тестовые заглушки, если хотите:

  • Отвязать код от медленных или недоступных зависимостей (баз данных, внешних API)

  • Задавать определённые возвращаемые значения для проверки разных путей выполнения кода

  • Проверять обработку ошибок, имитируя сбои

  • Сосредоточиться на проверке логики SUT, а не её зависимостей

Распространённые сценарии использования

Настройка возвращаемых значений

Если ваш код зависит от службы, которая возвращает данные, можно использовать тестовую заглушку, чтобы управлять результатами этой службы, не вызывая её на самом деле.

Пример 6.3 Используем тестовую заглушку, чтобы предоставить косвенные входные данные объекту, который хотим проверить
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class ServiceTest extends TestCase
{
    public function testDoSomethingReturnsTrueWhenQueryReturnsRows(): void
    {
        $database = $this->createStub(Database::class);

        $database
            ->method('query')
            ->willReturn([['foo' => 'bar']]);

        $service = new Service($database);

        $this->assertTrue($service->doSomething());
    }
}

1. Создание тестовой заглушки

$database = $this->createStub(Database::class);
  • Что: Создаёт тестовую заглушку, реализующую интерфейс Database

  • Как: createStub() создаёт тестовую заглушку, методы которой возвращают значения по умолчанию, если не заданы другие настройки

  • Зачем: Нам нужен объект, который «похож» на Database, чтобы тестировать Service изолированно от реального подключения к базе данных

Тестовые заглушки идеально подходят, когда нам нужно контролировать только то, что возвращает зависимость (косвенные входные данные).

2. Настройка тестовой заглушки

$database
    ->method('query')
    ->willReturn([['foo' => 'bar']]);
  • Что: Настраивает тестовую заглушку так, чтобы при вызове query() возвращалось определённое значение

  • Как:

    • method('query') указывает, какой метод нужно настроить

    • willReturn([['foo' => 'bar']]) задаёт возвращаемое значение — массив с одной строкой

  • Зачем: Это имитирует сценарий, в котором запрос к базе данных находит подходящие строки

3. Создание системы под тестированием

$service = new Service($database);
  • Что: Создаёт экземпляр класса Service, используя тестовую заглушку в качестве зависимости

  • Как: Тестовая заглушка передаётся конструктору, удовлетворяя требованию к типу Database

  • Зачем: Именно этот объект мы тестируем; передавая тестовую заглушку, мы управляем поведением базы данных

4. Проверка ожидаемого поведения

$this->assertTrue($service->doSomething());
  • Что: Проверяет, что doSomething() возвращает true

  • Как: assertTrue() завершает тест с ошибкой, если значение не в точности равно true

  • Зачем: Если запрос возвращает строки, doSomething() должен вернуть true

Ключевое понятие: косвенные входные данные

Косвенные входные данные возникают, когда система под тестированием получает данные от зависимости, а не непосредственно из аргументов, переданных проверяемому методу или функции. В этом тесте:

  1. Нельзя напрямую передать данные в doSomething(), поскольку он не принимает параметров

  2. Вместо этого doSomething() получает данные, вызывая $this->database->query()

  3. Тестовая заглушка предоставляет эти косвенные входные данные, возвращая [['foo' => 'bar']]

Настройка исключений

Тестовые заглушки можно настроить на выбрасывание исключений, чтобы проверить, как ваш код обрабатывает ошибки.

Пример 6.4 Используем тестовую заглушку, выбрасывающую исключение, чтобы проверить путь обработки ошибки
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class ServiceTest extends TestCase
{
    public function testExceptionIsThrownWhenSomethingGoesWrong(): void
    {
        $database = $this->createStub(Database::class);

        $database
            ->method('query')
            ->willThrowException(new DatabaseException);

        $service = new Service($database);

        $this->expectException(ServiceException::class);

        $service->doSomething();
    }
}

1. Создание тестовой заглушки

$database = $this->createStub(Database::class);
  • Что: Создаёт тестовую заглушку, реализующую интерфейс Database

  • Как: createStub() создаёт тестовую заглушку, методы которой возвращают значения по умолчанию, если не заданы другие настройки

  • Зачем: Нам нужен объект, который «похож» на Database, чтобы тестировать Service изолированно от реального подключения к базе данных

Тестовые заглушки идеально подходят, когда нам нужно контролировать только то, что возвращает зависимость (косвенные входные данные).

2. Настройка тестовой заглушки

$database
    ->method('query')
    ->willThrowException(new DatabaseException);
  • Что: Настраивает тестовую заглушку на выбрасывание определённого исключения при вызове query()

  • Как:

    • method('query') указывает, какой метод нужно настроить

    • willThrowException(new DatabaseException) задаёт исключение, которое будет выброшено

  • Зачем: Это имитирует ситуацию, когда при запросе к базе данных возникает ошибка

3. Настройка ожидания

$this->expectException(ServiceException::class);
  • Что: Настраивает тест так, чтобы он завершился успешно, только если будет выброшено исключение ServiceException

  • Как: Мы передаём expectException() имя исключения, которое ожидаем получить

  • Зачем: Мы хотим проверить, что исключение DatabaseException, выброшенное реализацией Database, приводит к выбрасыванию ServiceException

4. Создание системы под тестированием

$service = new Service($database);
  • Что: Создаёт экземпляр класса Service, используя тестовую заглушку в качестве зависимости

  • Как: Тестовая заглушка передаётся конструктору, удовлетворяя требованию к типу Database

  • Зачем: Именно этот объект мы тестируем; передавая тестовую заглушку, мы управляем поведением базы данных

4. Вызов проверяемого кода

$service->doSomething();

Справочник

Создание тестовых заглушек

createStub()

Создаёт тестовую заглушку для указанного интерфейса (или расширяемого класса).

$stub = $this->createStub(InterfaceName::class);

Все методы исходного типа заменяются реализацией, возвращающей автоматически сгенерированное значение, которое соответствует объявленному типу возвращаемого значения метода; исходный метод при этом не вызывается. Такие методы называются «дублированными методами» или «методами-заглушками».

Дублированные методы можно настроить с помощью описанных ниже методов.

Ограничение: финальные классы

Обратите внимание, что классы final нельзя дублировать.

Ограничение: финальные, закрытые и статические методы

Обратите внимание, что методы final, private и static нельзя дублировать. Функциональность тестовых дублёров PHPUnit игнорирует их, и они сохраняют исходное поведение, за исключением методов static, которые заменяются методом, выбрасывающим исключение.

Ограничение: перечисления

Перечисления (enum) являются классами final и поэтому не могут быть дублированы.

createStubForIntersectionOfInterfaces()

Создаёт тестовую заглушку для пересечения интерфейсов.

$stub = $this->createStubForIntersectionOfInterfaces(
    [InterfaceA::class, InterfaceB::class]
);

Это полезно, когда нужно заменить объект, реализующий несколько интерфейсов.

createConfiguredStub()

Создаёт тестовую заглушку с уже настроенными методами, возвращающими определённые значения.

$stub = $this->createConfiguredStub(
    InterfaceName::class,
    [
        'methodOne' => 'return value one',
        'methodTwo' => 'return value two',
    ]
);

// $stub->methodOne() will return "return value one"
// $stub->methodTwo() will return "return value two"

Это вспомогательный метод для простых случаев.

getStubBuilder()

Метод getStubBuilder() предоставляет гибкий API для создания тестовых заглушек. Его следует использовать только в особых случаях, которые не поддерживаются более простыми методами createStub() или createStubForIntersectionOfInterfaces().

Используйте getStubBuilder() только при необходимости расширенной настройки, например:

  • Указать собственное имя класса тестовой заглушки

  • Включить исходный конструктор с пользовательскими аргументами

  • Создать частичные тестовые заглушки (дублировать только определённые методы)

  • Управлять поведением клонирования

  • Отключить автоматическую генерацию возвращаемых значений

Метод getStubBuilder(string $type) возвращает объект, который можно использовать для настройки, а затем создания тестовой заглушки для указанного интерфейса (или расширяемого класса).

У объекта, возвращаемого методом getStubBuilder(), есть, помимо прочих, метод с именем getStub(). Он создаёт и возвращает настроенную тестовую заглушку. Этот метод должен вызываться последним в цепочке вызовов методов гибкого API.

Для настройки создания тестовой заглушки можно использовать следующие методы объекта, возвращаемого методом getStubBuilder():

setStubClassName(string $name)

Задаёт пользовательское имя для сгенерированного класса тестовой заглушки.

Примечание

Указанное имя класса не должно быть уже занято.

onlyMethods(array $methods)

Указывает, какие методы следует дублировать (заменить заглушками). Методы, не включённые в этот список, сохранят исходную реализацию, в результате чего будет создан частичный дублёр.

Примечание

Все указанные методы должны существовать в классе.

setConstructorArgs(array $arguments)

Задаёт аргументы, передаваемые конструктору при использовании enableOriginalConstructor() (см. ниже).

disableOriginalConstructor()

Отключает вызов исходного конструктора. Это полезно, когда нужно создать частичный дублёр, а конструктор имеет побочные эффекты или требует зависимостей, которые вы не хотите предоставлять.

Примечание

createStub(), createConfiguredStub(), createMock() и createConfiguredMock() создают тестовые дублёры без вызова исходного конструктора, если используются для создания тестового дублёра расширяемого класса.

enableOriginalConstructor()

Включает вызов исходного конструктора. Используйте этот метод вместе с setConstructorArgs() (см. выше), чтобы передать обязательные аргументы.

Примечание

Это поведение используется по умолчанию. Метод enableOriginalConstructor() существует лишь на случай, если вы хотите явно указать в коде теста, что полагаетесь на это поведение.

disableOriginalClone()

Отключает вызов исходного метода __clone() при клонировании тестовой заглушки.

Примечание

Исходный метод __clone() не вызывается для тестовых дублёров расширяемых классов, созданных с помощью createStub(), createConfiguredStub(), createMock() и createConfiguredMock().

enableOriginalClone()

Включает вызов исходного метода __clone() при клонировании тестовой заглушки.

Примечание

Это поведение используется по умолчанию. Метод enableOriginalClone() существует лишь на случай, если вы хотите явно указать в коде теста, что полагаетесь на это поведение.

enableAutoReturnValueGeneration()

Включает автоматическую генерацию возвращаемых значений для дублированных методов, для которых не настроены явные возвращаемые значения.

Примечание

Это поведение используется по умолчанию. Метод enableAutoReturnValueGeneration() существует лишь на случай, если вы хотите явно указать в коде теста, что полагаетесь на это поведение.

disableAutoReturnValueGeneration()

Отключает автоматическую генерацию возвращаемых значений. Если она отключена, методы-заглушки без явной настройки возвращают null или выбрасывают исключение — в зависимости от объявленного типа возвращаемого значения.

Настройка возвращаемых значений

willReturn()

Настраивает метод на возврат определённого значения.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturn('result');

// $stub->doSomething() always returns "result"

Возвращаемое значение должно соответствовать объявленному типу возвращаемого значения метода.

С помощью willReturn() можно настроить метод так, чтобы при последовательных вызовах он возвращал разные значения:

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturn('first result', 'second result');

// $stub->doSomething() returns "first result" when it is invoked for the first time
// $stub->doSomething() returns "second result" when it is invoked for the second time
// $stub->doSomething() will trigger an error when it is invoked more than twice

Если метод настроен на возврат разных значений при последовательных вызовах, его можно вызвать только столько раз, сколько задано возвращаемых значений.

willReturnSelf()

Настраивает метод на возврат самого объекта тестовой заглушки.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnSelf();

Это полезно для тестирования цепочечных интерфейсов.

willReturnArgument()

Настраивает метод на возврат одного из своих аргументов.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnArgument(0);

// $stub->doSomething('some value') returns 'some value'

Индексация аргументов начинается с нуля.

willReturnMap()

Настраивает метод на возврат разных значений в зависимости от полученных аргументов.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnMap([
        ['foo', 'bar', 'baz'],
        ['one', 'two', 'three'],
    ]);

// $stub->doSomething('foo', 'bar') returns 'baz'
// $stub->doSomething('one', 'two') returns 'three'

Каждый вложенный массив содержит аргументы метода, за которыми следует возвращаемое значение.

Каждая позиция параметра в карте значений может содержать буквальное значение (сравниваемое с помощью ===) или ограничение PHPUnit:

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnMap([
        [$this->greaterThan(5), 'large'],
        [$this->lessThanOrEqual(5), 'small'],
    ]);

// $stub->doSomething(10) returns 'large'
// $stub->doSomething(3) returns 'small'

Если в карте значений не найдено совпадение, возвращается null.

willReturnStrictMap()

Работает так же, как willReturnMap(), но выбрасывает ExpectationFailedException, если вызову метода не соответствует ни одна запись в карте значений, вместо того чтобы без предупреждения возвращать null:

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnStrictMap([
        [1, 'one'],
        [2, 'two'],
    ]);

// $stub->doSomething(1) returns 'one'
// $stub->doSomething(99) throws ExpectationFailedException

Как и в willReturnMap(), для сопоставления параметров можно использовать объекты-ограничения.

willReturnCallback()

Настраивает метод на возврат результата функции обратного вызова.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturnCallback(
        static fn(string $input) => strtoupper($input)
    );

// $stub->doSomething('string') returns 'STRING'

Функция обратного вызова получает аргументы метода и может реализовывать сложную логику.

Свойства с перехватчиками чтения

В PHP 8.4 появилась языковая возможность свойств с перехватчиками чтения.

В приведённом ниже примере показан интерфейс, объявляющий свойство с перехватчиком чтения:

Пример 6.5 Интерфейс, объявляющий свойство с перехватчиком чтения
<?php declare(strict_types=1);
interface InterfaceWithGetHookedProperty
{
    public string $property { get; }
}

Поведение свойства с перехватчиком чтения property можно настроить следующим образом:

Пример 6.6 Тест с использованием тестовой заглушки интерфейса со свойством с перехватчиком чтения
<?php declare(strict_types=1);
use PHPUnit\Framework\MockObject\Runtime\PropertyHook;
use PHPUnit\Framework\TestCase;

final class ExampleTest extends TestCase
{
    public function testExample(): void
    {
        $stub = $this->createStub(
            InterfaceWithHookedProperty::class,
        );

        $stub
            ->method(PropertyHook::get('property'))
            ->willReturn('value');

        $this->assertSame('value', $stub->property);
    }
}

В приведённом выше примере PropertyHook::get('property') используется для указания, что мы хотим настроить поведение метода, вызываемого при чтении свойства с именем property.

Настройка исключений

willThrowException()

Настраивает метод на выбрасывание исключения вместо возврата значения.

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willThrowException(new Exception);

// $stub->doSomething() throws the configured exception

Генерация возвращаемых значений

Обратите внимание, что для методов без настроенного поведения автоматически и рекурсивно создаются возвращаемые значения-заглушки в соответствии с типом возвращаемого значения метода. Рассмотрим пример ниже:

Пример 6.7 Метод с объявлением типа возвращаемого значения
<?php declare(strict_types=1);
class C
{
    public function m(): D
    {
        // Do something.
    }
}

В приведённом выше примере метод C::m() имеет объявление типа возвращаемого значения, указывающее, что он возвращает объект типа D. Когда создаётся тестовый дублёр для C и для m() не настроено возвращаемое значение с помощью, например, willReturn(), PHPUnit автоматически создаст тестовый дублёр для D, который будет возвращаться при вызове m().

Аналогично, если бы для m() был объявлен скалярный тип возвращаемого значения, было бы сгенерировано такое возвращаемое значение, как 0 (для int), 0.0 (для float), "" (для string) и т. д.

Эту генерацию возвращаемых значений можно отключить с помощью атрибута #[DisableReturnValueGenerationForTestDoubles] в классе тестового случая.

Мок-объекты

Что такое мок-объекты?

Мок-объект — это тестовая заглушка, которую дополнительно можно настроить с помощью ожиданий относительно того, как её должны вызывать. Мок-объекты позволяют проверять взаимодействие между системой под тестом и её зависимостями.

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

Используйте мок-объекты, если хотите:

  • Проверить, что вызываются определённые методы

  • Проверить аргументы, передаваемые методам

  • Проверить количество вызовов методов

  • Проверить координацию и взаимодействие между объектами

Если вы создадите мок-объект, но не настроите для него никаких ожиданий, PHPUnit выдаст уведомление. Это означает, что следует либо добавить ожидания, либо вместо этого использовать createStub().

Чтобы узнать, почему различие между тестовыми заглушками и мок-объектами имеет принципиальное значение, прочитайте статьи «Тестирование с зависимостями и без них» и «Различие между заглушками и мок-объектами».

Типичные случаи использования

Использование мок-объекта для тестирования прямого вывода

Используйте мок-объект, чтобы проверить, выполняются ли действия, когда ваш код вызывает их в своих зависимостях.

Пример 6.8 Мы используем тестовую заглушку, чтобы предоставить косвенные входные данные объекту, который хотим протестировать
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class ServiceTest extends TestCase
{
    public function testDoSomethingElseExecutesInsertQuery(): void
    {
        $database = $this->createMock(Database::class);

        $database
            ->expects($this->once())
            ->method('execute')
            ->with(
                'INSERT INTO bar (foo, baz) VALUES (?, ?);',
                'value',
                'another value',
            );

        $service = new Service($database);

        $service->doSomethingElse();
    }
}

1. Создание мок-объекта

$database = $this->createMock(Database::class);
  • Что: Создаёт мок-объект, реализующий интерфейс Database

  • Как: createMock() создаёт мок-объект, который может проверять вызовы методов и их аргументы

  • Зачем: Нам нужно проверить, что Service правильно вызывает метод execute() базы данных

Мок-объекты идеально подходят, когда нужно проверить, как система под тестом взаимодействует со своими зависимостями (косвенный вывод).

2. Настройка ожиданий

$database
    ->expects($this->once())
    ->method('execute')
    ->with(
        'INSERT INTO bar (foo, baz) VALUES (?, ?);',
        'value',
        'another value',
    );
  • Что: Настраивает мок-объект так, чтобы он ожидал вызова определённого метода с определёнными аргументами

  • Как:

    • expects($this->once()) — метод должен быть вызван ровно один раз; тест завершится неудачей, если метод не будет вызван или будет вызван более одного раза

    • method('execute') — указывает, какой метод должен быть вызван

    • with(…) — указывает аргументы, которые должны быть переданы; тест завершится неудачей, если будут использованы другие аргументы

  • Зачем: Это основа тестирования с помощью мок-объектов. Мы проверяем, что doSomethingElse() передаёт правильный SQL-код и параметры объекту Database

3. Создание системы под тестом

$service = new Service($database);
  • Что: Создаёт экземпляр класса Service, используя мок-объект в качестве зависимости

  • Как: Мок-объект передаётся конструктору, удовлетворяя требованию типа Database

  • Зачем: Именно этот объект мы тестируем; внедрив мок-объект, мы можем проверить взаимодействие между Service и Database

4. Выполнение системы под тестом

$service->doSomethingElse();
  • Что: Вызывает метод, который мы хотим протестировать

После завершения тестового метода PHPUnit автоматически проверяет, выполнены ли все ожидания, заданные для мок-объекта.

Ключевое понятие: косвенный вывод

Косвенный вывод возникает, когда система под тестом передаёт данные зависимости, а не возвращает их напрямую. В этом тесте:

  1. doSomethingElse() ничего не возвращает (void), поэтому проверить прямой вывод нельзя

  2. Вместо этого результат работы метода — вызов $this->database->execute() с определёнными аргументами

  3. Мок-объект перехватывает этот косвенный вывод и проверяет, соответствует ли он нашим ожиданиям

Справочник

Создание мок-объектов

createMock()

Создаёт мок-объект для указанного интерфейса (или расширяемого класса).

$mock = $this->createMock(InterfaceName::class);

Для всех методов можно настроить возвращаемые значения и ожидания.

Ограничение: final-классы

Обратите внимание, что классы final нельзя подменить тестовыми двойниками.

Ограничение: final-, private- и static-методы

Обратите внимание, что методы final, private и static нельзя подменить тестовыми двойниками. Функциональность тестовых двойников PHPUnit игнорирует их, и они сохраняют исходное поведение, за исключением методов static, которые будут заменены методом, генерирующим исключение.

Ограничение: перечисления

Перечисления (enum) являются классами final, поэтому их нельзя подменить тестовыми двойниками.

createMockForIntersectionOfInterfaces()

Создаёт мок-объект для пересечения интерфейсов.

$mock = $this->createMockForIntersectionOfInterfaces(
    [InterfaceA::class, InterfaceB::class]
);

Это полезно, когда нужно заменить объект, реализующий несколько интерфейсов.

createConfiguredMock()

Создаёт мок-объект с методами, уже настроенными на возврат определённых значений.

$mock = $this->createConfiguredMock(
    InterfaceName::class,
    [
        'methodOne' => 'return value one',
        'methodTwo' => 'return value two',
    ]
);

// $mock->methodOne() will return "return value one"
// $mock->methodTwo() will return "return value two"

Это вспомогательный метод для простых случаев.

createPartialMock()

Создаёт мок-объект для расширяемого класса, в котором подменяются тестовыми двойниками только указанные методы. Все остальные методы сохраняют исходную реализацию.

$mock = $this->createPartialMock(
    ClassName::class,
    ['methodToDouble']
);

$mock
    ->expects($this->once())
    ->method('methodToDouble')
    ->willReturn('mocked value');

// $mock->methodToDouble() is expected to be called once
// $mock->methodToDouble() returns "mocked value"
// All other methods of ClassName execute their real code

Это может быть полезно, если нужно протестировать класс, но заменить только определённые методы (например, методы, взаимодействующие с внешними системами), сохранив при этом остальное поведение класса. Исходный конструктор отключается.

getMockBuilder()

Метод getMockBuilder() предоставляет гибкий API для создания мок-объектов. Его следует использовать только в нестандартных случаях, которые не поддерживаются более простыми методами createMock() или createMockForIntersectionOfInterfaces().

Документация и рекомендации для getStubBuilder() (см. выше) также применимы к getMockBuilder(), за двумя исключениями:

  • Для задания пользовательского имени класса созданного класса мок-объекта используется метод setMockClassName()

  • Последним в цепочке вызовов методов гибкого API необходимо вызывать метод getMock()

Настройка поведения

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

Настройка ожиданий

Ожидания определяют, сколько раз и с какими аргументами должен вызываться метод.

once()

Метод должен быть вызван ровно один раз.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->once())
    ->method('doSomething');

once() — это удобная обёртка для exactly(1).

exactly(int $count)

Метод должен быть вызван ровно $count раз.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->exactly(2))
    ->method('doSomething');
atLeastOnce()

Метод должен быть вызван как минимум один раз.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->atLeastOnce())
    ->method('doSomething');

atLeastOnce() — это удобная обёртка для atLeast(1).

Не используйте atLeastOnce()

Как правило, следует избегать неточных ожиданий относительно вызовов, таких как atLeastOnce(), поскольку они делают цель теста менее очевидной. Если точное число вызовов не имеет значения, обычно уместнее использовать тестовую заглушку, а не объект-мок. Если же число вызовов важно, exactly() или once() яснее выражают намерение.

Подробности см. в обсуждении #6483 на GitHub.

atLeast(int $requiredInvocations)

Метод должен быть вызван как минимум $requiredInvocations раз.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->atLeast(2))
    ->method('doSomething');

Не используйте atLeast()

Как правило, следует избегать неточных ожиданий относительно вызовов, таких как atLeast(), поскольку они делают цель теста менее очевидной. Если точное число вызовов не имеет значения, обычно уместнее использовать тестовую заглушку, а не объект-мок. Если же число вызовов важно, exactly() яснее выражает намерение.

Подробности см. в обсуждении #6483 на GitHub.

atMost(int $allowedInvocations)

Метод не должен вызываться более $allowedInvocations раз.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->atMost(2))
    ->method('doSomething');

Не используйте atMost()

Как правило, следует избегать неточных ожиданий относительно вызовов, таких как atMost(), поскольку они делают цель теста менее очевидной. Если точное число вызовов не имеет значения, обычно уместнее использовать тестовую заглушку, а не объект-мок. Если же число вызовов важно, exactly() или never() яснее выражают намерение.

Подробности см. в обсуждении #6483 на GitHub.

never()

Метод не должен вызываться.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->never())
    ->method('doSomething');

never() — это удобная обёртка для exactly(0).

any()

Метод может быть вызван ноль или более раз. Число вызовов не ограничивается ожиданиями, поэтому тест не завершится с ошибкой независимо от количества вызовов метода (в том числе если он не будет вызван ни разу).

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->any())
    ->method('doSomething')
    ->willReturn('value');

Показанная выше конфигурация объекта-мока эквивалентна конфигурации тестовой заглушки, приведённой ниже:

$stub = $this->createStub(InterfaceName::class);

$stub
    ->method('doSomething')
    ->willReturn('value');

Устаревшая возможность: any() устарел

Сопоставитель any(), используемый как $this->expects($this->any()), устарел. Он будет удалён в PHPUnit 14.

Использование any() вместе с expects() противоречиво: вы создаёте объект-мок (предназначенный для проверки взаимодействия объектов), по сути говоря: «Мне всё равно, произойдёт ли это взаимодействие вообще».

Если вам не нужно проверять, что метод был вызван, используйте createStub() вместо createMock(). Тестовые заглушки уместны, когда нужно лишь контролировать возвращаемое зависимостью значение, не проверяя, сколько раз она была вызвана.

Подробности см. в обсуждении #6461 на GitHub.

with()

Метод with() проверяет аргументы, переданные вызываемому методу мока.

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->once())
    ->method('doSomething')
    ->with('argument 1', 'argument 2');

В приведённом выше примере мы настраиваем объект-мок так, чтобы ожидался ровно один вызов метода doSomething(). При этом единственном вызове должны быть переданы аргументы "argument 1" и argument 2".

Приведённый выше пример эквивалентен следующему:

$mock = $this->createMock(InterfaceName::class);

$mock
    ->expects($this->once())
    ->method('doSomething')
    ->with($this->equalTo('argument 1'), $this->equalTo('argument 2'));

Метод with() проверяет аргументы, переданные вызываемому методу мока, с помощью объектов Constraint. Если значение, переданное в with(), не является объектом Constraint, оно автоматически оборачивается в объект Constraint, проверяющий равенство. Объект Constraint, проверяющий равенство, можно создать вручную с помощью $this->equalTo().

Устаревшая возможность: использование with() без expects() устарело

Использование with() для объекта-мока без одновременного использования expects() устарело. В PHPUnit 14 это станет невозможным.

Метод with() используется для проверки аргументов, переданных методу, то есть задаёт ожидание относительно вызова объекта-мока. Использование with() без expects() противоречиво: вы указываете, с какими аргументами должен вызываться метод, но при этом не задаёте ожидание относительно количества его вызовов. Используйте expects() вместе с with(), чтобы ясно выразить намерение теста.

PHPUnit предоставляет множество методов ограничений для проверки аргументов:

Идентичность и равенство

  • identicalTo()

  • equalTo()

  • equalToCanonicalizing()

  • equalToIgnoringCase()

  • equalToWithDelta()

  • objectEquals()

Количество

  • isEmpty()

  • countOf()

  • greaterThan()

  • greaterThanOrEqual()

  • lessThan()

  • lessThanOrEqual()

Математика

  • isFinite()

  • isInfinite()

  • isNan()

Логические значения

  • isFalse()

  • isTrue()

Операторы

  • logicalAnd()

  • logicalNot()

  • logicalOr()

  • logicalXor()

Строки

  • isJson()

  • matches()

  • matchesRegularExpression()

  • stringContains()

  • stringEndsWith()

  • stringEqualsStringIgnoringLineEndings()

  • stringStartsWith()

Перебираемые значения

  • arrayHasKey()

  • containsEqual()

  • containsIdentical()

  • containsOnlyArray()

  • containsOnlyBool()

  • containsOnlyCallable()

  • containsOnlyClosedResource()

  • containsOnlyFloat()

  • containsOnlyInstancesOf()

  • containsOnlyInt()

  • containsOnlyIterable()

  • containsOnlyNull()

  • containsOnlyNumeric()

  • containsOnlyObject()

  • containsOnlyResource()

  • containsOnlyScalar()

  • containsOnlyString()

  • isList()

Типы

  • isArray()

  • isBool()

  • isCallable()

  • isClosedResource()

  • isFloat()

  • isInstanceOf()

  • isInt()

  • isIterable()

  • isNull()

  • isNumeric()

  • isObject()

  • isResource()

  • isScalar()

  • isString()

Файловая система

  • directoryExists()

  • fileExists()

  • isReadable()

  • isWritable()

Ожидание вызовов одного метода с разными аргументами в определённом порядке

При тестировании кода, который несколько раз вызывает один и тот же метод с разными аргументами, часто необходимо проверить, что:

  1. Метод вызывается ожидаемое число раз

  2. При каждом вызове передаются правильные аргументы

  3. Вызовы происходят в определённом порядке

Рассмотрим службу, зависящую от Dispatcher и использующую его метод dispatch() для отправки событий. В этих сценариях важен порядок отправки событий. Мы не можем обработать этот случай с помощью with() (см. выше), поскольку настройка двух отдельных ожиданий с помощью expects() для одного и того же метода не работает (второе ожидание перезаписывает первое).

Метод withParameterSetsInOrder() решает эту задачу, позволяя указать несколько наборов параметров, которые должны сопоставляться последовательно:

$dispatcher = $this->createMock(Dispatcher::class);

$dispatcher
    ->expects($this->exactly(2))
    ->method('dispatch')
    ->withParameterSetsInOrder(
        [new AnEvent],
        [new AnotherEvent],
    );

$service = new Service($dispatcher);

$service->doSomething();

Упорядоченные наборы параметров позволяют проверить, что метод вызывается несколько раз с разными аргументами в определённом порядке. Это необходимо, если порядок операций влияет на корректность или требуется проверить определённый рабочий процесс либо протокол.

Тест завершится с ошибкой, если:

  • Метод вызван с неправильными аргументами

  • Вызовы происходят в неправильном порядке

  • Метод вызван меньше или больше раз, чем ожидалось

Ожидание вызовов одного метода с разными аргументами в произвольном порядке

Иногда требуется проверить, что метод вызывается несколько раз с определёнными аргументами, но порядок вызовов не имеет значения. Например:

  • Регистрация нескольких событий, для которых последовательность не важна

  • Отправка уведомлений нескольким получателям

  • Запись независимых друг от друга элементов аудита

Метод withParameterSetsInAnyOrder() позволяет указать несколько наборов параметров, которые должны быть использованы все, но могут сопоставляться в любом порядке:

$dispatcher = $this->createMock(Dispatcher::class);

$dispatcher
    ->expects($this->exactly(2))
    ->method('dispatch')
    ->withParameterSetsInAnyOrder(
        [new AnotherEvent],
        [new AnEvent],
    );

$service = new Service($dispatcher);

$service->doSomething();

Неупорядоченные наборы параметров позволяют проверить, что метод вызывается несколько раз с определёнными аргументами, не задавая определённую последовательность. Это полезно, когда:

  1. Порядок операций не влияет на корректность

  2. Нужно оставить реализации свободу действий

  3. Тестируется код, в котором вызовы могут менять порядок ради оптимизации

Тест завершится с ошибкой, если:

  • Метод вызван с аргументами, которые не соответствуют ни одному ожидаемому набору параметров

  • Использованы не все наборы параметров (некоторые ожидаемые вызовы отсутствуют)

  • Метод вызван меньше или больше раз, чем ожидалось

Ожидание вызовов одного метода с разными аргументами в частично заданном порядке

Иногда порядок одних вызовов важен, а других — нет. Например, вызов инициализации должен произойти на определённой позиции в последовательности, тогда как окружающие его вызовы могут выполняться в любом порядке.

Метод withParameterSetsInPartialOrder() позволяет указать несколько наборов параметров, отдельные из которых можно «закрепить» за определённой позицией, а остальные сопоставлять в любом порядке. Набор параметров закрепляется, если обернуть его в массив с ключом pinned; его положение в списке аргументов withParameterSetsInPartialOrder() определяет индекс вызова (начиная с нуля), с которым он должен быть сопоставлен:

$dispatcher = $this->createMock(Dispatcher::class);

$dispatcher
    ->expects($this->exactly(3))
    ->method('dispatch')
    ->withParameterSetsInPartialOrder(
        [new AnEvent],
        [new AnotherEvent],
        ['pinned' => new FinalEvent],
    );

$service = new Service($dispatcher);

$service->doSomething();

В приведённом выше примере AnEvent и AnotherEvent могут отправляться в любом порядке при первых двух вызовах, но аргументом третьего (и последнего) вызова должен быть FinalEvent.

Частично упорядоченные наборы параметров позволяют сочетать строгость упорядоченных наборов параметров с гибкостью неупорядоченных наборов параметров. Это полезно, когда:

  • Значимый порядок имеют только некоторые вызовы (например, вызов настройки или завершения), а остальные можно переставлять

  • Нужно закрепить определённый вызов за конкретной позицией, не задавая излишне строгий порядок для всех остальных вызовов

Тест завершится с ошибкой, если:

  • Закреплённый набор параметров не сопоставлен на заданной для него позиции

  • Метод вызван с аргументами, которые не соответствуют ни одному из оставшихся (незакреплённых) наборов параметров

  • Использованы не все наборы параметров (некоторые ожидаемые вызовы отсутствуют)

  • Метод вызван меньше или больше раз, чем ожидалось

Проверка относительного порядка вызовов между ожиданиями объекта-мока

Методы id() и after() позволяют задавать зависимости между ожиданиями объекта-мока и гарантировать, что один метод будет вызван только после вызова другого.

  • id(string $id): присваивает ожиданию уникальный идентификатор

  • after(string $id): указывает, что это ожидание следует проверять, только если совпало ожидание с заданным идентификатором

Ниже приведён полный пример использования id() и after() для проверки того, что two() вызывается после one():

public function testTwoIsCalledAfterOne(): void
{
    $mock = $this->createMock(ServiceInterface::class);

    $mock
        ->expects($this->once())
        ->method('one')
        ->id('first-call');

    $mock
        ->expects($this->once())
        ->method('two')
        ->after('first-call');

    $mock->one();
    $mock->two();
}

1. Настройка первого ожидания

$mock
    ->expects($this->once())
    ->method('one')
    ->id('first-call');
  • expects($this->once()) задаёт ожидание, что следующий метод будет вызван ровно один раз

  • method('one') указывает, что это ожидание относится к методу one()

  • id('first-call') присваивает этому ожиданию уникальный идентификатор "first-call". На этот идентификатор могут ссылаться другие ожидания с помощью after().

2. Настройка второго ожидания

$mock
    ->expects($this->once())
    ->method('two')
    ->after('first-call');
  • expects($this->once()) задаёт ожидание, что two() будет вызван ровно один раз

  • method('two') указывает, что это ожидание относится к методу two()

  • after('first-call') создаёт зависимость от ожидания с идентификатором "first-call"'"

Это означает, что:

  • Ожидание для two() будет проверяться, только после вызова one()

  • Если two() вызывается до one(), этот вызов не будет засчитан при выполнении данного ожидания

  • Тест завершится с ошибкой, если two() не будет вызван после вызова one()

3. Выполнение тестируемой системы

$mock->one();
$mock->two();
  • Вызов one() для объекта-мока выполняет первое ожидание и «разблокирует» второе

  • Вызов two() для объекта-мока выполняет второе ожидание, поскольку one() уже был вызван

Если two() вызван до one(), этот вызов не будет засчитан при выполнении ожидания after().

Повторная регистрация того же идентификатора приведёт к ошибке теста. Использование after() с идентификатором, который не был зарегистрирован с помощью id(), приведёт к сбою теста.

Устаревшая возможность: id() и after() устарели

Методы id() и after() считаются мягко устаревшими начиная с PHPUnit 13.1. В PHPUnit 14 они будут официально объявлены устаревшими, а в PHPUnit 15 удалены.

Подробности см. в обсуждении #6537 на GitHub.

Методы, которые никогда не возвращают управление

Тип возвращаемого значения never указывает, что функция или метод никогда не завершится обычным образом. Такие методы могут:

  • Выбросить исключение

  • Вызвать exit() или die()

  • Войти в бесконечный цикл

При написании модульных тестов для имитации методов с типом возвращаемого значения never требуется особая обработка, поскольку мок не может действительно «не возвращать управление»: при вызове он должен что-то сделать.

Если вы создаёте объект-мок интерфейса или расширяемого класса с методом, имеющим тип возвращаемого значения never, при вызове этого метода объект-мок выбросит NeverReturningMethodException. Это исключение имитирует поведение метода, который не завершается обычным образом.

Рассмотрим сценарий, в котором у нас есть интерфейс ErrorHandler с методом handle(), имеющим тип возвращаемого значения never:

Пример 6.9 Интерфейс, объявляющий обработчик ошибок, который не возвращает управление
<?php declare(strict_types=1);
interface ErrorHandler
{
    public function handle(Exception $e): never;
}
Пример 6.10 Реализация интерфейса обработчика ошибок
<?php declare(strict_types=1);
final readonly class ErrorHandlerImplementation implements ErrorHandler
{
    public function handle(Exception $e): never
    {
        print $e->getMessage();

        exit;
    }
}
Пример 6.11 Класс, зависящий от обработчика ошибок
<?php declare(strict_types=1);
final readonly class ServiceImplementation implements Service
{
    private ErrorHandler $errorHandler;

    public function __construct(ErrorHandler $errorHandler)
    {
        $this->errorHandler = $errorHandler;
    }

    public function doSomething(): void
    {
        try {
            // ...

            throw new Exception('message');
        } catch (Exception $e) {
            $this->errorHandler->handle($e);
        }
    }
}

Мы хотим проверить, что при выбрасывании исключения во время выполнения ServiceImplementation::doSomething() метод ErrorHandler::handle() вызывается с объектом исключения ожидаемого типа. Вот как это сделать:

1. Создание объекта-мока

$errorHandler = $this->createMock(ErrorHandler::class);

Эта строка создаёт объект-мок, реализующий интерфейс ErrorHandler. В объекте-моке будут все методы, определённые в интерфейсе, включая метод handle() с типом возвращаемого значения never.

2. Настройка объекта-мока

$errorHandler
    ->expects($this->once())
    ->method('handle')
    ->with($this->isInstanceOf(Exception::class))
    ->seal();

Эта цепочка вызовов настраивает поведение и ожидания объекта-мока:

  • ->expects($this->once()): задаёт ожидание, что метод handle() будет вызван во время теста ровно один раз. Если метод не будет вызван или будет вызван более одного раза, тест завершится с ошибкой.

  • ->method('handle'): указывает, что мы настраиваем метод handle() объекта-мока.

  • ->with($this->isInstanceOf(Exception::class)): указывает, что метод handle() должен быть вызван с аргументом, являющимся экземпляром класса Exception. Это ограничение проверяет аргумент, переданный методу.

  • ->seal(): завершает настройку мока и запрещает дальнейшие изменения конфигурации.

3. Создание тестируемой системы

$service = new ServiceImplementation($errorHandler);

Эта строка создаёт экземпляр класса ServiceImplementation, передавая ему созданный нами объект-мок, который заменяет зависимость ErrorHandler. Это стандартная процедура внедрения зависимостей, позволяющая тестировать ServiceImplementation изолированно от зависимости ErrorHandler.

4. Настройка ожидания, что вызываемый метод мока не вернёт управление

$this->expectException(NeverReturningMethodException::class);

Эта строка указывает PHPUnit, что во время теста ожидается исключение NeverReturningMethodException. Это ключевой момент при тестировании методов с типом возвращаемого значения never:

  • При вызове $service->doSomething() внутри будет вызван $this->errorHandler->handle($e)

  • Поскольку у handle() тип возвращаемого значения never, PHPUnit выбрасывает NeverReturningMethodException

  • Ожидая это исключение, мы документируем предположение о том, что метод handle() не возвращает управление

5. Выполнение тестируемой системы

$service->doSomething();

Эта строка вызывает тестируемый метод. Последовательность выполнения такова:

  1. Для службы вызывается doSomething()

  2. Внутри doSomething() выбрасывается исключение, которое перехватывается

  3. Перехваченное исключение передаётся в $this->errorHandler->handle($e)

  4. Вызывается метод handle() объекта-мока

  5. PHPUnit проверяет ожидание (метод вызван один раз с экземпляром Exception)

  6. PHPUnit выбрасывает NeverReturningMethodException, поскольку у метода тип возвращаемого значения never

  7. Тест проходит, поскольку мы ожидали это исключение

Всегда используйте $this->expectException(NeverReturningMethodException::class) при тестировании кода, вызывающего метод мока с типом возвращаемого значения never.

Исключение заменяет поведение «не возвращать управление»: в реальном коде метод с типом возвращаемого значения never вызвал бы exit(), выбросил исключение или вошёл бы в бесконечный цикл. В тестах NeverReturningMethodException имитирует это поведение, обеспечивая контролируемый способ завершить метод.

Проверка всё равно выполняется: даже если выбрасывается исключение, PHPUnit по-прежнему проверяет, выполнены ли заданные ожидания (например, expects($this->once()) и with()).

Этот подход позволяет эффективно тестировать код, зависящий от методов, которые не завершаются обычным образом, и при этом проверять, что такие методы вызываются с правильными аргументами.

Свойства с перехватчиком установки значения

В примере ниже показан интерфейс, объявляющий свойство с перехватчиком установки значения:

Пример 6.12 Интерфейс, объявляющий свойство с перехватчиком установки значения
<?php declare(strict_types=1);
interface InterfaceWithSetHookedProperty
{
    public string $property { set; }
}

Ожидания для свойства property с перехватчиком установки значения можно настроить следующим образом:

Пример 6.13 Тест, использующий объект-мок интерфейса со свойством с перехватчиком установки значения
<?php declare(strict_types=1);
use PHPUnit\Framework\MockObject\Runtime\PropertyHook;
use PHPUnit\Framework\TestCase;

final class SetHookedPropertyMockExampleTest extends TestCase
{
    public function testExample(): void
    {
        $mock = $this->createMock(
            InterfaceWithSetHookedProperty::class,
        );

        $mock
            ->expects($this->once())
            ->method(PropertyHook::set('property'))
            ->with('value');

        $mock->property = 'value';
    }
}

В приведённом выше примере PropertyHook::set('property') используется, чтобы указать, что мы хотим настроить ожидание для метода, вызываемого при обращении на запись к свойству с именем property.

Лучшие практики

  1. Предпочитайте создавать тестовые двойники интерфейсов, а не классов.

  2. Используйте понятные имена: давайте тестовым двойникам ясные имена, указывающие на их назначение.

  3. Не усложняйте: не настраивайте тестовые двойники избыточно. Настраивайте только необходимые методы.

  4. Тестируйте на подходящем уровне: создавайте mock-объекты для границ сервисов (репозиториев, внешних сервисов), а не для доменных объектов или объектов-значений.

  5. Одна концепция на тест: тестируйте либо состояние (с помощью заглушек), либо поведение (с помощью mock-объектов), но не то и другое в одном тесте.

  6. Избегайте хрупких тестов: не создавайте mock-объекты для деталей внутренней реализации. Создавайте их для интерфейсов и публичных контрактов.

  7. Проверяйте уведомления PHPUnit: если вы видите уведомление о неиспользованных mock-объектах, подумайте, следует ли добавить ожидания или вместо этого использовать тестовую заглушку.

Запечатанные тестовые двойники

Метод seal() входит во fluent API для настройки тестовых заглушек и mock-объектов. Запечатывая тестовый двойник, вы запрещаете его дальнейшую настройку и указываете, что его конфигурация полностью завершена.

Основные преимущества

  • Предотвращает случайную неправильную настройку: после запечатывания любая попытка настроить дополнительное поведение или ожидания приведёт к ошибке теста

  • Более строгое поведение mock-объектов: для mock-объектов запечатывание добавляет неявные ожидания never() для всех явно не настроенных методов, из-за чего тест завершится с ошибкой, если эти методы будут вызваны

  • Тесты с понятной документацией: запечатывание явно показывает, что настройка тестового двойника завершена

Запечатанные тестовые заглушки

В этом примере показано, как запечатать тестовую заглушку с помощью метода seal():

public function testDemonstratingSealedTestStubs(): void
{
    $stub = $this->createStub(InterfaceName::class);

    $stub
        ->method('doSomething')
        ->willReturn('value')
        ->seal();

    // ...

    $stub->doSomething();
    $stub->doSomethingElse();
}

Метод запечатанной тестовой заглушки, который не был настроен, например doSomethingElse() в примере выше, можно вызвать.

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

Запечатанные mock-объекты

В этом примере показано, как запечатать mock-объект с помощью метода seal():

public function testDemonstratingSealedMockObjects(): void
{
    $mock = $this->createMock(InterfaceName::class);

    $mock
        ->expects($this->once())
        ->method('doSomething')
        ->seal();

    // ...

    $mock->doSomething();
    $mock->doSomethingElse();
}

Метод запечатанного mock-объекта, который не был настроен, например doSomethingElse() в примере выше, не должен вызываться. Если его вызвать, тест завершится с ошибкой. Это эквивалентно следующему:

public function testDemonstratingSealedMockObjects(): void
{
    $mock = $this->createMock(InterfaceName::class);

    $mock
        ->expects($this->once())
        ->method('doSomething');

    $mock
        ->expects($this->never())
        ->method('doSomethingElse');

    // ...

    $mock->doSomething();
    $mock->doSomethingElse();
}
Обязательное запечатывание mock-объектов

Описанный выше метод seal() предоставляет разработчикам возможность по желанию завершить настройку mock-объектов и предотвратить неожиданные вызовы методов. Однако разработчикам необходимо самостоятельно помнить о вызове этого метода для своих mock-объектов, чтобы воспользоваться этой мерой безопасности.

По мере развития проектов и установления стандартов тестирования в командах становится полезным закреплять определённые практики тестирования на уровне проекта, а не полагаться на то, что отдельные разработчики будут применять их последовательно. Именно поэтому requireSealedMockObjects="true" можно использовать в файле конфигурации XML PHPUnit, чтобы установить политику на уровне проекта, закрепляющую лучшую практику запечатывания mock-объектов.

© 2005–2026 Sebastian Bergmann
Licensed under the Creative Commons Attribution 3.0 Unported License.
https://docs.phpunit.de/en/13.2/test-doubles.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API