Довольно часто при написании модульных тестов нам приходится сталкиваться с тем, что тестируемый класс зависит от данных из внешних источников, состояние которых мы не можем контролировать. К таким источникам можно отнести далеко расположенную общедоступную базу данных или службу, датчик какого-нибудь физического процесса и пр. Либо нам необходимо убедиться, что некие действия выполняются в строго определенном порядке. В этих случаях к нам на помощь приходят 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(): // проверяем, что в $mock находится экземпляр класса MyClass
class MyClassTest extends PHPUnit_Framework_TestCase {
public function test_process() {
$mock = $this->getMock('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