Написание тестов

Написание тестов для 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').