() translation by (you can also view the original English article)
Существует два стиля тестирования: стили «черного ящика» и «белого ящика». Тестирование черного ящика фокусируется на состоянии объекта; тогда как тестирование белого ящика фокусируется на поведении. Эти два стиля дополняют друг друга и могут быть объединены для тщательного тестирования кода. Mocking позволяет нам тестировать поведение, и этот учебник сочетает концепцию моков с TDD, чтобы создать класс примера, который использует несколько других компонентов для своей работы.
Шаг 1: Введение в тестирование поведения
Объекты - это сущности, которые отправляют сообщения друг другу. Каждый объект распознает набор сообщений, на которые он в свою очередь отвечает. Это public методы объекта. Private методы - это полная противоположность. Они полностью внутренне относятся к объекту и не могут связываться с чем-либо вне объекта. Если публичные методы сродни сообщениям, частные методы похожи на мысли.
Общее количество всех методов, public и private, доступных через публичные методы, представляет собой поведение объекта. Например, указание move объекту, заставляет этот объект не только взаимодействовать со своими внутренними методами, но и с другими объектами. С точки зрения пользователя объект имеет только одно простое поведение: он moves.
Однако с точки зрения программиста объект должен сделать много мелочей для достижения движения.
Например, представьте, что наш объект - это автомобиль. Чтобы он мог move, он должен иметь ходовой двигатель, находиться на первой передаче (или наоборот), а колеса должны вращаться. Это поведение, которое нам необходимо проверить и использовать для разработки и написания нашего итогового кода.
Шаг 2: Игрушечная машинка с дистанционным управлением
Наш тестируемый класс никогда не использует эти фиктивные объекты.
Представим себе, что мы создаем программу для дистанционного управления игрушечной машиной. Все команды нашего класса проходят через пульт дистанционного управления. Мы должны создать класс, который понимает, что пульт дистанционного управления отправляет и выдает команды машине.
Это будет упражнение, и мы предполагаем, что другие классы, контролирующие различные части автомобиля, уже написаны. Мы знаем точную сигнатуру всех этих классов, но, к сожалению, производитель автомобилей не смог отправить нам прототип - даже нет исходного кода. Все, что мы знаем, это имена классов, методы, которые у них есть, и какое поведение каждый метод инкапсулирует. Также указываются возвращаемые значения.
Шаг 3: Схема приложения
Вот полная схема приложения. На данный момент нет никаких объяснений; просто имейте это в виду для последующего использования.



Шаг 4: Тестовые дубликаты
Тест-stub (заглушка) является объектом контроля косвенного ввода тестируемого кода.
Mocking - это стиль тестирования, для которого требуется собственный набор инструментов, набор специальных объектов, представляющих разные уровни фальсификации поведения объекта. Вот они:
- фиктивные объекты
- тестовые заглушки
- тестовые шпионы
- тестовые моки
- пробные подделки
Каждый из этих объектов имеет свою собственную зону ответственности и поведение. В PHPUnit они создаются с помощью метода $this->getMock()
. Разница заключается в том, как и по каким причинам используются объекты.
Чтобы лучше понять эти объекты, я буду реализовывать «игрушечный контроллер автомобиля» шаг за шагом, используя типы объектов, в таком порядке, как было указано выше. Каждый объект в списке более сложный, чем предыдущий объект. Это приводит к реализации, которая радикально отличается от реального мира. Кроме того, будучи воображаемым приложением, я буду использовать некоторые сценарии, которые могут быть даже невозможны в реальной игрушечной машине. Но, эй, давайте представим, что нам нужно, чтобы понять большую картину.
Шаг 5: Dummy-объект
Объекты «пустышки» - это объекты, от которых зависит системный тест (SUT), но они на самом деле никогда не используются. Фиктивный объект может быть аргументом, переданным другому объекту, или он может быть возвращен вторым объектом и затем передан третьему объекту. Дело в том, что наш тестируемый класс никогда не использовал эти фиктивные объекты. В то же время объект должен напоминать реальный объект; в противном случае получатель может отказаться от него.
Лучший способ продемонстрировать это - представить себе сценарий; схема которого представлена ниже:



