Scrivere i test

Scrivere test per Nette Tester è particolare perché ogni test è uno script PHP che si può eseguire da solo. In questo c'è un grande potenziale. Mentre scrivete un test potete semplicemente eseguirlo e controllare se funziona correttamente. Se non funziona, potete percorrerlo comodamente passo passo nel vostro IDE e cercare l'errore.

Potete perfino aprire il test nel browser. Ma soprattutto: eseguendolo, eseguite il test. Scoprite subito se è passato o fallito.

Nel capitolo introduttivo abbiamo mostrato un test davvero banale sul lavoro con un array. Ora creeremo una nostra classe da testare, anche se sarà comunque semplice.

Partiamo da una tipica struttura di directory per una libreria o un progetto. È importante separare i test dal resto del codice, per esempio in vista del deploy, perché non vogliamo caricare i test sul server di produzione. La struttura può essere questa:

├── src/           # codice che testeremo
│   ├── Rectangle.php
│   └── ...
├── tests/         # test
│   ├── bootstrap.php
│   ├── RectangleTest.php
│   └── ...
├── vendor/
└── composer.json

Creiamo ora i singoli file. Cominciamo dalla classe testata, che mettiamo nel 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('La dimensione non deve essere negativa.');
		}
		$this->width = $width;
		$this->height = $height;
	}

	public function getArea(): float
	{
		return $this->width * $this->height;
	}

	public function isSquare(): bool
	{
		return $this->width === $this->height;
	}
}

E creiamo un test per essa. Il nome del file del test deve corrispondere alla maschera *Test.php oppure *.phpt; scegliamo la variante RectangleTest.php:

<?php
use Tester\Assert;

require __DIR__ . '/bootstrap.php';

// rettangolo generico
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());  # verifichiamo i risultati attesi
Assert::false($rect->isSquare());

Come vedete, per affermare che il valore reale corrisponde a quello atteso si usano i metodi di asserzione come Assert::same().

L'ultimo passo è il file bootstrap.php. Contiene il codice comune a tutti i test, per esempio l'autoloading delle classi, la configurazione dell'ambiente, la creazione della directory temporanea, funzioni di supporto e simili. Tutti i test caricano il bootstrap e poi si occupano solo di testare. Il bootstrap può apparire così:

<?php
require __DIR__ . '/vendor/autoload.php';  # carica l'autoloader di Composer

Tester\Environment::setup();               # inizializzazione di Nette Tester

// e altre configurazioni (solo un esempio, nel nostro caso non serve)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');

Questo bootstrap presuppone che l'autoloader di Composer sappia caricare anche la classe Rectangle.php. Lo si può ottenere per esempio impostando la sezione autoload in composer.json e così via.

Ora possiamo eseguire il test dalla riga di comando come qualsiasi altro script PHP autonomo. La prima esecuzione rivelerà eventuali errori di sintassi e, se non ci sono refusi, stamperà:

$ php RectangleTest.php

OK

Se nel test cambiamo l'asserzione in una sbagliata, per esempio Assert::same(123, $rect->getArea());, succede questo:

$ php RectangleTest.php

Failed: 200.0 should be 123

in RectangleTest.php(5) Assert::same(123, $rect->getArea());

FAILURE

Quando scrivete i test è buona pratica coprire tutti i casi limite. Per esempio input come lo zero, i numeri negativi o, in altri scenari, le stringhe vuote, null ecc. Questo vi costringe a riflettere e a decidere come il codice debba comportarsi in queste situazioni. I test poi fissano questo comportamento.

Nel nostro caso un valore negativo deve lanciare un'eccezione, cosa che verifichiamo con Assert::exception():

// la larghezza non deve essere negativa
Assert::exception(
	fn() => new Rectangle(-1, 20),
	InvalidArgumentException::class,
	'La dimensione non deve essere negativa.',
);

E aggiungiamo un test analogo per l'altezza. Infine testiamo che isSquare() restituisca true se entrambe le dimensioni sono uguali. Provate a scrivere questi test come esercizio.

Test ben organizzati

