Introduzione
Quando si scrivono test con PHPUnit, le asserzioni predefinite come assertEquals(), assertTrue() e assertContains() sono spesso sufficienti. Tuttavia, nei progetti reali può essere necessario verificare condizioni specifiche del dominio applicativo.
Immaginiamo, per esempio, di dover controllare che un oggetto rappresenti un ordine valido, che un codice fiscale abbia un formato corretto oppure che una fattura rispetti determinate regole. In questi casi, numerose asserzioni generiche possono rendere il test difficile da leggere:
<?php
self::assertTrue($order->getTotal() > 0);
self::assertNotEmpty($order->getItems());
self::assertSame(´paid´, $order->getStatus()); Una soluzione più espressiva consiste nel creare una Constraint personalizzata. Una constraint incapsula una regola di verifica e permette di riutilizzarla in più test con un messaggio di errore chiaro.
Codice completo
Consideriamo una semplice classe Money che rappresenta un importo monetario:
<?php
// src/Money.php
declare(strict_types=1);
final class Money
{
public function __construct(
private readonly int $cents,
private readonly string $currency
) {
if ($cents < 0) {
throw new InvalidArgumentException(´L’importo non può essere negativo.´);
}
}
public function getCents(): int
{
return $this->cents;
}
public function getCurrency(): string
{
return $this->currency;
}
} Ora definiamo una constraint PHPUnit che verifica se un oggetto Money appartiene a una determinata valuta:
<?php
// tests/Constraints/IsMoneyInCurrency.php
declare(strict_types=1);
namespace TestsConstraints;
use Money;
use PHPUnitFrameworkConstraintConstraint;
final class IsMoneyInCurrency extends Constraint
{
public function __construct(
private readonly string $expectedCurrency
) {
}
protected function matches(mixed $other): bool
{
return $other instanceof Money
&& $other->getCurrency() === $this->expectedCurrency;
}
public function toString(): string
{
return sprintf(
´è un importo espresso in %s´,
$this->expectedCurrency
);
}
protected function failureDescription(mixed $other): string
{
return sprintf(
´%s %s´,
$this->exporter()->export($other),
$this->toString()
);
}
} Scriviamo quindi il test che utilizza la constraint:
<?php
// tests/MoneyTest.php
declare(strict_types=1);
use PHPUnitFrameworkTestCase;
use TestsConstraintsIsMoneyInCurrency;
final class MoneyTest extends TestCase
{
public function testImportoEspressoInEuro(): void
{
$price = new Money(1999, ´EUR´);
self::assertThat(
$price,
new IsMoneyInCurrency(´EUR´)
);
}
public function testImportoNonEspressoInDollari(): void
{
$price = new Money(1999, ´EUR´);
self::assertThat(
$price,
new IsMoneyInCurrency(´USD´)
);
}
} Il secondo test fallirà intenzionalmente. PHPUnit produrrà un messaggio simile a:
Failed asserting that Money Object (...) è un importo espresso in USD. Per installare PHPUnit in un progetto nuovo è sufficiente usare Composer:
composer require --dev phpunit/phpunit Una configurazione minima può essere inserita nel file phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit> Spiegazione
Il metodo matches()
Il metodo matches() contiene la logica principale della constraint. Riceve il valore passato a assertThat() e restituisce true se la condizione è soddisfatta, altrimenti false.
È importante controllare anche il tipo dell’oggetto. In questo esempio, un valore non appartenente alla classe Money deve essere considerato non valido:
return $other instanceof Money
&& $other->getCurrency() === $this->expectedCurrency; Il metodo toString()
toString() descrive la condizione attesa. Il testo restituito viene utilizzato da PHPUnit nei messaggi di errore. Una descrizione chiara accelera notevolmente il debug dei test falliti.
Il metodo failureDescription()
Questo metodo personalizza ulteriormente il messaggio mostrando il valore ricevuto. Il metodo exporter(), fornito da PHPUnit, converte l’oggetto in una rappresentazione leggibile.
Una constraint può anche essere usata insieme alle asserzioni negate:
self::assertThat(
$price,
self::logicalNot(new IsMoneyInCurrency(´USD´))
); In alternativa, è possibile comporre più condizioni con logicalAnd() e logicalOr(), particolarmente utile per verificare regole composte.
Best practice
- Usa constraint per regole di dominio riutilizzabili. Se una verifica compare una sola volta, una normale asserzione potrebbe essere più semplice.
- Mantieni la constraint focalizzata. Una constraint dovrebbe controllare una sola caratteristica, ad esempio la valuta o l’importo positivo, non un intero flusso applicativo.
- Scrivi messaggi di errore comprensibili. Un buon messaggio deve indicare il valore ricevuto e la condizione attesa.
- Evita di nascondere troppe verifiche. Una constraint troppo complessa può rendere difficile capire perché il test fallisce.
- Organizza il codice. Le constraint possono essere raccolte in una directory come tests/Constraints, separata dai test delle funzionalità.
- Verifica anche i casi limite. Testa oggetti validi, valute diverse, valori nulli e tipi inattesi.
Riepilogo
Le constraint personalizzate di PHPUnit consentono di trasformare regole tecniche in verifiche leggibili e riutilizzabili. Il metodo matches() implementa la condizione, toString() descrive l’aspettativa e failureDescription() migliora il messaggio di errore.
Questo approccio è particolarmente utile nei progetti con un dominio ricco, dove verifiche come “è un ordine pagato”, “è un importo in euro” o “rispetta il formato previsto” rappresentano concetti importanti dell’applicazione. In questo modo i test diventano più vicini al linguaggio del dominio e più semplici da mantenere.