Оранжевым объектом является RemoteControlTranslator
. Основная цель - получать сигналы с пульта дистанционного управления и переводить их в сообщения для наших классов. В какой-то момент пользователь выполнит действие «Ready to Go» на пульте дистанционного управления. Переводчик получит сообщение и создаст классы, необходимые для того, чтобы автомобиль был готов к работе.
Изготовитель сказал, что «Ready to Go» означает, что двигатель запущен, коробка передач находится в нейтральном положении, а индикаторы установлены в положение «включено» или «выключено» в соответствии с запросом пользователя.
Это означает, что пользователь может заранее определить состояние огней перед тем, как быть готовым к работе, и они включится или выключится на основе этого предопределенного значения при активации. Затем RemoteControlTranslator
отправляет всю необходимую информацию в класс CarControl
«getRightToGo($engine, $gearbox, $electronics, $lights)
. Я знаю, что это далеко не идеальный дизайн и нарушает несколько принципов и шаблонов, но это очень хорошо для этого примера.
Начните наш проект с этой исходной файловой структуры:

Помните, что все классы в папке CarInterface предоставлены производителем автомобиля; мы не знаем их реализации. Все, что мы знаем, это сигнатуры классов, но на данный момент нас это не волнует.
Наша основная цель - реализовать класс CarController
. Чтобы протестировать этот класс, нам нужно представить, как мы хотим его использовать. Другими словами, мы ставим себя на место RemoteControlTranslator
и / или любого другого будущего класса, который может использовать CarController
. Начнем с создания кейса для нашего класса.
1 |
class CarControllerTest extends PHPUnit_Framework_TestCase { |
2 |
}
|
Затем добавьте тестовый метод.
1 |
function testItCanGetReadyTheCar() { |
2 |
}
|
Теперь подумайте над тем, что нам нужно передать методу getReadyToGo()
: движок, коробку передач, контроллер электроники и данные по огням. Ради этого примера мы замокаем только огни:
1 |
require_once '../CarController.php'; |
2 |
include '../autoloadCarInterfaces.php'; |
3 |
|
4 |
class CarControllerTest extends PHPUnit_Framework_TestCase { |
5 |
|
6 |
function testItCanGetReadyTheCar() { |
7 |
$carController = new CarController(); |
8 |
|
9 |
$engine = new Engine(); |
10 |
$gearbox = new Gearbox(); |
11 |
$electornics = new Electronics(); |
12 |
|
13 |
$dummyLights = $this->getMock('Lights'); |
14 |
|
15 |
$this->assertTrue($carController->getReadyToGo($engine, $gearbox, $electornics, $dummyLights)); |
16 |
}
|
17 |
|
18 |
}
|
Это, очевидно, не сработает:
1 |
PHP Fatal error: Call to undefined method CarController::getReadyToGo() |
Несмотря на неудачу, этот тест дал нам отправную точку для нашей реализации CarController
. Я подключил файл с именем autoloadCarInterfaces.php, который не был в исходном списке. Я понял, что мне нужно что-то, что загрузит все классы, и я написал очень простое решение. Мы всегда можем переписать его, когда появятся реальные классы, но это уже совершенно другая история. На данный момент мы придерживаемся простого решения:
1 |
foreach (scandir(dirname(__FILE__) . '/CarInterface') as $filename) { |
2 |
$path = dirname(__FILE__) . '/CarInterface/' . $filename; |
3 |
|
4 |
if (is_file($path)) { |
5 |
require_once $path; |
6 |
}
|
7 |
}
|
Я предполагаю, что этот загрузчик классов очевиден для всех; поэтому давайте обсудим тестовый код.
Сначала мы создаем экземпляр CarController
, класс, который мы хотим протоестировать. Затем мы создаем экземпляры всех других классов, которые нам интересны: двигатель, коробка передач и электроника.
Затем мы создаем dummy-объект Lights
, вызывая метод getMock()
PHPUnit и передавая имя класса Lights
. Это возвращает экземпляр Lights
, но каждый метод возвращает null
- фиктивный объект. Этот фиктивный объект ничего не может сделать, но он дает нашему коду интерфейс, необходимый для работы со объектами Light
.
Очень важно отметить, что
$dummyLights
- это объектLights
, и любой пользователь, ожидающий объектLight
, может использовать фиктивный объект, не зная, что он не является реальным объектомLights
.
Чтобы избежать путаницы, я рекомендую указать тип параметра при определении функции. Это заставляет среду выполнения PHP проверять аргументы, переданные функции. Без указания типа данных вы можете передать любой объект любому параметру, что может привести к сбою вашего кода. Имея это в виду, давайте рассмотрим класс Electronics
:
1 |
require_once 'Lights.php'; |
2 |
|
3 |
class Electronics { |
4 |
|
5 |
function turnOn(Lights $lights) {} |
6 |
|
7 |
}
|
Давайте проведем тест:
1 |
class CarController { |
2 |
|
3 |
function getReadyToGo(Engine $engine, Gearbox $gearbox, Electronics $electronics, Lights $lights) { |
4 |
$engine->start(); |
5 |
$gearbox->shift('N'); |
6 |
|
7 |
$electronics->turnOn($lights); |
8 |
|
9 |
return true; |
10 |
}
|
11 |
|
12 |
}
|
Как вы можете видеть, функция getReadyToGo()
использовала объект $lights
только с целью отправки его методу turnOn()
объекта $electronics
. Это идеальное решение для такой ситуации? Наверное, нет, но вы можете четко наблюдать, как объект-пустышка, без какой-либо связи с функцией getReadyToGo()
, передается вместе с одним объектом, который действительно нуждается в нем.
Обратите внимание, что все классы, содержащиеся в каталоге CarInterface, при инициализации предоставляют фиктивные объекты. Также предположим, что для этого упражнения мы ожидаем, что производитель предоставит настоящие классы в будущем. Мы не можем полагаться на их нынешнюю нехватку функциональности; поэтому мы должны обеспечить, чтобы наши тесты успешно проходили.
Шаг 6: «Заглушить» статус и двигаться вперёд
Тест-заглушка является объектом контроля косвенного ввода тестируемого кода. Но что такое косвенный ввод? Это источник информации, который нельзя напрямую указать.
Наиболее распространенным примером тестового заглушки является то, что объект запрашивает другой объект для информации, а затем делает что-то с этими данными.
Шпионы, по определению, являются более способными заглушками.
Данные могут быть получены только путем запроса их у определенного объекта, и во многих случаях эти объекты используются для определенной цели внутри тестируемого класса. Мы не хотим создавать (new SomeClass()
) класс внутри другого класса для целей тестирования. Поэтому нам нужно ввести экземпляр класса, который действует как SomeClass
, при этом не вводя фактический объект SomeClass
.
Нам нужен класс заглушки, который затем приводит к инъекции зависимостей. Инъекция зависимостей (DI) - это техника, который вводит один объект в другой объект, заставляя его использовать введенный объект. DI распространен в TDD, и это абсолютно необходимо практически в любом проекте. Он обеспечивает простой способ заставить объект использовать подготовленный тестовый класс вместо реального класса, используемого в рабочей среде.
Давайте заставим нашу игрушечную машину двигаться вперед.



