PHPUnit: Mock объекты

Довольно часто при написании модульных тестов нам приходится сталкиваться с тем, что тестируемый класс зависит от данных из внешних источников, состояние которых мы не можем контролировать. К таким источникам можно отнести далеко расположенную общедоступную базу данных или службу, датчик какого-нибудь физического процесса и пр. Либо нам необходимо убедиться, что некие действия выполняются в строго определенном порядке. В этих случаях к нам на помощь приходят Mock объекты (mock в переводе с английского — пародия), позволяя тестировать классы в изоляции от внешних зависимостей. Использованию Mock объектов в PHPUnit посвящается эта статья.

В качестве используемого примера возьмем следующее описание класса:
class MyClass {
    protected function showWord($word) { /* отображает указанное слово на абстрактном устройстве */ }
    protected function getTemperature() { /* обращение к датчику температуры */ }
    public getWord($temparature) {
        $temperature = (int)$temparature;
        if ($temperature < 15) { return 'cold'; }
        if ($temperature > 25) { return 'hot'; }
        return 'warm';
    }
    public function process() {
        $temperature = $this->getTemperature();
        $word = $this->getWord($temperature);
        $this->showWord($word);
    }
}

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

В простейшем случае для проверки логики мы можем отнаследоваться от указанного класса, подменить заглушками методы, которые обращаются к неподключенным устройствам, и провести модульное тестирование на экземпляре потомка. Примерно так же реализованы Mock объекты в PHPUnit, где при этом предоставляется дополнительное удобство в виде встроенного API.

Получение Mock объекта


Для получения экземпляра Mock объекта используется метод getMock():
class MyClassTest extends PHPUnit_Framework_TestCase {
    public function test_process() {
        $mock = $this->getMock('MyClass');

        // проверяем, что в $mock находится экземпляр класса MyClass
        $this->assertInstanceOf('MyClass', $mock);
    }
}

Как видите, получить нужный нам Mock объект очень просто. По-умолчанию, все методы в нем будут подменены заглушками, которые ничего не делают и всегда возвращают null.

Параметры вызова getMock

public function getMock(
    $originalClassName, // название оригинального класса, для которого будет создан Mock объект
    $methods = array(), // в этом массиве можно указать какие именно методы будут подменены
    array $arguments = array(), // аргументы, передаваемые в конструктор
    $mockClassName = '', // можно указать имя Mock класса
    $callOriginalConstructor = true, // отключение вызова __construct()
    $callOriginalClone = true, // отключение вызова __clone()
    $callAutoload = true // отключение вызова __autoload()
);

Передача строителю getMock() в качестве второго аргумента значения null приведет к тому, что будет возвращен Mock объект вообще без подмен.

getMockBuilder

Для тех, кому приятнее писать в цепном стиле, PHPUnit предлагает соответствущий конструктор:
$mock = $this->getMockBuilder('MyClass')
    ->setMethods(null)
    ->setConstructorArgs(array())
    ->setMockClassName('')
    // отключив вызов конструктора, можно получить Mock объект "одиночки"
    ->disableOriginalConstructor()
    ->disableOriginalClone()
    ->disableAutoload()
    ->getMock();

Цепочка всегда должна начинаться с метода getMockBuilder() и закачиваться методом getMock() — это единственные звенья цепи, которые являются обязательными.

Дополнительные способы получения Mock объектов

  • getMockFromWsdl() — позволяет строить Mock объекты на основе описания из WSDL;
  • getMockClass() — создает Mock класс и возвращает его название в виде строки;
  • getMockForAbstractClass() — возвращает Mock объект абстрактного класса, в котором подменены все абстрактные методы.

Все это прекрасно — скажете вы, но что же дальше? В ответ скажу, что мы как раз подошли к самому интересному.

Ожидание вызова метода


PHPUnit позволяет нам контроллировать количество и порядок вызовов подмененных методов. Для этого используется конструкция expects() с последующим указанием нужного метода при помощи method(). В качестве примера обратимся к классу, приведенному в начале статьи, и напишем для него вот такой тест:
public function test_process() {
    $mock = $this->getMock('MyClass', array('getTemperature', 'getWord', 'showWord'));
    $mock->expects($this->once())->method('getTemperature');
    $mock->expects($this->once())->method('showWord');
    $mock->expects($this->once())->method('getWord');
    $mock->process();
}

Результат выполнения этого теста будет успешным, если при вызове метода process() произойдет однократный вызов трех перечисленных методов: getTemperature(), getWord(), showWord(). Обратите внимание, что в тесте проверка вызова getWord() стоит после проверки вызова showWord(), хотя в тестируемом методе наоборот. Все верно, ошибки здесь нет. Для контроля порядка вызова методов в PHPUnit используется другая конструкция — at(). Поправим немного код нашего теста так чтобы PHPUnit проверил заодно очередность вызова методов:
public function test_process() {
    $mock = $this->getMock('MyClass', array('getTemperature', 'getWord', 'showWord'));
    $mock->expects($this->at(0))->method('getTemperature');
    $mock->expects($this->at(2))->method('showWord');
    $mock->expects($this->at(1))->method('getWord');
    $mock->process();
}

