Primers passos amb testing
Com configurar i executar tests a Laravel amb PHPUnit i Pest: estructura del directori, configuració d'entorn, execució i bones pràctiques.
L'entorn de testing de Laravel#
Quan crees un projecte Laravel nou, el testing ja està configurat i llest per funcionar. El directori tests/ conté exemples de tests, el fitxer phpunit.xml defineix la configuració de PHPUnit, i les dependències necessàries (phpunit/phpunit i pestphp/pest si has escollit Pest durant la instal·lació) ja estan al composer.json. No cal instal·lar res addicional ni configurar res manualment per començar a escriure el teu primer test.
Aquesta decisió de disseny és deliberada: Laravel vol que el testing sigui el camí per defecte, no una opció que has d'activar. En molts frameworks, afegir testing requereix instal·lar paquets, crear fitxers de configuració, definir autoloading especial i configurar la base de dades de prova. Amb Laravel, tot això ja està fet. L'única excusa per no escriure tests és no voler fer-ho, no la complexitat de la configuració.
Estructura del directori de tests#
Laravel organitza els tests en dues carpetes principals dins del directori tests/, cadascuna amb un propòsit diferent. Aquesta separació no és arbitrària: reflecteix una diferència fonamental en com s'executen els tests i què proven.
tests/
├── Feature/ # Tests funcionals (HTTP, integració)
│ ├── Auth/
│ │ ├── LoginTest.php
│ │ └── RegistrationTest.php
│ ├── ArticleTest.php
│ └── PaymentTest.php
├── Unit/ # Tests unitaris (lògica aïllada)
│ ├── Services/
│ │ └── PriceCalculatorTest.php
│ ├── Models/
│ │ └── ArticleTest.php
│ └── Helpers/
│ └── SlugGeneratorTest.php
├── TestCase.php # Classe base per tests funcionals
└── Pest.php # Configuració global de Pest (si s'utilitza)
La carpeta Feature/ conté tests que proven fluxos complets de l'aplicació. Aquests tests arranquen el framework Laravel complet, simulen peticions HTTP, interactuen amb la base de dades i verifiquen respostes. Hereten de Tests\TestCase, que a la vegada hereta de la classe TestCase de Laravel i configura tot l'entorn necessari: l'aplicació, el service container, els service providers, el middleware, etc.
La carpeta Unit/ conté tests que proven unitats de codi aïllades sense el framework complet. En PHPUnit clàssic, hereten directament de PHPUnit\Framework\TestCase (no de Tests\TestCase), cosa que significa que no tenen accés a helpers de Laravel com $this->get() o $this->actingAs(). Això els fa molt més ràpids, perquè no han d'arrencar tot el framework per cada test.
A mesura que el projecte creix, és recomanable organitzar els tests en subcarpetes que reflecteixin l'estructura de l'aplicació. Si tens app/Services/PriceCalculator.php, el seu test hauria d'estar a tests/Unit/Services/PriceCalculatorTest.php. Aquesta convenció fa que sigui fàcil trobar el test corresponent a qualsevol classe.
PHPUnit vs Pest#
Laravel suporta dos frameworks de testing: PHPUnit i Pest. PHPUnit és l'estàndard de facto del testing en PHP des de fa més de vint anys. Pest és un framework modern creat per Nuno Maduro (membre de l'equip de Laravel) que es construeix sobre PHPUnit i ofereix una sintaxi inspirada en Jest (JavaScript) i RSpec (Ruby).
PHPUnit#
PHPUnit utilitza classes i mètodes. Cada test és un mètode públic dins d'una classe que hereta de TestCase. Els noms dels mètodes han de començar amb test_ o tenir l'anotació @test. Les assertions utilitzen mètodes com $this->assertEquals(), $this->assertTrue(), etc.
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ArticleTest extends TestCase
{
use RefreshDatabase;
public function test_users_can_view_articles_list(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/articles');
$response->assertStatus(200);
$response->assertViewIs('articles.index');
}
public function test_guests_cannot_create_articles(): void
{
$response = $this->post('/articles', [
'title' => 'El meu article',
'body' => 'Contingut de l\'article.',
]);
$response->assertRedirect('/login');
}
public function test_article_requires_title(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/articles', ['body' => 'Contingut sense títol.']);
$response->assertSessionHasErrors('title');
}
}PHPUnit és robust, madur i àmpliament documentat. Si véns d'altres llenguatges, la seva estructura basada en classes et resultarà familiar. La majoria de tutorials, exemples i documentació de Laravel utilitzen PHPUnit, cosa que fa que sigui fàcil trobar ajuda.
Pest#
Pest ofereix una sintaxi funcional i expressiva que redueix el codi repetitiu. En lloc de classes, utilitza funcions test() i it(). En lloc de $this->assertEquals(), utilitza expect() amb una API encadenada que es llegeix com anglès natural. Les funcions beforeEach() i afterEach() substitueixen els mètodes setUp() i tearDown():
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('users can view articles list', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/articles')
->assertStatus(200)
->assertViewIs('articles.index');
});
test('guests cannot create articles', function () {
$this->post('/articles', [
'title' => 'El meu article',
'body' => 'Contingut de l\'article.',
])->assertRedirect('/login');
});
it('requires a title to create an article', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', ['body' => 'Contingut sense títol.'])
->assertSessionHasErrors('title');
});La diferència entre test() i it() és purament semàntica. Amb it(), el nom del test es llegeix com una frase en anglès: "it requires a title to create an article". Amb test(), es descriu l'acció: "users can view articles list". Pots utilitzar el que prefereixis o barrejar-los.
Instal·lar Pest en un projecte existent#
Si el teu projecte utilitza PHPUnit i vols migrar a Pest, la instal·lació és senzilla:
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
# Inicialitzar Pest (crea tests/Pest.php)
./vendor/bin/pest --initEl fitxer tests/Pest.php és la configuració global de Pest. Aquí pots definir quina classe TestCase utilitzen els tests per defecte i aplicar traits globalment:
// tests/Pest.php
uses(Tests\TestCase::class, RefreshDatabase::class)
->in('Feature');
uses(PHPUnit\Framework\TestCase::class)
->in('Unit');Amb aquesta configuració, tots els tests de Feature/ utilitzen la classe TestCase de Laravel amb RefreshDatabase, i els tests de Unit/ utilitzen la classe TestCase bàsica de PHPUnit. No cal repetir uses(RefreshDatabase::class) a cada fitxer de test funcional.
Configuració de l'entorn de testing#
Quan executes tests, Laravel carrega automàticament les variables d'entorn del fitxer .env.testing si existeix. Si no existeix, utilitza les variables de .env amb els overrides definits a phpunit.xml. Això permet tenir una configuració completament diferent per als tests sense afectar l'entorn de desenvolupament.
El fitxer .env.testing típic utilitza SQLite en memòria per a la base de dades, cosa que fa els tests molt més ràpids perquè no cal accedir al disc ni a un servidor de base de dades extern:
APP_ENV=testing
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_STORE=array
QUEUE_CONNECTION=sync
SESSION_DRIVER=array
MAIL_MAILER=array
BCRYPT_ROUNDS=4Algunes d'aquestes configuracions mereixen explicació. BCRYPT_ROUNDS=4 redueix el nombre de rondes de hash de contrasenyes (el valor per defecte és 12). Cada ronda addicional duplica el temps de hash, així que passar de 12 a 4 rondes fa que el hash sigui unes 256 vegades més ràpid. En tests, no necessitem seguretat real de contrasenyes, i cada test que crea un usuari ha de fer hash de la contrasenya.
CACHE_STORE=array, SESSION_DRIVER=array i MAIL_MAILER=array utilitzen el driver "array", que emmagatzema les dades en memòria durant la petició actual i les descarta quan acaba. Això evita efectes secundaris entre tests (un test que guarda alguna cosa a cache no afecta un altre test) i elimina la dependència de serveis externs com Redis.
QUEUE_CONNECTION=sync fa que els jobs s'executin immediatament en lloc d'enviar-los a una cua. Això simplifica els tests perquè no cal executar un worker de cues per verificar que els jobs funcionen. Si necessites testejar específicament el comportament asíncron, pots utilitzar Queue::fake().
El fitxer phpunit.xml a l'arrel del projecte defineix la configuració de PHPUnit i permet sobreescriure variables d'entorn:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="MAIL_MAILER" value="array"/>
</php>
</phpunit>Les variables definides a la secció <php> de phpunit.xml sobreescriuen les de .env (però no les de .env.testing, que tenen prioritat). La secció <testsuites> defineix on buscar els tests, i la secció <source> indica quins fitxers incloure en els informes de cobertura.
Executar tests#
La comanda principal per executar tests a Laravel és php artisan test, que és un wrapper elegant al voltant de PHPUnit (o Pest) amb una sortida més visual i funcionalitats addicionals:
# Executar tots els tests
php artisan test
# Executar amb Pest directament (equivalent)
./vendor/bin/pest
# Executar amb PHPUnit directament (equivalent)
./vendor/bin/phpunitLa diferència entre php artisan test i ./vendor/bin/pest (o ./vendor/bin/phpunit) és principalment la presentació de la sortida. La comanda Artisan mostra una sortida amb colors, temps d'execució i un resum final més visual. Les comandes directes mostren la sortida estàndard de cada framework.
Filtrar tests#
Quan estàs treballant en una funcionalitat concreta, no vols executar tots els tests del projecte cada vegada. Els filtres permeten executar només els tests rellevants, cosa que accelera enormement el cicle de desenvolupament:
# Filtrar per nom del test
php artisan test --filter=test_users_can_register
# Filtrar per nom de la classe
php artisan test --filter=ArticleTest
# Filtrar per directori
php artisan test --filter=Feature
# Executar un fitxer específic
php artisan test tests/Feature/ArticleTest.php
# Executar només una suite (Unit o Feature)
php artisan test --testsuite=Unit
php artisan test --testsuite=FeatureAmb Pest, pots filtrar també per la descripció del test:
# Filtrar per text de la descripció
./vendor/bin/pest --filter="users can register"
# Filtrar per grup
./vendor/bin/pest --group=authExecució en paral·lel#
Per defecte, els tests s'executen seqüencialment: un darrere l'altre. En projectes grans amb centenars de tests, això pot trigar minuts. L'opció --parallel executa els tests en múltiples processos simultanis, aprofitant tots els nuclis del processador:
# Executar en paral·lel (utilitza tots els nuclis disponibles)
php artisan test --parallel
# Especificar el nombre de processos
php artisan test --parallel --processes=4L'execució en paral·lel requereix el paquet brianium/paratest, que Laravel inclou per defecte. Cada procés utilitza la seva pròpia base de dades de prova (Laravel crea automàticament bases de dades temporals per a cada procés), cosa que evita conflictes entre tests que s'executen simultàniament.
Cal tenir en compte que l'execució en paral·lel pot revelar problemes de concurrència als tests. Si dos tests comparteixen estat global (una variable estàtica, un fitxer temporal al mateix camí), poden fallar quan s'executen alhora. Això sol ser un signe que els tests no estan prou aïllats.
Cobertura de codi#
La cobertura de codi mesura quin percentatge del codi de l'aplicació s'executa durant els tests. No és una mesura perfecta de la qualitat dels tests (pots tenir 100% de cobertura amb tests que no verifiquen res), però és útil per identificar àrees del codi que no tenen cap test:
# Mostrar cobertura al terminal
php artisan test --coverage
# Cobertura amb un mínim requerit (falla si és inferior)
php artisan test --coverage --min=80
# Generar informe HTML detallat
php artisan test --coverage-html=coverage-reportLa cobertura requereix una extensió PHP com Xdebug o PCOV. PCOV és molt més ràpida que Xdebug per a cobertura (Xdebug fa molt més que cobertura: depuració, profiling, etc.). Si només necessites cobertura, instal·la PCOV:
# Instal·lar PCOV (Ubuntu/Debian)
sudo pecl install pcovUn objectiu de cobertura raonable per a la majoria de projectes és entre el 70% i el 85%. Intentar arribar al 100% sol ser contraproduent perquè força a escriure tests per a codi trivial (getters, setters, constructors) que no aporten valor real.
Crear un test#
Laravel proporciona comandes Artisan per generar tests amb l'estructura correcta. Utilitzar aquestes comandes garanteix que el test es crea al directori correcte, amb el namespace adequat i heretant de la classe TestCase apropiada:
# Test funcional (Feature) - per defecte
php artisan make:test ArticleTest
# Test unitari
php artisan make:test PriceCalculatorTest --unit
# Amb Pest (sintaxi funcional)
php artisan make:test ArticleTest --pest
php artisan make:test PriceCalculatorTest --unit --pestLa comanda make:test crea el fitxer a tests/Feature/ per defecte. L'opció --unit el crea a tests/Unit/. L'opció --pest genera la sintaxi funcional de Pest en lloc de la sintaxi de classes de PHPUnit.
Helpers de testing de Laravel#
La classe Tests\TestCase de Laravel proporciona una sèrie de helpers que faciliten enormement l'escriptura de tests. Aquests helpers estan disponibles en tots els tests funcionals (els que hereten de Tests\TestCase):
// Simular peticions HTTP
$this->get('/path');
$this->post('/path', ['data' => 'value']);
$this->put('/path/1', ['data' => 'updated']);
$this->patch('/path/1', ['field' => 'value']);
$this->delete('/path/1');
// Peticions JSON (afegeixen headers Accept: application/json)
$this->getJson('/api/articles');
$this->postJson('/api/articles', ['title' => 'Nou']);
$this->putJson('/api/articles/1', ['title' => 'Actualitzat']);
$this->deleteJson('/api/articles/1');
// Autenticació
$this->actingAs($user);
$this->actingAs($user, 'api'); // Guard específic
// Sessió
$this->withSession(['key' => 'value']);
$this->withCookie('name', 'value');
// Headers
$this->withHeaders(['X-Custom' => 'value']);
$this->withToken('bearer-token');
// Desactivar middleware
$this->withoutMiddleware();
$this->withoutMiddleware(ThrottleMiddleware::class);
// Congelar el temps
$this->travel(5)->days();
$this->travelTo(Carbon::parse('2024-01-15'));
$this->freezeTime();Aquests helpers retornen una instància de TestResponse que proporciona mètodes d'assertion per verificar la resposta. L'encadenament de mètodes permet escriure tests expressives en poques línies.
Cicle de desenvolupament amb tests#
Un flux de treball efectiu amb tests segueix un patró iteratiu. No cal escriure tots els tests abans de codificar (TDD pur) ni deixar-los tots per al final. Un enfocament pragmàtic combina escriure codi i tests en cicles curts:
# 1. Crear el test per a la funcionalitat que vols implementar
php artisan make:test CreateArticleTest
# 2. Escriure el test (fallarà perquè la funcionalitat no existeix)
php artisan test --filter=CreateArticleTest
# 3. Implementar la funcionalitat
# ... escriure el controlador, la ruta, la vista, etc.
# 4. Executar el test (hauria de passar)
php artisan test --filter=CreateArticleTest
# 5. Executar tots els tests per verificar que no has trencat res
php artisan testAquest cicle és especialment valuós quan refactoritzes codi existent. Si tens tests que cobreixen la funcionalitat actual, pots refactoritzar amb confiança: si els tests segueixen passant, la funcionalitat no s'ha trencat. Sense tests, cada refactorització és un acte de fe.
Organització i convencions#
A mesura que el projecte creix, mantenir els tests organitzats es converteix en una necessitat. Algunes convencions ajuden a mantenir l'ordre:
// Nomenclatura descriptiva: descriu el comportament, no la implementació
// Bé:
test('users cannot publish articles without a title', function () { });
test('expired subscriptions are automatically cancelled', function () { });
// Malament:
test('test article validation', function () { });
test('test subscription method', function () { });
// Agrupar tests relacionats amb describe (Pest)
describe('article publishing', function () {
test('authors can publish their own articles', function () { });
test('editors can publish any article', function () { });
test('guests cannot publish articles', function () { });
});
// Compartir setup amb beforeEach (Pest)
beforeEach(function () {
$this->user = User::factory()->create();
$this->article = Article::factory()->create(['user_id' => $this->user->id]);
});
test('owner can edit article', function () {
$this->actingAs($this->user)
->get("/articles/{$this->article->id}/edit")
->assertOk();
});
test('other users cannot edit article', function () {
$otherUser = User::factory()->create();
$this->actingAs($otherUser)
->get("/articles/{$this->article->id}/edit")
->assertForbidden();
});La convenció més important és que el nom del test descrigui el comportament que es verifica, no la implementació. Un test anomenat "users cannot publish articles without a title" et diu exactament què esperar, mentre que "test article validation" no t'explica res. Quan un test falla, el seu nom hauria de ser suficient per entendre què s'ha trencat sense necessitat de llegir el codi del test.