Pull to refresh

PHPUnit: Mock объекты

Reading time7 min
Views91K
Довольно часто при написании модульных тестов нам приходится сталкиваться с тем, что тестируемый класс зависит от данных из внешних источников, состояние которых мы не можем контролировать. К таким источникам можно отнести далеко расположенную общедоступную базу данных или службу, датчик какого-нибудь физического процесса и пр. Либо нам необходимо убедиться, что некие действия выполняются в строго определенном порядке. В этих случаях к нам на помощь приходят 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 объектами в каких-либо целях, отличных от изоляции тестируемого класса от внешних источников данных. Иначе при любом, даже незначительном, изменении исходного кода вам скорее всего понадобится также править и сами тесты. А довольно значительный рефакторинг может привести к тому, что вам придется вообще полностью их переписывать.
Tags:
Hubs:
Total votes 14: ↑11 and ↓3+8
Comments7

Articles