Написание тестов
****************
.[perex]
Написание тестов для Nette Tester уникально тем, что каждый тест - это PHP-скрипт, который можно запустить самостоятельно. В этом заложен большой потенциал. Уже при написании теста вы можете просто запустить его и проверить, работает ли он правильно. Если нет, вы легко пройдёте по нему шагами в своей IDE и найдёте ошибку.
Тест можно даже открыть в браузере. Но самое главное: запуская его, вы выполняете тест. Вы сразу узнаёте, прошёл он или провалился.
Во вводной главе мы [показали |guide#Чем Tester уникален?] совсем тривиальный тест работы с массивом. Теперь создадим собственный класс, который будем тестировать, хотя он тоже будет простым.
Начнём с типичной структуры каталогов библиотеки или проекта. Важно отделить тесты от остального кода, например ради развёртывания, потому что тесты мы на боевой сервер загружать не хотим. Структура может выглядеть так:
```
├── src/ # код, который будем тестировать
│ ├── Rectangle.php
│ └── ...
├── tests/ # тесты
│ ├── bootstrap.php
│ ├── RectangleTest.php
│ └── ...
├── vendor/
└── composer.json
```
Теперь создадим отдельные файлы. Начнём с тестируемого класса и поместим его в файл `src/Rectangle.php`:
```php .{file:src/Rectangle.php}
<?php
class Rectangle
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
if ($width < 0 || $height < 0) {
throw new InvalidArgumentException('Размер не должен быть отрицательным.');
}
$this->width = $width;
$this->height = $height;
}
public function getArea(): float
{
return $this->width * $this->height;
}
public function isSquare(): bool
{
return $this->width === $this->height;
}
}
```
И создадим для него тест. Имя файла теста должно соответствовать маске `*Test.php` либо `*.phpt`; выберем вариант `RectangleTest.php`:
```php .{file:tests/RectangleTest.php}
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
// обычный прямоугольник
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea()); # проверяем ожидаемые результаты
Assert::false($rect->isSquare());
```
Как видите, для утверждения, что фактическое значение соответствует ожидаемому, используются [методы утверждений |assertions] вроде `Assert::same()`.
Последний шаг - файл `bootstrap.php`. Он содержит код, общий для всех тестов, например автозагрузку классов, настройку окружения, создание временного каталога, вспомогательные функции и тому подобное. Все тесты загружают bootstrap и затем занимаются только тестированием. Bootstrap может выглядеть так:
```php .{file:tests/bootstrap.php}
<?php
require __DIR__ . '/vendor/autoload.php'; # загружаем автозагрузчик Composer
Tester\Environment::setup(); # инициализация Nette Tester
// и другие настройки (просто пример, в нашем случае не нужен)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');
```
.[note]
Этот bootstrap предполагает, что автозагрузчик Composer сумеет загрузить и класс `Rectangle.php`. Этого можно добиться, например, [настройкой секции autoload |best-practices:composer#Автозагрузка] в `composer.json` и т. д.
Теперь мы можем запустить тест из командной строки как любой другой самостоятельный PHP-скрипт. Первый запуск выявит возможные синтаксические ошибки, и если опечаток нет, он выведет:
/--pre .[terminal]
$ php RectangleTest.php
<span style="color:#FFF; background-color:#090">OK</span>
\--
Если мы изменим утверждение в тесте на неверное, например `Assert::same(123, $rect->getArea());`, произойдёт вот что:
/--pre .[terminal]
$ php RectangleTest.php
<span style="color: #FFF">Failed: </span><span style="color: #FF0">200.0</span><span style="color: #FFF"> should be </span><span style="color: #FF0">123</span>
<span style="color: #CCC">in </span><span style="color: #FFF">RectangleTest.php(5)</span><span style="color: #808080"> Assert::same(123, $rect->getArea());</span>
<span style="color: #FFF; background-color: #900">FAILURE</span>
\--
При написании тестов хорошо покрывать все крайние случаи. Например, ввод вроде нуля, отрицательных чисел, а в других ситуациях пустых строк, null и т. п. Это, по сути, заставляет вас подумать и решить, как код в таких ситуациях должен себя вести. Тесты затем это поведение закрепляют.
В нашем случае отрицательное значение должно выбросить исключение, что мы проверяем через [Assert::exception() |Assertions#Assert::exception()]:
```php .{file:tests/RectangleTest.php}
// ширина не должна быть отрицательной
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
'Размер не должен быть отрицательным.',
);
```
И добавим похожий тест для высоты. Наконец, проверим, что `isSquare()` возвращает `true`, если оба размера одинаковы. Попробуйте написать такие тесты в качестве упражнения.
Хорошо организованные тесты
===========================
Размер файла теста может расти, и он быстро становится неудобным. Поэтому практично сгруппировать отдельные тестируемые области в отдельные функции.
Сначала посмотрим на более простой, но элегантный вариант с глобальной функцией `test()`. Tester не создаёт эту функцию автоматически, чтобы не возникло конфликта, если у вас в коде есть функция с тем же именем. Её создаёт метод `setupFunctions()`, который вы вызовете в своём файле `bootstrap.php`:
```php .{file:tests/bootstrap.php}
Tester\Environment::setup();
Tester\Environment::setupFunctions();
```
С помощью этой функции мы можем красиво разбить файл теста на именованные части. При выполнении подписи будут выводиться по порядку.
```php .{file:tests/RectangleTest.php}
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
test('обычный прямоугольник', function () {
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());
Assert::false($rect->isSquare());
});
test('обычный квадрат', function () {
$rect = new Rectangle(5, 5);
Assert::same(25.0, $rect->getArea());
Assert::true($rect->isSquare());
});
test('размеры не должны быть отрицательными', function () {
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
);
Assert::exception(
fn() => new Rectangle(10, -1),
InvalidArgumentException::class,
);
});
```
Если вам нужно выполнить код перед каждым `test()` или после него, передайте его соответственно в функцию `setUp()` или `tearDown()`:
```php
setUp(function () {
// код инициализации, который выполняется перед каждым test()
});
```
Второй вариант - объектный. Мы создаём так называемый TestCase, то есть класс, в котором отдельные части представлены методами, имена которых начинаются на `test`.
```php .{file:tests/RectangleTest.php}
class RectangleTest extends Tester\TestCase
{
public function testGeneralOblong()
{
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());
Assert::false($rect->isSquare());
}
public function testGeneralSquare()
{
$rect = new Rectangle(5, 5);
Assert::same(25.0, $rect->getArea());
Assert::true($rect->isSquare());
}
/** @throws InvalidArgumentException */
public function testWidthMustNotBeNegative()
{
$rect = new Rectangle(-1, 20);
}
/** @throws InvalidArgumentException */
public function testHeightMustNotBeNegative()
{
$rect = new Rectangle(10, -1);
}
}
// Запускаем тестовые методы
(new RectangleTest)->run();
```
На этот раз для проверки исключений мы использовали аннотацию `@throws`. Подробнее вы узнаете в главе [TestCase |TestCase].
Вспомогательные функции
=======================
Nette Tester содержит несколько классов и функций, которые могут облегчить тестирование, например тестирование содержимого HTML-документа, тестирование функций, работающих с файлами, и так далее.
Их описание вы найдёте на странице [Вспомогательные классы |helpers].
Аннотации и пропуск тестов
==========================
На выполнение теста могут повлиять аннотации в phpDoc-комментарии в начале файла. Например, это может выглядеть так:
```php .{file:tests/RectangleTest.php}
/**
* @phpExtension pdo, pdo_pgsql
* @phpVersion >= 7.2
*/
```
Приведённые аннотации говорят, что тест должен запускаться только с PHP версии 7.2 или новее и только если присутствуют PHP-расширения `pdo` и `pdo_pgsql`. Эти аннотации истолковывает [запускатель тестов из командной строки |running-tests], который при невыполнении условий тест пропустит и в выводе пометит буквой `s` (skipped). Однако при ручном запуске теста эти аннотации никак не действуют.
Описание аннотаций вы найдёте на странице [Аннотации тестов |test-annotations].
Тест можно пропустить и по собственному условию с помощью `Environment::skip()`. Например, так тест пропускается в Windows:
```php
if (defined('PHP_WINDOWS_VERSION_BUILD')) {
Tester\Environment::skip('Требуется UNIX.');
}
```
Структура каталогов
===================
Для библиотек или проектов хоть сколько-нибудь крупнее мы рекомендуем разделить каталог тестов на подкаталоги по пространству имён тестируемого класса:
```
└── tests/
├── NamespaceOne/
│ ├── MyClass.getUsers.phpt
│ ├── MyClass.setUsers.phpt
│ └── ...
│
├── NamespaceTwo/
│ ├── MyClass.creating.phpt
│ ├── MyClass.dropping.phpt
│ └── ...
│
├── bootstrap.php
└── ...
```
Это позволяет запускать тесты из одного пространства имён, то есть подкаталога:
/--pre .[terminal]
tester tests/NamespaceOne
\--
Особые ситуации
===============
Тест, который не вызывает ни одного метода утверждения, считается подозрительным и будет оценён как ошибка:
/--pre .[terminal]
<span style="color: #FFF; background-color: #900">Error: This test forgets to execute an assertion.</span>
\--
Если тест без утверждений действительно правомерен, вызовите `Assert::true(true)`, чтобы пометить его как таковой.
Использование `exit()` или `die()` для завершения теста с сообщением об ошибке может ввести в заблуждение. Например, `exit('Error in connection')` завершает тест с кодом выхода 0, что означает успех. Используйте вместо этого `Assert::fail('Error in connection')`.
Написание тестов
Написание тестов для Nette Tester уникально тем, что каждый тест –
это PHP-скрипт, который можно запустить самостоятельно. В этом заложен
большой потенциал. Уже при написании теста вы можете просто запустить
его и проверить, работает ли он правильно. Если нет, вы легко пройдёте
по нему шагами в своей IDE и найдёте ошибку.
Тест можно даже открыть в браузере. Но самое главное: запуская его, вы
выполняете тест. Вы сразу узнаёте, прошёл он или провалился.
Во вводной главе мы показали
совсем тривиальный тест работы с массивом. Теперь создадим
собственный класс, который будем тестировать, хотя он тоже будет
простым.
Начнём с типичной структуры каталогов библиотеки или проекта. Важно
отделить тесты от остального кода, например ради развёртывания, потому
что тесты мы на боевой сервер загружать не хотим. Структура может
выглядеть так:
├── src/ # код, который будем тестировать
│ ├── Rectangle.php
│ └── ...
├── tests/ # тесты
│ ├── bootstrap.php
│ ├── RectangleTest.php
│ └── ...
├── vendor/
└── composer.json
Теперь создадим отдельные файлы. Начнём с тестируемого класса и
поместим его в файл src/Rectangle.php:
<?php
class Rectangle
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
if ($width < 0 || $height < 0) {
throw new InvalidArgumentException('Размер не должен быть отрицательным.');
}
$this->width = $width;
$this->height = $height;
}
public function getArea(): float
{
return $this->width * $this->height;
}
public function isSquare(): bool
{
return $this->width === $this->height;
}
}
И создадим для него тест. Имя файла теста должно соответствовать
маске *Test.php либо *.phpt; выберем вариант RectangleTest.php:
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
// обычный прямоугольник
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea()); # проверяем ожидаемые результаты
Assert::false($rect->isSquare());
Как видите, для утверждения, что фактическое значение соответствует
ожидаемому, используются методы
утверждений вроде Assert::same().
Последний шаг – файл bootstrap.php. Он содержит код, общий для всех
тестов, например автозагрузку классов, настройку окружения, создание
временного каталога, вспомогательные функции и тому подобное. Все
тесты загружают bootstrap и затем занимаются только тестированием. Bootstrap
может выглядеть так:
<?php
require __DIR__ . '/vendor/autoload.php'; # загружаем автозагрузчик Composer
Tester\Environment::setup(); # инициализация Nette Tester
// и другие настройки (просто пример, в нашем случае не нужен)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');
Этот bootstrap предполагает, что автозагрузчик Composer сумеет
загрузить и класс Rectangle.php. Этого можно добиться, например, настройкой секции autoload в
composer.json и т. д.
Теперь мы можем запустить тест из командной строки как любой другой
самостоятельный PHP-скрипт. Первый запуск выявит возможные
синтаксические ошибки, и если опечаток нет, он выведет:
$ php RectangleTest.php
OK
Если мы изменим утверждение в тесте на неверное, например
Assert::same(123, $rect->getArea());, произойдёт вот что:
$ php RectangleTest.php
Failed: 200.0 should be 123
in RectangleTest.php(5) Assert::same(123, $rect->getArea());
FAILURE
При написании тестов хорошо покрывать все крайние случаи. Например,
ввод вроде нуля, отрицательных чисел, а в других ситуациях пустых
строк, null и т. п. Это, по сути, заставляет вас подумать и решить, как код в
таких ситуациях должен себя вести. Тесты затем это поведение
закрепляют.
В нашем случае отрицательное значение должно выбросить исключение,
что мы проверяем через Assert::exception():
// ширина не должна быть отрицательной
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
'Размер не должен быть отрицательным.',
);
И добавим похожий тест для высоты. Наконец, проверим, что isSquare()
возвращает true, если оба размера одинаковы. Попробуйте написать
такие тесты в качестве упражнения.
Хорошо организованные тесты
Размер файла теста может расти, и он быстро становится неудобным.
Поэтому практично сгруппировать отдельные тестируемые области в
отдельные функции.
Сначала посмотрим на более простой, но элегантный вариант с
глобальной функцией test(). Tester не создаёт эту функцию
автоматически, чтобы не возникло конфликта, если у вас в коде есть
функция с тем же именем. Её создаёт метод setupFunctions(), который вы
вызовете в своём файле bootstrap.php:
Tester\Environment::setup();
Tester\Environment::setupFunctions();
С помощью этой функции мы можем красиво разбить файл теста на
именованные части. При выполнении подписи будут выводиться по
порядку.
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
test('обычный прямоугольник', function () {
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());
Assert::false($rect->isSquare());
});
test('обычный квадрат', function () {
$rect = new Rectangle(5, 5);
Assert::same(25.0, $rect->getArea());
Assert::true($rect->isSquare());
});
test('размеры не должны быть отрицательными', function () {
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
);
Assert::exception(
fn() => new Rectangle(10, -1),
InvalidArgumentException::class,
);
});
Если вам нужно выполнить код перед каждым test() или после него,
передайте его соответственно в функцию setUp() или tearDown():
setUp(function () {
// код инициализации, который выполняется перед каждым test()
});
Второй вариант – объектный. Мы создаём так называемый TestCase, то есть
класс, в котором отдельные части представлены методами, имена которых
начинаются на test.
class RectangleTest extends Tester\TestCase
{
public function testGeneralOblong()
{
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());
Assert::false($rect->isSquare());
}
public function testGeneralSquare()
{
$rect = new Rectangle(5, 5);
Assert::same(25.0, $rect->getArea());
Assert::true($rect->isSquare());
}
/** @throws InvalidArgumentException */
public function testWidthMustNotBeNegative()
{
$rect = new Rectangle(-1, 20);
}
/** @throws InvalidArgumentException */
public function testHeightMustNotBeNegative()
{
$rect = new Rectangle(10, -1);
}
}
// Запускаем тестовые методы
(new RectangleTest)->run();
На этот раз для проверки исключений мы использовали аннотацию
@throws. Подробнее вы узнаете в главе TestCase.
Вспомогательные функции
Nette Tester содержит несколько классов и функций, которые могут облегчить
тестирование, например тестирование содержимого HTML-документа,
тестирование функций, работающих с файлами, и так далее.
Их описание вы найдёте на странице Вспомогательные классы.
Аннотации и пропуск тестов
На выполнение теста могут повлиять аннотации в phpDoc-комментарии в
начале файла. Например, это может выглядеть так:
/**
* @phpExtension pdo, pdo_pgsql
* @phpVersion >= 7.2
*/
Приведённые аннотации говорят, что тест должен запускаться только с
PHP версии 7.2 или новее и только если присутствуют PHP-расширения
pdo и pdo_pgsql. Эти аннотации истолковывает запускатель тестов из командной строки,
который при невыполнении условий тест пропустит и в выводе пометит
буквой s (skipped). Однако при ручном запуске теста эти аннотации
никак не действуют.
Описание аннотаций вы найдёте на странице Аннотации тестов.
Тест можно пропустить и по собственному условию с помощью
Environment::skip(). Например, так тест пропускается в Windows:
if (defined('PHP_WINDOWS_VERSION_BUILD')) {
Tester\Environment::skip('Требуется UNIX.');
}
Структура каталогов
Для библиотек или проектов хоть сколько-нибудь крупнее мы
рекомендуем разделить каталог тестов на подкаталоги по пространству
имён тестируемого класса:
└── tests/
├── NamespaceOne/
│ ├── MyClass.getUsers.phpt
│ ├── MyClass.setUsers.phpt
│ └── ...
│
├── NamespaceTwo/
│ ├── MyClass.creating.phpt
│ ├── MyClass.dropping.phpt
│ └── ...
│
├── bootstrap.php
└── ...
Это позволяет запускать тесты из одного пространства имён, то есть
подкаталога:
tester tests/NamespaceOne
Особые ситуации
Тест, который не вызывает ни одного метода утверждения, считается
подозрительным и будет оценён как ошибка:
Error: This test forgets to execute an assertion.
Если тест без утверждений действительно правомерен, вызовите
Assert::true(true), чтобы пометить его как таковой.
Использование exit() или die() для завершения теста с
сообщением об ошибке может ввести в заблуждение. Например,
exit('Error in connection') завершает тест с кодом выхода 0, что означает
успех. Используйте вместо этого Assert::fail('Error in connection').