Помимо упомянутых once() и at() для тестирования ожиданий вызовов в PHPUnit есть также следующие конструкции: any(), never(), atLeastOnce() и exactly($count). Их названия говорят сами за себя.

Переопределение возвращаемого результата


Безусловно самой полезной функцией Mock объектов является возможность эмуляции возвращаемого результата подмененными методами. Снова обратимся к методу process() нашего класса. Мы видим там обращение к датчику температуры — getTemperature(). Но мы также помним, что на самом деле датчика у нас нет. Хотя даже если бы он у нас был, не будем же мы охлаждать его ниже 15 градусов или нагревать выше 25 для того, чтобы протестировать все возможные ситуации. Как вы уже догадались, в этом случае на помощь к нам приходят Mock объекты. Мы можем заставить интересующий нас метод вернуть любой результат какой захотим при помощи кострукции will(). Вот пример:
/**
 * @dataProvider provider_process
 */
public function test_process($temperature) {
    $mock = $this->getMock('MyClass', array('getTemperature', 'getWord', 'showWord'));

    // метод getTemperature() вернет значение $temperature
    $mock->expects($this->once())->method('getTemperature')->will($this->returnValue($temperature));

    $mock->process();
}

public static function provider_process() {
    return array(
        'cold' => array(10),
        'warm' => array(20),
        'hot' => array(30),
    );
}

Очевидно, что данный тест покрывает все возможные значения, которые может обработать наш тестируемый класс. PHPUnit предлагает к использованию совместно с will() следующие конструкции:
  • returnValue($value) — возвращает $value;
  • returnArgument($index) — возвращает аргумент с номером $index, указанный при вызове метода;
  • returnSelf() — возвращает указатель на самого себя, полезно для тестирования цепочечных методов;
  • returnValueMap($map) — используется для возвращения результата на основе определенных наборов аргументов;
  • returnCallback($callback) — передает аргументы в указанную функцию и возвращает ее результат;
  • onConsecutiveCalls() — последовательно возвращает один из перечисленных аргументов при каждом следующем вызове метода;
  • throwException($exception) — бросает указанное исключение.

Проверка указанных аргументов


Еще одной полезной для тестирования возможностью Mock объектов является проверка аргументов, указанных при вызове подмененного метода, при помощи конструкции with():
public function test_with_and_will_usage() {
    $mock = $this->getMock('MyClass', array('getWord'));
    $mock->expects($this->once())
        ->method('getWord')
        ->with($this->greaterThan(25))
        ->will($this->returnValue('hot'));
    $this->assertEquals('hot', $mock->getWord(30));
}

В качестве аргументов with() может принимать все те же конструкции, что и проверка assertThat(), поэтому здесь я приведу лишь список возможных конструкций без их подробного описания:
  • attribute()
  • anything()
  • arrayHasKey()
  • contains()
  • equalTo()
  • attributeEqualTo()
  • fileExists()
  • greaterThan()
  • greaterThanOrEqual()
  • classHasAttribute()
  • classHasStaticAttribute()
  • hasAttribute()
  • identicalTo()
  • isFalse()
  • isInstanceOf()
  • isNull()
  • isTrue()
  • isType()
  • lessThan()
  • lessThanOrEqual()
  • matchesRegularExpression()
  • stringContains()

Все перечисленные конструкции можно комбинировать при помощи логических конструкций logicalAnd(), logicalOr(), logicalNot() и logicalXor():
$mock->expects($this->once())
    ->method('getWord')
    ->with($this->logicalAnd($this->greaterThanOrEqual(15), $this->lessThanOrEqual(25)))
    ->will($this->returnValue('warm'));

Теперь, когда мы полностью ознакомились с возможностями Mock объектов в PHPUnit, мы можем провести окончательное тестирование нашего класса:
/**
 * @dataProvider provider_process
 */
public function test_process($temperature, $expected_word) {

    // получаем Mock объект, методы getWord() и process() наследуют логику от оригинального класса
    $mock = $this->getMock('MyClass', array('getTemperature', 'showWord'));

    // метод getTemperature() возвращает значение аргумента $temperature
    $mock->expects($this->once())->method('getTemperature')->will($this->returnValue($temperature));

    // проверяем, что метод showWord() запускается со значением $expected_word
    $mock->expects($this->once())->method('showWord')->with($this->equalTo($expected_word));

    // запуск
    $mock->process();
}

public static function provider_process() {
    return array(
        'cold' => array(10, 'cold'),
        'warm' => array(20, 'warm'),
        'hot' => array(30, 'hot'),
    );
}

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

Подмена статических методов


Начиная с PHPUnit версии 3.5 стала возможной подмена статических методов при помощи статической конструкции staticExpects():
$class = $this->getMockClass('SomeClass');

// работает только в PHP версии 5.3 и выше
// в более ранних версиях можно использовать call_user_func_array()
$class::staticExpects($this->once())->method('someStaticMethod');
$class::someStaticMethod();

