
- Установка PHPUnit.
- Основы PHPUnit - 1 часть.
- Основы PHPUnit - 2 часть.
- PHPUnit - тестирование исключений, анализ покрытия кода тестами.
Юнит-тестирование - это тестирование в изоляции, когда класс тестируется отдельно от остального кода (других классов). В более-менее серьезном WEB-приложении, одни классы часто взаимодействуют с другими классами. Например класс работающий с пользователями приложения взаимодействует с базой данных для получения списка пользователей, а так же может взаимодействовать с классом осуществляющим проверку при авторизации пользователя и тд.
Если мы, например, решили протестировать работу метода newUser(), который в т.ч. получает данные пользователя из базы данных, то зачем нам взаимодействовать с классом для подключения к базе данных?!! Это же только тест, данные из БД нас не интересуют, причем тест самого метода newUser(), а не данных, которые мы получим из БД. Нас вообще, на данном этапе, не интересует класс подключения к БД, пусть им занимаются другие тесты. А что, если класс отвечающий за получение данных из БД так же взаимодействует с какими-то классами, а те с еще какими-то... Во-первых это снижает скорость проведения тестирования, а во-вторых, если при выполнении теста, в каком-то из связанных классов произойдет сбой, тест будет не пройден и мы не узнаем из-за чего, т.к. все связано между собой. Зачем такие тесты? Так и вручную можно "протестировать".
Вот для того, чтобы избавиться от выполнения методов сторонних классов в тестируемом методе и создаются имитирующие (mock) объекты, которые заменяют лишние классы. Понять будет проще на примере.
Будем тестировать вымышленный класс DataUser, который я написал для демонстрации работы с Mock-объектами – имитациями. Мок-объекты позволяют тестировать классы в изоляции от внешних зависимостей (других классов/объектов).
При создании примеров использовался автозагрузчик классов Composer, с помощью которого устанавливал так же и фреймворк для тестирования «PhpUnit». Это позволяет подключать классы приложения автоматически из тестового класса.
Файл app\DataUser.php
<?php namespace app; class DataUser { public function newUser(array $data){ $user = $this->getInstance($data); if ($this->checkUser($user)){ $user->password = md5(uniqid($user->name)); return $user; } return false; } protected function getInstance($data){ return new User($data); } protected function checkUser($user){ if($user->verify()){ return true; } return false; } }
Задача: протестировать публичный метод newUser() данного класса.
В методах тестируемого класса DataUser создается объект класса User и используются его свойства/методы.
Например данный объект выглядит так, файл app\User.php
<?php namespace app; class User { public $name; public $email; public $password; public function __construct($data) { $this->name = $data['name']; $this->email = $data['email']; } public function verify(){ if ($this->name && $this->email){ return true; } return false; } }
Если есть желание, можете проверить работу данных классов в WEB:
$dataUser = new DataUser(); $data = ['name' => 'Serj', 'email' => '1234']; $result = $dataUser->newUser($data); var_dump ($result);
Файл тестов tests\DataUserTest.php
<?php namespace tests; use PHPUnit\Framework\TestCase; use app\User; use app\DataUser; class DataUserTest extends TestCase { protected $obj; /* * Тестируем метод NewUser, должен вернуть объект класса User */ public function testNewUserReturnTrue() { /* * Создаем (мокаем) объект аналогичный User, т.к. он создается в методе * getInstance текущего класса и далее используется. * Все его методы будут заглушками - возвращать NULL. */ $user = $this->getMockBuilder(User::class) ->disableOriginalConstructor() //Отключаем конструктор т.к. не нужен ->getMock(); /* * Переопределяем возвращаемое значение метода verify() с NULL на TRUE, * т.к. данный метод будет использоваться в тестируемом методе. */ $user->expects($this->once()) ->method('verify') ->will($this->returnValue(true)); /* * Создаем (мокаем) объект аналогичный DataUser т.к. нужно * переопределить метод getInstance. Он будет всегда возвращать объект User. */ $DataUser = $this->getMockBuilder(DataUser::class) /* * Указываем методы на которые ставим заглушки. По-умолчанию будут возвращать * NULL, а так же их можно потом переопределить */ ->setMethods(['getInstance']) ->getMock(); /* * Меняем возвращаемое значение у метода getInstance. * Будет всегда возвращен объект из переменной $user * т.к. дальше в коде используется его метод verify() */ $DataUser->expects($this->once()) ->method('getInstance') ->will($this->returnValue($user)); /* * Проверяем, принадлежит ли возвращаемый тестируемым методом объект * классу User */ $this->assertTrue(is_a($DataUser->newUser([]), User::class)); } /* * Тестируем метод NewUser, должен вернуть FALSE */ public function testNewUserReturnFalse() { /* * Изменяем метод checkUser (ставим заглушку), чтобы он возвращал NULL * Проверка if ($this->checkUser($user)) не выполнится и метод вернет FALSE */ $DataUser = $this->getMockBuilder(DataUser::class) ->setMethods(['getInstance', 'checkUser']) ->getMock(); $this->assertFalse($DataUser->newUser([])); } }
Описание теста.
1) Тестируем метод newUser() на возвращаемое значение «true».
Видим, что в тестируемом методе newUser() вызывается метод getInstance():
$this->getInstanceдля получения объекта User.
Тут следует заметить, что если бы получение данного объекта не было вынесено в отдельный метод, то тестирование метода newUser было бы как минимум затруднительно, а скорее невозможно. Т.к. выполнение теста потянуло бы не только создание объекта User, но и передачу аргументов в конструктор и, возможно, выполнение других методов вплодь до взаимодействия с базой данных. А смысл unit-тестирования заключается в выполнении отдельных блоков тестов, где тестируется нужный метод без его связи с отдельными классами.
Поэтому никогда не следует писать так:
public function newUser(array $data){ $user = new User($data); if ($this->checkUser($user)){ …
Так же можно использовать внедрение зависимостей, где создание объекта происходит не в коде метода, а передается ему (или методу конструктору) с аргументами:
public function newUser(app\User $user,array $data){ if ($this->checkUser($user)){ …
Итак, в методе getInstance() создается объект стороннего класса – User, который нас не интересует, но дальнейший код тестируемого метода зависит от объекта User, в частности использует его свойства, а так же метод verify(). Поэтому создаем для User имитирующий его объект (мок-объект):
$user = $this->getMockBuilder(User::class) ->disableOriginalConstructor() //Отключаем конструктор т.к. не нужен ->getMock();Т.к. по-умолчанию все методы данного имитирующего объекта будут возвращать NULL, нужно переопределить методы, которые нам нужны, а именно – метод verify:
$user->expects($this->any()) ->method('verify') ->will($this->returnValue(true));
Метод expects() принимает аргумент: число раз, которое указанный метод должен быть вызван в коде. Т.е. мы указываем что метод verify() должен быть вызван 1 раз и должен вернуть true.
Таким образом мы создали имитацию объекта User, которая не зависит от оригинального объекта и будем использовать ее в тесте. Сначала нужно подменить метод getInstance тестируемого класса. Для этой цели «мокаем» уже наш тестируемый класс:
$DataUser = $this->getMockBuilder(\app\DataUser::class) ->setMethods(['getInstance']) ->getMock();
Используя метод setMethods, мы указываем, что заглушку нужно ставить только на метод 'getInstance', а все остальные методы будут выполнять их исходный код. Далее мы переопределяем метод 'getInstance':
$DataUser->expects($this->once()) ->method('getInstance') ->will($this->returnValue($user));делаем, чтобы он возвращал созданный ранее мок-объект User.
Итак, в коде тестируемого метода получаем наш модифицированный объект:
$user = $this->getInstance($data);
Далее выполняется метод checkUser из условия:
if ($this->checkUser($user)){Сам метод
protected function checkUser($user){ if($user->verify()){ return true; } return false; }у нас всегда возвращает true, т.к. мы переопределили соответствующим образом метод verify объекта User.
Т.к. проверка будет пройдена, выполнится строка
$user->password = md5(uniqid($user->name));и метод должен вернуть объект класса User. Это мы и проверяем в тесте:
$this->assertTrue(is_a($DataUser->newUser([]), User::class));При этом методу newUser я передаю пустой массив, т.к. метод требует в качестве аргументов массив, но данные аргументы далее должны использоваться при вызове метода getInstance, который мы переопределили и они нам оказались не нужны.
В итоге, попутно, мы протестировали работу закрытого метода checkUser на возвращаемое значение «true».
2) Тестируем метод newUser на возвращаемое значение «false».
Метод testNewUserReturnFalse() тестирует выполнение метода newUser() на false. Для того, чтобы метод newUser вернул данное булево значение, у нас должна не пройти проверка
if ($this->checkUser($user)){
Выходит, что нет смысла вообще вызывать метод checkUser, все равно нам нужно получить false в проверке:
if(false)
В методе checkUser() используется объект стороннего класса – User. А так как нам данный метод не нужен, то и сам объект User не нужно создавать, ведь он в тестируемом классе больше нигде и не используется. Поэтому «мокаем» наш тестируемый класс newUser, создаем заглушку на метод checkUser(), а заодно и на метод getInstance(), ведь нужно оградить сторонний класс User от нашего метода:
$DataUser = $this->getMockBuilder(\app\DataUser::class) ->setMethods(['getInstance', 'checkUser']) ->getMock();
В следующей строке теста:
$this->assertFalse($DataUser->newUser([]));выполняется наш тестируемый метод newUser(), которому передаем в качестве аргумента пустой массив (т.к. метод требует передачи аргументов).
Начинается выполнение метода, в переменную $user
$user = $this->getInstance($data);сохраняется значение NULL, которое возвращает заглушка.
Далее не проходит условие:
if ($this->checkUser($user)){т.к. метод checkUser возвращает NULL по той же причине. В результате тестируемый метод возвращает false, что мы и сравниваем с аналогичным значением в строке теста:
$this->assertFalse($DataUser->newUser([]));
Дополнения.
И еще один реальный пример теста с использованием имитирующего объекта моего расширения для Yii2 по созданию мультиязычного приложения. Его ссылка на GitHub.
Задача стояла такая - нужно было создать имитирующий объект класса ListWidget, который нужно протестировать, что бы все методы класса выполняли свои функции кроме одного 'createLink', который нужно переопределить т.к. он лез глубоко в дебри приложения Yii2.
Реализация:
$mockListWidget = $this->getMockBuilder(ListWidget::class) ->disableOriginalConstructor() //метод будет возвращать Null если не переопределить ->setMethods(['createLink']) ->getMock(); $mockListWidget //метод отработает минимум 1 раз ->expects($this->atLeastOnce()) //переопределяем возвращаемое значение этого метода ->method('createLink') ->will($this->returnValue('http://site.com/en'));
Используемые методы:
getMockBuilder() - используется для создания любых имитирующих объектов (мок-объектов) указанных пользователем.
getMock() - возвращает этот имитирующий объект.
expects() - говорит, что указанный метод имитирующего объекта будет вызываться указанное число раз.
Возможные значения:
any - если не важно количество вызовов той или иной функции/метода
never - если метод не должен отработать
atLeastOnce - метод отработает не менее одно раза
once - метод выполняется только один раз
exactly - метод выполняется ровно столько раз, сколько мы указали
at - метод отработает не менее указанного количества раз
method() - определяет, какой метод будет вызван.
will() - устанавливает возвращаемое значение методом.
setMethods()
1) Если не используем setMethods():
$user = $this->getMockBuilder(User::class)->getMock();создается имитирующий объект, в котором методы:
- все являются заглушками,
- все возвращают значение null по умолчанию,
- легко переопределяемы.
2) Вы можете задать пустой массив для setMethods():
$user = $this->getMockBuilder(User::class) ->setMethods(array()) ->getMock();Создается точно такой же имитирующий объект, как если бы вы не вызывали setMethods(). Методы:
- все являются заглушками,
- все возвращают значение null по умолчанию,
- легко переопределяемы.
3) Вы также можете задать значение null:
$user = $this->getMockBuilder(User::class) ->setMethods(null) ->getMock();Создается имитирующий объект, в котором методы:
- все являются имитирующими,
- когда вызываются, выполняют реальный код, содержащийся внутри метода
- не позволяют вам переопределять возвращаемое значение
4) Задание массива, содержащего имена методов:
$user= $this->getMockBuilder(User::class) ->setMethods(['getInstance', 'checkUser']) ->getMock();Создается имитирующий объект, чьи методы представляют собой комбинацию первых трех сценариев.
Методы, которые вы определили:
- все являются заглушками,
- все возвращают значение null по умолчанию,
- легко переопределяемы.
Методы, которые вы не определили:
- все являются имитирующими,
- когда вызываются, выполняют оригинальный код, содержащийся внутри метода,
- не позволяют переопределять возвращаемое значение.
Это означает, что в имитирующем объекте $authorizeNet методы ::getInstance() и ::checkUser() будут возвращать значение null или вы можете переопределить их возвращаемые значения, но любой метод внутри этого класса, отличный от двух вышеназванных методов, будет выполнять оригинальный код.
При создании имитирующего объекта переопределяя нужный метод, можно передать ему параметры с помощью метода with:
$this->getMock('MyClass', array('myMethod')) ->expects($this->once()) ->method('myMethod'); ->with($this->equalTo(42), $this->equalTo('hello'));тут в качестве первого аргумента передается число 42, в качестве второго – строка 'hello'.
С помощью $this->equalTo() уточняется тип аргумента. Например $this->equalTo(42) означает, что аргумент должен быть равен указанному значению.
Для уточнения аргументов так же используются:
anything()
contains($value)
arrayHasKey($key)
equalTo($value, $delta, $maxDepth)
classHasAttribute($attribute)
greaterThan($value)
isInstanceOf($className)
isType($type)
matchesRegularExpression($pattern)
stringContains($string, $case)