Писане на тестове

Писането на тестове за 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('The dimension must not be negative.');
		}
		$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());

Както виждате, т.нар. assertion методи като Assert::same() се използват за потвърждаване, че действителната стойност съответства на очакваната стойност.

Остава последната стъпка, а именно файлът bootstrap.php. Той съдържа код, общ за всички тестове, например автоматично зареждане на класове, конфигурация на средата, създаване на временна директория, помощни функции и подобни. Всички тестове зареждат bootstrap и по-нататък се занимават само с тестване. Bootstrap може да изглежда по следния начин:

<?php
require __DIR__ . '/vendor/autoload.php';   # зарежда Composer autoloader

Tester\Environment::setup();                # инициализация на Nette Tester

// и друга конфигурация (това е само пример, в нашия случай не са необходими)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');

Посоченият bootstrap предполага, че autoloader-ът на 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,
	'The dimension must not be negative.',
);

И подобен тест ще добавим за височината. Накрая ще тестваме дали 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,
	);
});

Ако трябва да стартирате код преди или след всеки тест, предайте го на функцията 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();

За тестване на изключения този път използвахме анотацията @throw. Повече ще научите в главата 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('Requires UNIX.');
}

Структура на директориите

Препоръчваме при малко по-големи библиотеки или проекти да разделите директорията с тестове още на поддиректории според пространството от имена на тествания клас:

└── tests/
	├── NamespaceOne/
	│   ├── MyClass.getUsers.phpt
	│   ├── MyClass.setUsers.phpt
	│   └── ...
	│
	├── NamespaceTwo/
	│   ├── MyClass.creating.phpt
	│   ├── MyClass.dropping.phpt
	│   └── ...
	│
	├── bootstrap.php
	└── ...

Така ще можете да стартирате тестове от едно единствено пространство от имена, т.е. поддиректория:

tester tests/NamespaceOne

Специални ситуации

Тест, който не извиква нито един assertion метод, е подозрителен и се оценява като грешен:

Error: This test forgets to execute an assertion.

Ако наистина тестът без извикване на assertions трябва да се счита за валиден, извикайте например Assert::true(true).

Също така може да бъде коварно да се използват exit() и die() за прекратяване на теста със съобщение за грешка. Например exit('Error in connection') прекратява теста с код на връщане 0, което сигнализира за успех. Използвайте Assert::fail('Error in connection').