Чтобы это нововведение имело практическое применение, нужно чтобы внутри тестируемого класса вызов подменяемого статического метода происходил одним из перечисленных ниже способов:
  • $this->staticMethod() — из динамических методов;
  • static::staticMethod() — из статических и динамических методов.

Из-за ограничений self не будет работать подмена статических методов, вызываемых внутри класса таким способом:
self::staticMethod();

Заключение


В заключении скажу, что не стоит сильно увлекаться Mock объектами в каких-либо целях, отличных от изоляции тестируемого класса от внешних источников данных. Иначе при любом, даже незначительном, изменении исходного кода вам скорее всего понадобится также править и сами тесты. А довольно значительный рефакторинг может привести к тому, что вам придется вообще полностью их переписывать.
  • +8
  • 64,7k
  • 7
Поделиться публикацией

Похожие публикации

Комментарии 7

    +1
    На код же невозможно смотреть, где подсветка синтаксиса?
      0
      Прошу прощения. Поправил.
      +4
      Вообще говоря, использование классических моков это уже не модульные тесты, а интеграционные. И основное их назначение не изоляция от внешних источников данных (это задача стабов, хотя в PHPUnit такой отдельной сущности нет — её заменяет мок, возвращающий результаты), а контроль взаимодействия подсистем.

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

      Большой соблазн написать что-то вроде: «тест проверяет, что в методе первой вызывается ровно один раз $db->query со строковым параметром „SELECT * FROM users“, возвращающую не false, строго после неё вызывается $db->fetch с параметром, возвращенным ранее, столько раз сколько она возвращает не false, а массив(onConsecutiveCalls(array(...), array(...), array(), false), строго после неё один раз вызывается $db->free с тем же параметром, а главное, что метод возвращает массив объектов соответствующий возврату $db->fetch ранее. Но представьте как с этим работать. Нужно хотя бы отделить проверку корректности вызовов методов $db (классические моки, все эти excepts, with, at и т.п.) — интеграционные тесты — и проверку работы собственно метода (моки в роли стабов — will), пускай и 90% кода будет пересекаться (его можно вынести в setUp или отдельный метод). Главное, чтобы сфейливший тест однозначно указывал на место ошибки одним фактом своего фейла и своим названием, без необходимости залезать в код теста и тестируемый код. Если мы говорим о модульном тестировании.

      В вашем случае у теста (последний вариант) есть три потенциальных точки фейла: метод getTemperature не вызовется ровно один раз, метод showWord не вызовется ровно один раз и метод showWord, вызовется ровно один раз, но не с ожидаемым параметром. Одним тестом вы тестируете, по сути, логику взаимодействия с датчиком (что getTemperature вызывается один раз), логику взаимодействия с индикатором (что showWord вызывается один раз) и логику самого метода process (что параметр вызова showWord соответствует температуре, то есть ещё и неявное тестирование метода getWord).

      Вам ничего не скажет сообщение о том, что test_process сфейлил. Более того, без изучения кода и теста, и всего класса вам ничего не скажет даже сообщение о том, что метод showWord вызвался не с тем параметром.

      Я бы тестировал так:

      0. тесты на getWord (4 штуки, 14,15,25 и 26) — быстрые модульные тесты, не требующие моков, стабов и прочих отражений/кодогенераций

      1. проверяем, что getTemperature (мок getTemperature с произвольным will, „нулевой“ стаб showWord) вызывается один раз без параметров — ожидаемое взаимодействие с датчиком — один медленный интеграционный тест

      2. проверяем, что showWord для какой-то температуры (стаб getTemperature c произвольным will, мок showWord) вызывается один раз с нужным значением — ожидаемое взаимодействие с индикатором — второй медленный интеграционный тест

      3. (на любителя, имхо оверхид) проверяем, что getWord вызывается один раз с параметром соответствующим температуре (стаб getTemperature c произвольным will, мок getWord, „нулевой“ стаб showWord) — третий медленный интеграционный тест

      Любой сфейливший тест покажет нам однозначно место ошибки — либо что-то не то с вызовом getTemperature, либо с вызовом showWord, либо с логикой getWord. Нет нужды много раз неявно, с использованием моков и стабов, проверять getWord, особенно учитывая, что ошибка скорее всего будет именно в нём.
        0
        Полностью согласен с вашими суждениями. Целью моей статьи является демонстрация приемов применения Mock объектов в PHPUnit. Поэтому и был выбран пример, в котором можно было бы полностью отразить все возможности использования таких объектов в одном тесте.
          0
          Проблема в том, что неудачные примеры имеют свойство «размножаться», причём, субъективно, быстрее чем удачные. Такое ощущение, что антипаттерн в коде запоминается в мозгу как паттерн даже если большими буквами написано «НИКОГДА ТАК НЕ ДЕЛАЙТЕ».
            0
            Было бы здорово, если бы вы переписали текущий тест на «как надо» и кинули бы сюда пример.
        0
        Хоть статья и не новая, но полезная — спасибо.

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

        Самое читаемое