PHPUnit: Mock объекты

в 21:13, , рубрики: mock, php, phpunit, метки: , ,

Довольно часто при написании модульных тестов нам приходится сталкиваться с тем, что тестируемый класс зависит от данных из внешних источников, состояние которых мы не можем контролировать. К таким источникам можно отнести далеко расположенную общедоступную базу данных или службу, датчик какого-нибудь физического процесса и пр. Либо нам необходимо убедиться, что некие действия выполняются в строго определенном порядке. В этих случаях к нам на помощь приходят 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('')
->disableOriginalConstructor() // отключив вызов конструктора, можно получить Mock объект "одиночки"
->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'),
);
}

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

Начиная с 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 объектами в каких-либо целях, отличных от изоляции тестируемого класса от внешних источников данных. Иначе при любом, даже незначительном, изменении исходного кода вам скорее всего понадобится также править и сами тесты. А довольно значительный рефакторинг может привести к тому, что вам придется вообще полностью их переписывать.

Автор: renskiy

Поделиться

* - обязательные к заполнению поля