Tests unitaris
Com escriure tests unitaris a Laravel per testejar lògica de negoci aïllada: serveis, models, helpers, value objects i validacions.
Què és un test unitari?#
Un test unitari prova una unitat de codi aïllada: una funció, un mètode o una classe. La idea fonamental és que si cada peça individual funciona correctament, el sistema complet té moltes més probabilitats de funcionar correctament. Els tests unitaris no arrenquen el framework Laravel, no accedeixen a la base de dades, no fan peticions HTTP i no interactuen amb serveis externs. Proven lògica pura: donat un input, es produeix l'output esperat.
Aquesta aïllació és el que fa que els tests unitaris siguin extremadament ràpids. Mentre que un test funcional pot trigar centenars de mil·lisegons (arrencar el framework, preparar la base de dades, simular una petició HTTP), un test unitari típic s'executa en menys d'un mil·lisegon. En un projecte amb 500 tests unitaris, la diferència entre executar la suite en 2 segons o en 5 minuts marca si els tests es fan servir o no.
A Laravel, els tests unitaris viuen a tests/Unit/ i, en PHPUnit clàssic, hereten directament de PHPUnit\Framework\TestCase en lloc de Tests\TestCase. Això significa que no tenen accés als helpers de Laravel com $this->get(), $this->actingAs() o les facades. Si un test necessita aquests helpers, probablement és un test funcional disfressat i hauria d'estar a tests/Feature/.
Crear un test unitari#
# PHPUnit
php artisan make:test PriceCalculatorTest --unit
# Pest
php artisan make:test PriceCalculatorTest --unit --pestTestejar serveis#
Els serveis són els candidats ideals per a tests unitaris perquè encapsulen lògica de negoci que es pot provar de manera aïllada. Un servei ben dissenyat rep les seves dependències per constructor i retorna resultats previsibles:
// app/Services/PriceCalculator.php
namespace App\Services;
class PriceCalculator
{
public function calculateSubtotal(array $items): float
{
return array_reduce($items, function ($total, $item) {
return $total + ($item['price'] * $item['quantity']);
}, 0);
}
public function applyDiscount(float $price, float $percentage): float
{
if ($percentage < 0 || $percentage > 100) {
throw new \InvalidArgumentException(
"El percentatge ha de ser entre 0 i 100, s'ha rebut: {$percentage}"
);
}
return round($price * (1 - $percentage / 100), 2);
}
public function calculateTax(float $price, float $taxRate = 21): float
{
return round($price * $taxRate / 100, 2);
}
public function calculateTotal(float $subtotal, float $discount, float $taxRate = 21): float
{
$discounted = $this->applyDiscount($subtotal, $discount);
$tax = $this->calculateTax($discounted, $taxRate);
return round($discounted + $tax, 2);
}
}// tests/Unit/Services/PriceCalculatorTest.php
use App\Services\PriceCalculator;
beforeEach(function () {
$this->calculator = new PriceCalculator();
});
describe('calculateSubtotal', function () {
test('calculates subtotal from items', function () {
$items = [
['price' => 10.00, 'quantity' => 2],
['price' => 25.50, 'quantity' => 1],
['price' => 5.00, 'quantity' => 3],
];
expect($this->calculator->calculateSubtotal($items))->toBe(60.50);
});
test('returns zero for empty items', function () {
expect($this->calculator->calculateSubtotal([]))->toBe(0);
});
});
describe('applyDiscount', function () {
test('applies percentage discount', function () {
expect($this->calculator->applyDiscount(100, 20))->toBe(80.0);
expect($this->calculator->applyDiscount(49.99, 10))->toBe(44.99);
expect($this->calculator->applyDiscount(100, 0))->toBe(100.0);
expect($this->calculator->applyDiscount(100, 100))->toBe(0.0);
});
test('throws exception for invalid percentage', function () {
expect(fn () => $this->calculator->applyDiscount(100, -5))
->toThrow(\InvalidArgumentException::class);
expect(fn () => $this->calculator->applyDiscount(100, 150))
->toThrow(\InvalidArgumentException::class);
});
});
describe('calculateTax', function () {
test('calculates tax with default rate', function () {
expect($this->calculator->calculateTax(100))->toBe(21.0);
});
test('calculates tax with custom rate', function () {
expect($this->calculator->calculateTax(100, 4))->toBe(4.0);
expect($this->calculator->calculateTax(200, 10))->toBe(20.0);
});
});
describe('calculateTotal', function () {
test('calculates total with discount and tax', function () {
// 100 - 20% = 80, + 21% IVA = 96.80
expect($this->calculator->calculateTotal(100, 20))->toBe(96.80);
});
test('calculates total without discount', function () {
// 100 - 0% = 100, + 21% IVA = 121.00
expect($this->calculator->calculateTotal(100, 0))->toBe(121.0);
});
test('calculates total with reduced tax', function () {
// 100 - 10% = 90, + 4% IVA = 93.60
expect($this->calculator->calculateTotal(100, 10, 4))->toBe(93.60);
});
});Observa com cada test segueix el patró Arrange-Act-Assert (AAA): primer es preparen les dades, després s'executa l'acció, i finalment es verifica el resultat. Amb Pest i expect(), l'assert és l'última línia de cada test. El describe() agrupa tests relacionats, cosa que fa la sortida més organitzada.
Testejar models (lògica pura)#
Els models Eloquent tenen sovint lògica que es pot testejar unitàriament: accessors, mutadors, mètodes de càlcul i scopes que transformen dades. La clau és distingir entre lògica que necessita la base de dades (relacions, consultes) i lògica que no (transformacions, càlculs sobre atributs):
// app/Models/Article.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Article extends Model
{
protected $fillable = ['title', 'body', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (Article $article) {
$article->slug = Str::slug($article->title);
});
}
public function getReadingTimeAttribute(): int
{
$wordCount = str_word_count(strip_tags($this->body));
return max(1, (int) ceil($wordCount / 200));
}
public function getExcerptAttribute(): string
{
return Str::limit(strip_tags($this->body), 150);
}
public function isPublished(): bool
{
return $this->published_at !== null
&& $this->published_at->isPast();
}
public function isScheduled(): bool
{
return $this->published_at !== null
&& $this->published_at->isFuture();
}
}// tests/Unit/Models/ArticleTest.php
use App\Models\Article;
use Carbon\Carbon;
test('generates slug from title on creation', function () {
$article = new Article(['title' => 'Aprèn Laravel amb Andorra']);
// El slug es genera durant l'event "creating", que requereix
// la base de dades. Per testar-ho unitàriament, simulem la lògica:
$article->slug = \Illuminate\Support\Str::slug($article->title);
expect($article->slug)->toBe('apren-laravel-amb-andorra');
});
test('calculates reading time based on word count', function () {
$article = new Article();
// 200 paraules = 1 minut
$article->body = implode(' ', array_fill(0, 200, 'paraula'));
expect($article->reading_time)->toBe(1);
// 450 paraules = 3 minuts (ceil de 2.25)
$article->body = implode(' ', array_fill(0, 450, 'paraula'));
expect($article->reading_time)->toBe(3);
// Text buit = mínim 1 minut
$article->body = '';
expect($article->reading_time)->toBe(1);
});
test('generates excerpt from body', function () {
$article = new Article();
$article->body = str_repeat('Lorem ipsum dolor sit amet. ', 20);
expect($article->excerpt)
->toHaveLength(153) // 150 + '...'
->toEndWith('...');
});
test('strips HTML tags from excerpt', function () {
$article = new Article();
$article->body = '<p>Primer paràgraf amb <strong>negreta</strong> i molt contingut addicional.</p>';
expect($article->excerpt)
->not->toContain('<p>')
->not->toContain('<strong>')
->toContain('negreta');
});
test('determines if article is published', function () {
$article = new Article();
// Sense data de publicació
$article->published_at = null;
expect($article->isPublished())->toBeFalse();
// Publicat en el passat
$article->published_at = Carbon::yesterday();
expect($article->isPublished())->toBeTrue();
// Programat per al futur
$article->published_at = Carbon::tomorrow();
expect($article->isPublished())->toBeFalse();
});
test('determines if article is scheduled', function () {
$article = new Article();
$article->published_at = Carbon::tomorrow();
expect($article->isScheduled())->toBeTrue();
$article->published_at = Carbon::yesterday();
expect($article->isScheduled())->toBeFalse();
$article->published_at = null;
expect($article->isScheduled())->toBeFalse();
});Cal tenir en compte que alguns aspectes del model requereixen la base de dades per ser testejats (relacions, scopes, events del cicle de vida com creating). Aquests tests haurien d'anar a tests/Feature/ amb RefreshDatabase. Els tests unitaris del model se centren en la lògica pura: accessors, mutadors, mètodes de càlcul i predicats booleans.
Testejar Value Objects#
Els Value Objects són classes immutables que representen un concepte del domini (un preu, una adreça, un rang de dates). Són perfectes per a tests unitaris perquè no tenen dependències externes:
// app/ValueObjects/Money.php
namespace App\ValueObjects;
class Money
{
public function __construct(
private readonly int $cents,
private readonly string $currency = 'EUR'
) {
if ($cents < 0) {
throw new \InvalidArgumentException('L\'import no pot ser negatiu.');
}
}
public static function fromEuros(float $euros): self
{
return new self((int) round($euros * 100));
}
public function cents(): int
{
return $this->cents;
}
public function euros(): float
{
return $this->cents / 100;
}
public function currency(): string
{
return $this->currency;
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('No es poden sumar monedes diferents.');
}
return new self($this->cents + $other->cents, $this->currency);
}
public function multiply(int $factor): self
{
return new self($this->cents * $factor, $this->currency);
}
public function format(): string
{
return number_format($this->euros(), 2, ',', '.') . ' ' . $this->currency;
}
}// tests/Unit/ValueObjects/MoneyTest.php
use App\ValueObjects\Money;
test('creates money from cents', function () {
$money = new Money(1500);
expect($money->cents())->toBe(1500);
expect($money->euros())->toBe(15.0);
expect($money->currency())->toBe('EUR');
});
test('creates money from euros', function () {
$money = Money::fromEuros(29.99);
expect($money->cents())->toBe(2999);
expect($money->euros())->toBe(29.99);
});
test('rejects negative amounts', function () {
expect(fn () => new Money(-100))
->toThrow(\InvalidArgumentException::class);
});
test('adds two money values', function () {
$a = new Money(1000);
$b = new Money(2500);
$result = $a->add($b);
expect($result->cents())->toBe(3500);
expect($result->euros())->toBe(35.0);
});
test('cannot add different currencies', function () {
$eur = new Money(1000, 'EUR');
$usd = new Money(1000, 'USD');
expect(fn () => $eur->add($usd))
->toThrow(\InvalidArgumentException::class);
});
test('multiplies by factor', function () {
$money = new Money(500);
expect($money->multiply(3)->cents())->toBe(1500);
});
test('formats with currency', function () {
$money = new Money(125099);
expect($money->format())->toBe('1.250,99 EUR');
});Testejar helpers i funcions#
Les funcions helper que no depenen del framework són també bones candidates per a tests unitaris. Si tens helpers que formaten text, transformen dates, generen identificadors o processen dades, pots testejar-los directament:
// app/Helpers/TextHelper.php
namespace App\Helpers;
class TextHelper
{
public static function initials(string $name): string
{
return collect(explode(' ', trim($name)))
->filter()
->map(fn ($word) => mb_strtoupper(mb_substr($word, 0, 1)))
->implode('');
}
public static function sanitizeFilename(string $filename): string
{
$filename = preg_replace('/[^\w\s\-.]/', '', $filename);
$filename = preg_replace('/\s+/', '-', trim($filename));
return strtolower($filename);
}
public static function truncateWords(string $text, int $words = 20, string $end = '...'): string
{
$wordArray = explode(' ', $text);
if (count($wordArray) <= $words) {
return $text;
}
return implode(' ', array_slice($wordArray, 0, $words)) . $end;
}
}// tests/Unit/Helpers/TextHelperTest.php
use App\Helpers\TextHelper;
describe('initials', function () {
test('extracts initials from full name', function () {
expect(TextHelper::initials('Edu Lázaro'))->toBe('EL');
expect(TextHelper::initials('Joan Pere Vila'))->toBe('JPV');
});
test('handles single name', function () {
expect(TextHelper::initials('Edu'))->toBe('E');
});
test('handles extra spaces', function () {
expect(TextHelper::initials(' Edu Lázaro '))->toBe('EL');
});
});
describe('sanitizeFilename', function () {
test('removes special characters', function () {
expect(TextHelper::sanitizeFilename('fitxer@#$.pdf'))
->toBe('fitxer.pdf');
});
test('replaces spaces with hyphens', function () {
expect(TextHelper::sanitizeFilename('el meu fitxer.pdf'))
->toBe('el-meu-fitxer.pdf');
});
test('converts to lowercase', function () {
expect(TextHelper::sanitizeFilename('Document IMPORTANT.PDF'))
->toBe('document-important.pdf');
});
});
describe('truncateWords', function () {
test('truncates text to specified word count', function () {
$text = 'un dos tres quatre cinc sis set vuit nou deu';
expect(TextHelper::truncateWords($text, 5))
->toBe('un dos tres quatre cinc...');
});
test('does not truncate short text', function () {
expect(TextHelper::truncateWords('hola món', 5))
->toBe('hola món');
});
test('uses custom ending', function () {
$text = 'un dos tres quatre cinc sis';
expect(TextHelper::truncateWords($text, 3, ' [+]'))
->toBe('un dos tres [+]');
});
});Expectations de Pest#
Pest proporciona una API d'expectations encadenables que fa que les assertions siguin molt expressives. Les expectations més utilitzades cobreixen tots els tipus de verificació que necessitaràs:
// Igualtat
expect($value)->toBe(42); // Igualtat estricta (===)
expect($value)->toEqual(42); // Igualtat flexible (==)
expect($value)->not->toBe(0); // Negació
// Tipus
expect($value)->toBeString();
expect($value)->toBeInt();
expect($value)->toBeFloat();
expect($value)->toBeBool();
expect($value)->toBeArray();
expect($value)->toBeNull();
expect($value)->toBeInstanceOf(Money::class);
// Booleans
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeTruthy();
expect($value)->toBeFalsy();
// Strings
expect($text)->toContain('Laravel');
expect($text)->toStartWith('Hola');
expect($text)->toEndWith('!');
expect($text)->toMatch('/^\d{4}-\d{2}-\d{2}$/');
expect($text)->toHaveLength(10);
expect($text)->toBeEmpty();
// Números
expect($number)->toBeGreaterThan(5);
expect($number)->toBeLessThan(100);
expect($number)->toBeBetween(1, 10);
// Arrays
expect($array)->toHaveCount(3);
expect($array)->toContain('element');
expect($array)->toHaveKey('name');
expect($array)->toMatchArray(['a' => 1, 'b' => 2]);
// Excepcions
expect(fn () => riskyOperation())
->toThrow(CustomException::class, 'Missatge d\'error');
// Cada element d'una col·lecció
expect([1, 2, 3])->each->toBeInt();
expect($users)->each->toHaveKey('email');Datasets (Pest)#
Els datasets permeten executar el mateix test amb múltiples conjunts de dades, evitant duplicar tests que proven la mateixa lògica amb valors diferents. Cada conjunt de dades genera una execució independent del test:
test('validates email format', function (string $email, bool $expected) {
$validator = new EmailValidator();
expect($validator->isValid($email))->toBe($expected);
})->with([
'valid email' => ['usuari@example.com', true],
'valid with subdomain' => ['user@mail.example.com', true],
'missing @' => ['invalid-email', false],
'missing domain' => ['user@', false],
'missing user' => ['@example.com', false],
'spaces' => ['user @example.com', false],
]);
// Dataset amb nom per a reutilització
dataset('valid prices', [
[10.00, 20, 8.00],
[49.99, 10, 44.99],
[100.00, 50, 50.00],
[0.01, 0, 0.01],
]);
test('applies discount correctly', function (float $price, float $discount, float $expected) {
$calculator = new PriceCalculator();
expect($calculator->applyDiscount($price, $discount))->toBe($expected);
})->with('valid prices');Els datasets són especialment útils per a validacions, transformacions i càlculs on vols verificar múltiples casos límit sense repetir el cos del test. El nom de cada cas apareix a la sortida dels tests, facilitant la identificació de quins casos fallen.
Quan fer tests unitaris vs funcionals#
La decisió de si un test ha de ser unitari o funcional depèn del que estàs testejant:
Tests unitaris per a:
- Càlculs i transformacions de dades (preus, descomptes, impostos)
- Value Objects (Money, Address, DateRange)
- Helpers i funcions utilitàries (formatadors, generadors, parsers)
- Lògica de negoci pura sense dependències del framework
- Accessors i mutadors de models que no necessiten la BD
Tests funcionals per a:
- Peticions HTTP (rutes, controladors, middleware)
- Fluxos d'autenticació i autorització
- Validació de formularis amb Form Requests
- Relacions i consultes Eloquent
- Events, listeners i notificacions
- Qualsevol cosa que necessiti la base de dades o el framework
Si dubtes, la regla és senzilla: si el test necessita $this->get(), $this->actingAs(), la base de dades o qualsevol servei de Laravel, és un test funcional. Si pots instanciar la classe amb new i cridar-ne els mètodes directament, és un test unitari.