Pisanie testów
Pisanie testów dla Nette Testera jest wyjątkowe, bo każdy test to skrypt PHP, który można uruchomić samodzielnie. Kryje to w sobie duży potencjał. Pisząc test, możesz po prostu go uruchomić i sprawdzić, czy działa poprawnie. Jeśli nie, możesz łatwo przejść go krokowo w swoim IDE i znaleźć błąd.
Test możesz nawet otworzyć w przeglądarce. Ale co najważniejsze, uruchamiając go, wykonujesz test. Od razu dowiadujesz się, czy przeszedł, czy nie.
W rozdziale wprowadzającym pokazaliśmy bardzo trywialny test pracy z tablicą. Teraz utworzymy własną klasę do testowania, choć również będzie prosta.
Zacznijmy od typowej struktury katalogów biblioteki albo projektu. Ważne jest oddzielenie testów od reszty kodu, na przykład na potrzeby wdrożenia, bo nie chcemy wgrywać testów na serwer produkcyjny. Struktura może wyglądać tak:
├── src/ # kod, który będziemy testować
│ ├── Rectangle.php
│ └── ...
├── tests/ # testy
│ ├── bootstrap.php
│ ├── RectangleTest.php
│ └── ...
├── vendor/
└── composer.json
Utwórzmy teraz poszczególne pliki. Zaczniemy od testowanej klasy, którą umieścimy w pliku
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;
}
}
I utworzymy dla niej test. Nazwa pliku testu powinna pasować do wzorca *Test.php albo *.phpt;
wybierzemy wariant RectangleTest.php:
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
// zwykły prostokąt
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea()); # weryfikujemy oczekiwane wyniki
Assert::false($rect->isSquare());
Jak widzisz, do potwierdzenia, że rzeczywista wartość odpowiada wartości oczekiwanej, służą metody asercji takie jak Assert::same().
Ostatnim krokiem jest plik bootstrap.php. Zawiera kod wspólny dla wszystkich testów, na przykład autoloading
klas, konfigurację środowiska, utworzenie katalogu tymczasowego, funkcje pomocnicze i tym podobne. Wszystkie testy wczytują
bootstrap, a potem skupiają się wyłącznie na testowaniu. Bootstrap może wyglądać tak:
<?php
require __DIR__ . '/vendor/autoload.php'; # wczytujemy autoloader Composera
Tester\Environment::setup(); # inicjalizacja Nette Testera
// i dalsze konfiguracje (tylko przykład, w naszym przypadku niepotrzebne)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');
Ten bootstrap zakłada, że autoloader Composera będzie potrafił wczytać także klasę
Rectangle.php. Można to osiągnąć na przykład przez ustawienie sekcji autoload w
composer.json itd.
Test możemy teraz uruchomić z wiersza poleceń jak każdy inny samodzielny skrypt PHP. Pierwsze uruchomienie ujawni ewentualne błędy składniowe, a jeśli nie ma literówek, wypisze:
$ php RectangleTest.php
OK
Jeśli zmienimy asercję w teście na coś nieprawidłowego, jak Assert::same(123, $rect->getArea());, stanie
się to:
$ php RectangleTest.php Failed: 200.0 should be 123 in RectangleTest.php(5) Assert::same(123, $rect->getArea()); FAILURE
Pisząc testy, dobrą praktyką jest pokrycie wszystkich przypadków skrajnych. Na przykład wejść takich jak zero, liczby ujemne, a w innych scenariuszach puste ciągi, null itd. To właściwie zmusza Cię do przemyślenia i zdecydowania, jak kod ma się w takich sytuacjach zachowywać. Testy potem to zachowanie utrwalają.
W naszym przypadku wartość ujemna powinna rzucić wyjątek, co weryfikujemy przez Assert::exception():
// szerokość nie może być ujemna
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
'The dimension must not be negative.',
);
I dodajemy podobny test dla wysokości. Na koniec testujemy, że isSquare() zwraca true, jeśli oba
wymiary są takie same. Spróbuj napisać takie testy jako ćwiczenie.
Przejrzyste testy
Rozmiar pliku testu może rosnąć i szybko stać się nieprzejrzysty. Dlatego praktyczne jest grupowanie poszczególnych testowanych obszarów w osobne funkcje.
Najpierw spójrzmy na prostszy, a zarazem elegancki wariant z globalną funkcją test(). Tester nie tworzy tej
funkcji automatycznie, żeby uniknąć kolizji, gdybyś miał w swoim kodzie funkcję o tej samej nazwie. Tworzy ją metoda
setupFunctions(), którą powinieneś wywołać w swoim pliku bootstrap.php:
Tester\Environment::setup();
Tester\Environment::setupFunctions();
Dzięki tej funkcji możemy ładnie ustrukturyzować plik testu w nazwane jednostki. Przy wykonaniu etykiety będą wypisywane kolejno.
<?php
use Tester\Assert;
require __DIR__ . '/bootstrap.php';
test('general rectangle', function () {
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());
Assert::false($rect->isSquare());
});
test('general square', function () {
$rect = new Rectangle(5, 5);
Assert::same(25.0, $rect->getArea());
Assert::true($rect->isSquare());
});
test('dimensions must not be negative', function () {
Assert::exception(
fn() => new Rectangle(-1, 20),
InvalidArgumentException::class,
);
Assert::exception(
fn() => new Rectangle(10, -1),
InvalidArgumentException::class,
);
});
Jeśli potrzebujesz uruchomić kod przed każdym test() albo po nim, przekaż go odpowiednio funkcji
setUp() albo tearDown():
setUp(function () {
// kod inicjalizacyjny wykonywany przed każdym test()
});
Drugi wariant jest obiektowy. Tworzymy tak zwany TestCase, czyli klasę, w której poszczególne jednostki reprezentują metody
o nazwach zaczynających się od 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);
}
}
// Uruchomienie metod testowych
(new RectangleTest)->run();
Tym razem do testowania wyjątków użyliśmy adnotacji @throws. Więcej dowiesz się w rozdziale TestCase.
Funkcje pomocnicze
Nette Tester zawiera kilka klas i funkcji, które mogą ułatwić testowanie, na przykład testowanie zawartości dokumentu HTML, testowanie funkcji pracujących z plikami i tak dalej.
Ich opis znajdziesz na stronie Klasy pomocnicze.
Adnotacje i pomijanie testów
Na wykonanie testu mogą wpływać adnotacje w komentarzu phpDoc na początku pliku. Może to wyglądać na przykład tak:
/**
* @phpExtension pdo, pdo_pgsql
* @phpVersion >= 7.2
*/
Pokazane adnotacje mówią, że test ma zostać uruchomiony tylko z PHP w wersji 7.2 albo wyższej i tylko wtedy, gdy obecne
są rozszerzenia PHP pdo i pdo_pgsql. Adnotacje te interpretuje runner testów z wiersza poleceń, który test pomija, jeśli warunki nie
są spełnione, i oznacza go w wyjściu literą s (skipped). Przy ręcznym uruchomieniu testu adnotacje te nie mają
jednak żadnego efektu.
Opis adnotacji znajdziesz na stronie Adnotacje testów.
Test można też pominąć na podstawie własnego warunku za pomocą Environment::skip(). To na przykład pomija
test w Windows:
if (defined('PHP_WINDOWS_VERSION_BUILD')) {
Tester\Environment::skip('Requires UNIX.');
}
Struktura katalogów
Dla bibliotek albo projektów choćby trochę większych zalecamy podzielenie katalogu testów na podkatalogi zgodnie z przestrzenią nazw testowanej klasy:
└── tests/
├── NamespaceOne/
│ ├── MyClass.getUsers.phpt
│ ├── MyClass.setUsers.phpt
│ └── ...
│
├── NamespaceTwo/
│ ├── MyClass.creating.phpt
│ ├── MyClass.dropping.phpt
│ └── ...
│
├── bootstrap.php
└── ...
Pozwala to uruchamiać testy z jednej przestrzeni nazw, czyli podkatalogu:
tester tests/NamespaceOne
Sytuacje szczególne
Test, który nie wywołuje żadnej metody asercji, uznawany jest za podejrzany i zostanie oceniony jako błąd:
Error: This test forgets to execute an assertion.
Jeśli test bez asercji jest celowo poprawny, wywołaj Assert::true(true), żeby go tak oznaczyć.
Używanie exit() albo die() do zakończenia testu z komunikatem o błędzie może być mylące. Na
przykład exit('Error in connection') kończy test z kodem wyjścia 0, co sygnalizuje sukces. Użyj zamiast tego
Assert::fail('Error in connection').