Il file del test può crescere e diventare in fretta poco leggibile. Conviene perciò raggruppare le singole aree testate in funzioni separate.

Guardiamo prima l'opzione più semplice ma elegante, che usa la funzione globale test(). Tester non la crea automaticamente per evitare collisioni se nel vostro codice avete una funzione con lo stesso nome. La crea il metodo setupFunctions(), che dovreste chiamare nel vostro file bootstrap.php:

Tester\Environment::setup();
Tester\Environment::setupFunctions();

Grazie a questa funzione possiamo dividere bene il file del test in unità con un nome. Durante l'esecuzione le etichette verranno stampate una dopo l'altra.

<?php
use Tester\Assert;

require __DIR__ . '/bootstrap.php';

test('rettangolo generico', function () {
	$rect = new Rectangle(10, 20);
	Assert::same(200.0, $rect->getArea());
	Assert::false($rect->isSquare());
});

test('quadrato generico', function () {
	$rect = new Rectangle(5, 5);
	Assert::same(25.0, $rect->getArea());
	Assert::true($rect->isSquare());
});

test('le dimensioni non devono essere negative', function () {
	Assert::exception(
		fn() => new Rectangle(-1, 20),
        InvalidArgumentException::class,
	);

	Assert::exception(
		fn() => new Rectangle(10, -1),
        InvalidArgumentException::class,
	);
});

Se avete bisogno di eseguire del codice prima o dopo ogni test(), passatelo rispettivamente alla funzione setUp() oppure tearDown():

setUp(function () {
	// codice di inizializzazione eseguito prima di ogni test()
});

La seconda variante è orientata agli oggetti. Creiamo un cosiddetto TestCase, cioè una classe in cui le singole unità sono rappresentate da metodi il cui nome inizia con 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);
	}
}

// esecuzione dei metodi di test
(new RectangleTest)->run();

Questa volta abbiamo usato l'annotazione @throws per testare le eccezioni. Ne saprete di più nel capitolo TestCase.

Funzioni di supporto

Nette Tester comprende alcune classi e funzioni che possono facilitare i test, per esempio testare il contenuto di documenti HTML, testare funzioni che lavorano con i file e così via.

La loro descrizione la trovate nella pagina Classi di supporto.

Annotazioni e test saltati

L'esecuzione dei test può essere influenzata dalle annotazioni nel commento phpDoc all'inizio del file. Possono apparire per esempio così:

/**
 * @phpExtension pdo, pdo_pgsql
 * @phpVersion >= 7.2
 */

Le annotazioni mostrate dicono che il test va eseguito solo con PHP versione 7.2 o superiore e solo se sono presenti le estensioni PHP pdo e pdo_pgsql. Queste annotazioni vengono interpretate dal runner dei test da riga di comando, che salta il test se le condizioni non sono soddisfatte e lo contrassegna nell'output con la lettera s (skipped). Quando però il test viene eseguito a mano, queste annotazioni non hanno effetto.

La descrizione delle annotazioni la trovate nella pagina Annotazioni dei test.

Un test si può saltare anche in base a una condizione personalizzata usando Environment::skip(). Questo per esempio salta il test su Windows:

if (defined('PHP_WINDOWS_VERSION_BUILD')) {
	Tester\Environment::skip('Richiede UNIX.');
}

Struttura delle directory

Per librerie o progetti anche solo un po' più grandi consigliamo di dividere la directory dei test in sottodirectory secondo il namespace della classe testata:

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

Questo vi permette di eseguire i test di un solo namespace, cioè di una sottodirectory:

tester tests/NamespaceOne

Situazioni particolari

Un test che non chiama alcun metodo di asserzione è considerato sospetto e viene valutato come errore:

Error: This test forgets to execute an assertion.

Se un test senza asserzioni è intenzionalmente valido, chiamate Assert::true(true) per contrassegnarlo come tale.

Usare exit() o die() per terminare il test con un messaggio di errore può trarre in inganno. Per esempio exit('Errore di connessione') termina il test con codice di uscita 0, che segnala il successo. Usate invece Assert::fail('Errore di connessione').