Мы хотим реализовать метод moveForward()
. Этот метод сначала запрашивает объект StatusPanel
для состояния топлива и двигателя. Если автомобиль готов к работе, то метод инструктирует электронику ускоряться.
Чтобы лучше понять, как работает заглушка, я сначала напишу код проверки состояния и ускорения:
1 |
function goForward(Electronics $electronics) { |
2 |
$statusPanel = new StatusPanel(); |
3 |
if($statusPanel->engineIsRunning() && $statusPanel->thereIsEnoughFuel()) |
4 |
$electronics->accelerate (); |
5 |
}
|
Этот код довольно прост, но у нас нет реального двигателя или топлива, чтобы проверить нашу реализацию goForward()
. Наш код даже не будет входить в оператор if
, потому что у нас нет класса StatusPanel
. Но если мы продолжим тестирование, появится логическое решение:
1 |
function testItCanAccelerate() { |
2 |
$carController = new CarController(); |
3 |
|
4 |
$electronics = new Electronics(); |
5 |
|
6 |
$stubStatusPanel = $this->getMock('StatusPanel'); |
7 |
$stubStatusPanel->expects($this->any())->method('thereIsEnoughFuel')->will($this->returnValue(TRUE)); |
8 |
$stubStatusPanel->expects($this->any())->method('engineIsRunning')->will($this->returnValue(TRUE)); |
9 |
|
10 |
$carController->goForward($electronics, $stubStatusPanel); |
11 |
}
|
Построчное объяснение:
Мне нравится рекурсия; всегда легче протестировать рекурсию, чем циклы.
- создать новый
CarController
- создать зависимый объект
Electronics
- создать мок для
StatusPanel
- ожидать, что будет вызван метод
thereIsEnoughFuel()
ноль или более раз и вернутьtrue
- ожидание вызова
engineIsRunning()
ноль или более раз и вернутьtrue
- вызов
goForward()
сElelctronics
иStubbedStatusPanel
Это тест, который мы хотим написать, но он не будет работать с нашей текущей версией goForward()
. Мы должны изменить его:
1 |
function goForward(Electronics $electronics, StatusPanel $statusPanel = null) { |
2 |
$statusPanel = $statusPanel ? : new StatusPanel(); |
3 |
if($statusPanel->engineIsRunning() && $statusPanel->thereIsEnoughFuel()) |
4 |
$electronics->accelerate (); |
5 |
}
|
В нашей модификации используется инъекция зависимостей путем добавления второго необязательного параметра типа StatusPanel
. Мы определяем, имеет ли этот параметр значение и создаем новый StatusPanel
, если $statusPanel
имеет значение NULL. Это гарантирует, что новый объект StatusPanel
будет создан в процессе работы, все еще позволяя нам протестировать метод.
Важно указать тип параметра $statusPanel
. Это гарантирует, что к методу может быть передан только объект StatusPanel
(или объект наследуемого класса). Но даже с этой модификацией наш тест все еще не завершен.
Шаг 7: Завершите тест с помощью мока
Мы должны протестировать объект Electronics
, чтобы гарантировать, что наш метод из шага 6 вызывает метод accelerate()
. Мы не можем использовать настоящий класс Electronics
по нескольким причинам:
- У нас нет этого класса.
- Мы не можем проверить его поведение.
- Даже если бы мы могли его вызвать, мы должны тестировать его изолированно.
Тестовый мок - это объект, который способен контролировать как косвенный ввод так и вывод, и у него есть механизм автоматического утверждения ожиданий и результатов. Это определение может показаться немного запутанным, но реализовать его довольно просто:
1 |
function testItCanAccelerate() { |
2 |
$carController = new CarController(); |
3 |
|
4 |
$electronics = $this->getMock('Electronics'); |
5 |
$electronics->expects($this->once())->method('accelerate'); |
6 |
|
7 |
$stubStatusPanel = $this->getMock('StatusPanel'); |
8 |
$stubStatusPanel->expects($this->any())->method('thereIsEnoughFuel')->will($this->returnValue(TRUE)); |
9 |
$stubStatusPanel->expects($this->any())->method('engineIsRunning')->will($this->returnValue(TRUE)); |
10 |
|
11 |
$carController->goForward($electronics, $stubStatusPanel); |
12 |
}
|
Мы просто изменили переменную $electronics
. Вместо создания реального объекта Electronics
мы просто делаем его мок.
На следующей строке мы определяем ожидание для объекта $electronics
. Точнее, мы ожидаем, что метод accelerate()
вызывается только один раз ($this->once()
). Тест теперь проходит!
Не стесняйтесь поиграть с этим тестом. Попробуйте изменить $this->once()
на $this->exactly(2)
и посмотрите, какое приятное сообщение об ошибке PHPUnit выдаст вам:
1 |
1) CarControllerTest::testItCanAccelerate |
2 |
Expectation failed for method name is equal to <string:accelerate>; when invoked 2 time(s). |
3 |
Method was expected to be called 2 times, actually called 1 times. |
Шаг 8: Использование тестового шпиона
Тестовый шпион - это объект, способный фиксировать косвенный вывод и при необходимости обеспечивать косвенный ввод.
Косвенный вывод - это то, что мы не можем наблюдать напрямую. Например: когда тестируемый класс вычисляет значение, а затем использует его в качестве аргумента для метода другого объекта. Единственный способ наблюдать этот вывод - спросить вызываемый объект о переменной, используемой для доступа к его методу.
Это определение делает шпиона чем-то очень ложным.
Основное различие между моком и шпионом заключается в том, что мок объекты имеют встроенные утверждения и ожидания.
В этом случае, как мы можем создать тестовый шпион, используя getMock()
PHPUnit? Мы не можем (ну, мы не можем создать чистого шпиона), но мы можем создавать моки, способные шпионить за другими объектами.
Давайте реализуем тормозную систему, чтобы мы могли остановить машину. Торможение довольно простое; пульт дистанционного управления будет ощущать интенсивность торможения от пользователя и отправлять его на контроллер. Пульт дистанционного управления также обеспечивает кнопку «аварийная остановка!». Это должно немедленно задействовать тормоза с максимальной мощностью.
Тормозная мощность измеряется в значениях от 0 до 100, при этом 0 ничего не означает и 100 означает максимальную мощность тормоза. Команда «Аварийная остановка!» будет приниматься как отдельный вызов.



CarController
выдаст сообщение объекту Electronics
для активации тормозной системы. Контроллер автомобиля также может запросить StatusPanel
для получения информации о скорости, полученной с помощью датчиков на автомобиле.
Реализация с использованием чистого тестового шпиона
Давайте сначала реализуем чистый объект-шпион, не используя инфраструктуру моков PHPUnit. Это даст вам лучшее представление о концепции тестового шпиона. Начнем с проверки сигнатуры объекта Electronics
.
1 |
class Electronics { |
2 |
|
3 |
function turnOn(Lights $lights) {} |
4 |
function accelerate(){} |
5 |
function pushBrakes($brakingPower){} |
6 |
|
7 |
}
|
Нам интересен метод pushBrakes()
. Я не назвал его brake()
, чтобы избежать путаницы с ключевым словом break
в PHP.
Чтобы создать настоящего шпиона, мы расширим Electronics
и переопределим метод pushBrakes()
. Этот переопределенный метод не будет тормозить; вместо этого он регистрирует только тормозную мощность.
1 |
class SpyingElectronics extends Electronics { |
2 |
private $brakingPower; |
3 |
|
4 |
function pushBrakes($brakingPower) { |
5 |
$this->brakingPower = $brakingPower; |
6 |
}
|
7 |
|
8 |
function getBrakingPower() { |
9 |
return $this->brakingPower; |
10 |
}
|
11 |
}
|
Метод getBrakingPower()
дает нам возможность проверить мощность торможения в нашем тесте. Этот метод не будет использоваться в рабочем коде.
Теперь мы можем написать тест, способный проверить мощность торможения. Следуя принципам TDD, мы начнем с самого простого теста и обеспечим самую основную реализацию:
1 |
function testItCanStop() { |
2 |
$halfBrakingPower = 50; |
3 |
$electronicsSpy = new SpyingElectronics(); |
4 |
|
5 |
$carController = new CarController(); |
6 |
$carController->pushBrakes($halfBrakingPower, $electronicsSpy); |
7 |
|
8 |
$this->assertEquals($halfBrakingPower, $electronicsSpy->getBrakingPower()); |
9 |
}
|
Этот тест завершился неудачно, потому что у нас еще нет метода pushBrakes()
в CarController
. Давайте исправим это и напишем:
1 |
function pushBrakes($brakingPower, Electronics $electronics) { |
2 |
$electronics->pushBrakes($brakingPower); |
3 |
}
|
Тест теперь проходит, эффективно тестируя метод pushBrakes()
.
Мы также можем отслеживать вызовы методов. Тестирование класса StatusPanel
является следующим логическим шагом. Он предоставляет пользователю различные сведения о дистанционно управляемом автомобиле. Давайте напишем тест, который проверяет, запрашивается ли объект StatusPanel
о скорости автомобиля. Мы создадим для этого шпиона:
1 |
class SpyingStatusPanel extends StatusPanel { |
2 |
private $speedWasRequested = false; |
3 |
|
4 |
function getSpeed() { |
5 |
$this->speedWasRequested = true; |
6 |
}
|
7 |
|
8 |
function speedWasRequested() { |
9 |
return $this->speedWasRequested; |
10 |
}
|
11 |
}
|
Затем мы модифицируем наш тест для использования шпиона:
1 |
function testItCanStop() { |
2 |
$halfBrakingPower = 50; |
3 |
$electronicsSpy = new SpyingElectronics(); |
4 |
$statusPanelSpy = new SpyingStatusPanel(); |
5 |
|
6 |
$carController = new CarController(); |
7 |
$carController->pushBrakes($halfBrakingPower, $electronicsSpy, $statusPanelSpy); |
8 |
|
9 |
$this->assertEquals($halfBrakingPower, $electronicsSpy->getBrakingPower()); |
10 |
$this->assertTrue($statusPanelSpy->speedWasRequested()); |
11 |
}
|
Обратите внимание, что я не написал отдельный тест.
Рекомендация «одно утверждение за тест» хороша, но если ваш тест описывает действие, требующее нескольких шагов или состояний, допустимо использование нескольких утверждений в одном тесте.
Более того, это сохраняет все ваши утверждения об одной концепции в одном месте. Это помогает устранить дублирующий код, не требуя от вас повторной настройки тех же условий для вашего SUT.
И теперь сама реализация:
1 |
function pushBrakes($brakingPower, Electronics $electronics, StatusPanel $statusPanel = null) { |
2 |
$statusPanel = $statusPanel ? : new StatusPanel(); |
3 |
$electronics->pushBrakes($brakingPower); |
4 |
$statusPanel->getSpeed(); |
5 |
}
|
Там всего лишь маленькая крошечная вещь, которая меня беспокоит: имя этого теста - testItCanStop()
. Это явно означает, что мы нажимаем тормоза до тех пор, пока автомобиль не остановится. Мы, однако, называем метод pushBrakes()
, что не совсем корректно. Время для рефакторинга:
1 |
function stop($brakingPower, Electronics $electronics, StatusPanel $statusPanel = null) { |
2 |
$statusPanel = $statusPanel ? : new StatusPanel(); |
3 |
$electronics->pushBrakes($brakingPower); |
4 |
$statusPanel->getSpeed(); |
5 |
}
|
Не забудьте также изменить вызов метода в тесте.
1 |
$carController->stop($halfBrakingPower, $electronicsSpy, $statusPanelSpy); |
Косвенный вывод - это то, что мы не можем наблюдать напрямую.
На этом этапе нам нужно подумать о нашей тормозной системе и о том, как она работает. Существует несколько возможностей, но для этого примера предположим, что поставщик игрушечного автомобиля указал, что торможение происходит сдержанно. Вызов метода pushBreakes()
объекта Electronics
заставляет тормозить в течение некоторого времени, а затем отпускает его. Временной интервал для нас неважен, но давайте представим, что это часть секунды. С таким небольшим интервалом времени мы должны непрерывно посылать команды pushBrakes()
, пока скорость не станет равной нулю.
Шпионы по определению являются более способными заглушками, и при необходимости они могут также управлять косвенным вводом. Давайте сделаем наш шпион для StatusPanel
более способным и предложим определенное значение для скорости. Я думаю, что первый вызов должен обеспечить положительную скорость - допустим, значение 1
. Второй вызов обеспечит скорость 0
.
1 |
class SpyingStatusPanel extends StatusPanel { |
2 |
|
3 |
private $speedWasRequested = false; |
4 |
private $currentSpeed = 1; |
5 |
|
6 |
function getSpeed() { |
7 |
if ($this->speedWasRequested) $this->currentSpeed = 0; |
8 |
$this->speedWasRequested = true; |
9 |
return $this->currentSpeed; |
10 |
}
|
11 |
|
12 |
function speedWasRequested() { |
13 |
return $this->speedWasRequested; |
14 |
}
|
15 |
|
16 |
function spyOnSpeed() { |
17 |
return $this->currentSpeed; |
18 |
}
|
19 |
|
20 |
}
|
Переопределенный метод getSpeed()
возвращает соответствующее значение скорости с помощью метода spyOnSpeed()
. Добавим третье утверждение к нашему тесту:
1 |
function testItCanStop() { |
2 |
$halfBrakingPower = 50; |
3 |
$electronicsSpy = new SpyingElectronics(); |
4 |
$statusPanelSpy = new SpyingStatusPanel(); |
5 |
|
6 |
$carController = new CarController(); |
7 |
$carController->stop($halfBrakingPower, $electronicsSpy, $statusPanelSpy); |
8 |
|
9 |
$this->assertEquals($halfBrakingPower, $electronicsSpy->getBrakingPower()); |
10 |
$this->assertTrue($statusPanelSpy->speedWasRequested()); |
11 |
$this->assertEquals(0, $statusPanelSpy->spyOnSpeed()); |
12 |
}
|
Согласно последнему утверждению, скорость должна иметь значение 0
по завершению метода stop()
. Выполнение этого теста приводит к сбою с загадочным сообщением:
1 |
1) CarControllerTest::testItCanStop |
2 |
Failed asserting that 1 matches expected 0. |
Давайте добавим наше собственное сообщение об утверждении:
1 |
$this->assertEquals(0, $statusPanelSpy->spyOnSpeed(), |
2 |
'Expected speed to be 0 (zero) after stopping but it actually was ' . $statusPanelSpy->spyOnSpeed()); |
Это дает гораздо более читаемое сообщение об ошибке:
1 |
1) CarControllerTest::testItCanStop |
2 |
Expected speed to be 0 (zero) after stopping but it actually was 1 |
3 |
Failed asserting that 1 matches expected 0. |
Хватит уже ошибок! Давайте сделаем это.
1 |
function stop($brakingPower, Electronics $electronics, StatusPanel $statusPanel = null) { |
2 |
$statusPanel = $statusPanel ? : new StatusPanel(); |
3 |
$electronics->pushBrakes($brakingPower); |
4 |
if ($statusPanel->getSpeed()) $this->stop($brakingPower, $electronics, $statusPanel); |
5 |
}
|
Мне нравится рекурсия; всегда легче протестировать рекурсию, чем циклы. Более простое тестирование означает более простой код, который, в свою очередь, означает лучший алгоритм. Ознакомьтесь с Помещением приоритета трансформации для получения дополнительной информации по этому вопросу.
Возвращение к PHPUnit Mocking Framework
Хватит дополнительных классов. Давайте перепишем это с помощью мок фреймворка PHPUnit и исключим этих чистых шпионов. Зачем?
Поскольку PHPUnit предлагает лучший и простой мок-синтаксис, меньше кода и некоторые хорошие предопределенные методы.
Обычно я создаю чистых шпионов и заглушки только тогда, когда мокать их с помощью getMock()
будет еще более сложным. Если ваши классы настолько сложные, что getMock()
не может их обработать, тогда у вас есть проблема с вашим рабочим кодом - а не с вашими тестами.
1 |
function testItCanStop() { |
2 |
$halfBrakingPower = 50; |
3 |
$electronicsSpy = $this->getMock('Electronics'); |
4 |
$electronicsSpy->expects($this->exactly(2))->method('pushBrakes')->with($halfBrakingPower); |
5 |
$statusPanelSpy = $this->getMock('StatusPanel'); |
6 |
$statusPanelSpy->expects($this->at(0))->method('getSpeed')->will($this->returnValue(1)); |
7 |
$statusPanelSpy->expects($this->at(1))->method('getSpeed')->will($this->returnValue(0)); |
8 |
|
9 |
$carController = new CarController(); |
10 |
$carController->stop($halfBrakingPower, $electronicsSpy, $statusPanelSpy); |
11 |
}
|
Общее количество всех методов, public и private, доступных через публичные методы, представляет собой поведение объекта.
Построчное объяснение приведенного выше кода:
- установить половину мощности торможения = 50
- создать мок для
Electronics
- ожидание вызова метода
pushBrakes()
ровно два раза с указанной выше тормозной силой - создать мок
StatusPanel
- вернуть
1
при первом вызовеgetSpeed()
- вернуть
0
при втором выполненииgetSpeed()
- вызвать проверенный метод
stop()
на реальном объектеCarController
Вероятно, самое интересное в этом коде - метод $this->at($someValue)
. PHPUnit подсчитывает количество вызовов этого мока. Подсчет происходит на уровне мока; поэтому вызов нескольких методов в $statusPanelSpy
увеличит счетчик. Поначалу это может показаться немного интуитивным; поэтому давайте посмотрим на пример.
Предположим, мы хотим проверить уровень топлива на каждом вызове stop()
. Код будет выглядеть так:
1 |
function stop($brakingPower, Electronics $electronics, StatusPanel $statusPanel = null) { |
2 |
$statusPanel = $statusPanel ? : new StatusPanel(); |
3 |
$electronics->pushBrakes($brakingPower); |
4 |
$statusPanel->thereIsEnoughFuel(); |
5 |
if ($statusPanel->getSpeed()) $this->stop($brakingPower, $electronics, $statusPanel); |
6 |
}
|
Это сломает наш тест. Сперва может быть непонятно почему, но вы получите следующее сообщение:
1 |
1) CarControllerTest::testItCanStop |
2 |
Expectation failed for method name is equal to <string:pushBrakes> when invoked 2 time(s). |
3 |
Method was expected to be called 2 times, actually called 1 times. |
Совершенно очевидно, что pushBrakes()
следует вызывать два раза. Почему же мы получаем это сообщение? Из-за ожидания $this->at($someValue)
. Счетчик увеличивается следующим образом:
- первый вызов
stop()
-> первый вызовthereIsEnougFuel()
=> внутренний счетчик на0
- первый вызов
stop()
-> первый вызовgetSpeed()
=> внутренний счетчик в 1 и возвращает0
- второй вызов
stop()
никогда не произойдет => второй вызовgetSpeed()
никогда не происходит
Каждый вызов любого замоканного метода на $statusPanelSpy
увеличивает внутренний счетчик PHPUnit.
Шаг 9: Тестовый фейк
Если публичные методы сродни сообщениям, приватные методы похожи на мысли.
Тестовая подделка - это более простая реализация производственного кода. Это очень похожее определение тестовых заглушек. В действительности, Fakes и Stubs очень похожи по внешнему поведению. Оба являются объектами, имитирующими поведение некоторых других реальных объектов, и оба реализуют метод управления косвенным вводом. Разница в том, что подделки гораздо ближе к реальному объекту, чем к фиктивному объекту.
Стаб - это, в основном, фиктивный объект, методы которого возвращают предопределенные значения. Подделка, однако, выполняет полную реализацию реального объекта но более простым способом. Вероятно, наиболее распространенным примером является InMemoryDatabase
, чтобы идеально имитировать реальный класс базы данных, фактически не записывая в хранилище данных. Таким образом, тестирование становится быстрее.
Тестовые подделки не должны реализовывать какие-либо методы прямого управления входными данными или возвращать наблюдаемое состояние. Они не используются для вопросов; они используются для получения данных. Наиболее распространенные случаи использования подделок - это когда реальный зависимый компонент (DOC) еще не написан, он слишком медленный (например, база данных) или реальный DOC недоступен в тестовой среде.
Шаг 10: Выводы
Наиболее важной функциональностью является управление DOC. Это также обеспечивает отличный способ управления косвенным вводом-выводом с помощью техники инъекции зависимостей.
Есть два основных мнения насчет моков:
Некоторые говорят, что моки это плохо ...
- Некоторые говорят, что мокать нельзя, и они правы. Mocking делает что-то тонкое и уродливое: он связывает слишком много тестов с реализацией. Когда это возможно, тест должен быть как можно более независимым от реализации. Тестирование черного ящика всегда предпочтительнее для тестирования белых ящиков. Всегда проверяйте состояние, если можете; не мокайте поведение. Отказ от моков поощряет восходящую разработку и дизайн. Это означает, что сначала создаются небольшие составные части системы, а затем они объединяются в гармоничную структуру.
- Некоторые говорят, что мокать можно, и они правы. Mocking делает что-то тонкое и красивое; он определяет поведение. Это заставляет нас думать гораздо больше с точки зрения пользователя. Обычно при этом используют подход «сверху вниз» для внедрения и проектирования. Мы начинаем с самого верхнего класса в системе и записываем свой первый тест, мокая другие воображаемые DOC, которые еще не реализованы. Компоненты системы появляются и развиваются в зависимости от того, какие моки создаются на один уровень выше.
Куда двигаться - все зависит от вас.
Некоторые предпочитают использовать моки, в то время как другие предпочитают тестирование состояния. Каждый подход имеет свои плюсы и минусы. Система с моками предлагает дополнительную поведенческую информацию в тестах. Система с состояниями предлагает более подробную информацию о компонентах, но может также скрывать некоторые действия.