Tests funcionals
Com escriure tests funcionals (feature tests) a Laravel per testejar peticions HTTP, formularis, APIs, autenticació i fluxos complets de l'aplicació.
Què són els tests funcionals?#
Els tests funcionals (feature tests) proven l'aplicació des de la perspectiva de l'usuari: fan peticions HTTP, envien formularis, verifiquen respostes i comproven que els fluxos complets funcionen correctament. A diferència dels tests unitaris que proven peces aïllades de codi, els tests funcionals proven la integració entre totes les capes: rutes, middleware, controladors, validació, models, base de dades i vistes.
Quan un test funcional fa $this->post('/articles', [...]), Laravel simula una petició HTTP completa: el kernel processa la petició, el router la dirigeix al controlador correcte, el middleware verifica l'autenticació, el controlador executa la lògica de negoci, el model guarda les dades a la base de dades i el controlador retorna una resposta. Tot això passa en una fracció de segon perquè la petició no passa per una connexió de xarxa real, sinó que es processa internament dins del framework.
Aquesta simulació és el que fa que els tests funcionals de Laravel siguin tan poderosos: proven el flux complet de l'aplicació amb un nivell de confiança molt alt, però sense la lentitud i la fragilitat dels tests de navegador que interactuen amb un navegador real.
Tests HTTP bàsics#
Els mètodes get(), post(), put(), patch() i delete() simulen peticions HTTP a l'aplicació. Retornen un objecte TestResponse que proporciona mètodes d'assertion per verificar l'status, el contingut, les redireccions, la sessió i molt més:
use App\Models\User;
use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('homepage loads successfully', function () {
$this->get('/')
->assertStatus(200)
->assertSee('Laravel Andorra');
});
test('authenticated users can view dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/dashboard')
->assertOk()
->assertViewIs('dashboard')
->assertSee("Benvingut, {$user->name}");
});
test('guests are redirected from dashboard to login', function () {
$this->get('/dashboard')
->assertRedirect('/login');
});
test('non-existent pages return 404', function () {
$this->get('/pagina-que-no-existeix')
->assertNotFound();
});El mètode actingAs() estableix l'usuari autenticat per a la petició. Això simula que l'usuari ha iniciat sessió sense necessitat de fer el procés de login complet. Si necessites testejar el guard específic (per exemple, el guard api per a tokens), pots passar-lo com a segon paràmetre: $this->actingAs($user, 'api').
Testejar vistes#
Quan una ruta retorna una vista Blade, pots verificar tant la vista renderitzada com les dades que rep. Això és especialment útil per assegurar-te que el controlador passa les dades correctes a la vista:
test('articles index shows published articles', function () {
$published = Article::factory()->published()->count(3)->create();
$draft = Article::factory()->draft()->count(2)->create();
$this->get('/articles')
->assertOk()
->assertViewIs('articles.index')
->assertViewHas('articles', function ($articles) {
return $articles->count() === 3;
});
});
test('article show page displays correct article', function () {
$article = Article::factory()->published()->create([
'title' => 'Laravel a Andorra',
]);
$this->get("/articles/{$article->slug}")
->assertOk()
->assertViewIs('articles.show')
->assertViewHas('article', $article)
->assertSee('Laravel a Andorra');
});
test('article show page includes meta tags', function () {
$article = Article::factory()->published()->create([
'title' => 'Guia de Laravel',
'description' => 'Una guia completa per aprendre Laravel.',
]);
$this->get("/articles/{$article->slug}")
->assertSee('<meta name="description"', false)
->assertSee('Una guia completa per aprendre Laravel.');
});El mètode assertViewHas() accepta tres formes: assertViewHas('key') verifica que la variable existeix, assertViewHas('key', $value) verifica el valor exacte, i assertViewHas('key', function ($value) { ... }) permet verificacions personalitzades amb un closure.
Testejar formularis i validació#
Els tests de formularis són dels més importants perquè cobreixen la interacció principal entre l'usuari i l'aplicació. Un test complet verifica tant el cas d'èxit (dades vàlides) com els casos d'error (dades invàlides, camps obligatoris, formats incorrectes):
test('users can create articles', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'title' => 'El meu primer article',
'body' => 'Contingut de l\'article amb suficient text per passar la validació.',
'category_id' => 1,
])
->assertRedirect('/articles')
->assertSessionHas('success', 'Article creat correctament.');
$this->assertDatabaseHas('articles', [
'title' => 'El meu primer article',
'user_id' => $user->id,
]);
});
test('title is required', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'body' => 'Contingut sense títol.',
])
->assertSessionHasErrors('title');
$this->assertDatabaseCount('articles', 0);
});
test('title must be at most 255 characters', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'title' => str_repeat('a', 256),
'body' => 'Contingut vàlid.',
])
->assertSessionHasErrors(['title']);
});
test('body must have at least 10 characters', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'title' => 'Títol vàlid',
'body' => 'Curt',
])
->assertSessionHasErrors('body');
});
test('multiple validation errors are returned', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [])
->assertSessionHasErrors(['title', 'body']);
});Verificar missatges d'error específics#
A vegades no n'hi ha prou amb saber que hi ha un error de validació: vols verificar el missatge exacte. Això és especialment important si tens missatges personalitzats o traduccions:
test('shows specific validation messages', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', ['title' => '', 'body' => ''])
->assertSessionHasErrors([
'title' => 'El camp títol és obligatori.',
'body' => 'El camp cos és obligatori.',
]);
});Testejar actualització i eliminació#
test('users can update their articles', function () {
$user = User::factory()->create();
$article = Article::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->put("/articles/{$article->id}", [
'title' => 'Títol actualitzat',
'body' => 'Contingut actualitzat amb suficient text.',
])
->assertRedirect("/articles/{$article->id}");
expect($article->fresh()->title)->toBe('Títol actualitzat');
});
test('users can delete their articles', function () {
$user = User::factory()->create();
$article = Article::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->delete("/articles/{$article->id}")
->assertRedirect('/articles');
$this->assertDatabaseMissing('articles', ['id' => $article->id]);
});
test('users cannot delete articles from other users', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$article = Article::factory()->create(['user_id' => $otherUser->id]);
$this->actingAs($user)
->delete("/articles/{$article->id}")
->assertForbidden();
$this->assertDatabaseHas('articles', ['id' => $article->id]);
});Testejar autenticació#
Els fluxos d'autenticació (registre, login, logout, reset de contrasenya) són crítics i haurien de tenir cobertura completa:
test('users can register', function () {
$this->post('/register', [
'name' => 'Joan Pere',
'email' => 'joan@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertRedirect('/dashboard');
$this->assertAuthenticated();
$this->assertDatabaseHas('users', [
'email' => 'joan@example.com',
]);
});
test('users can login', function () {
$user = User::factory()->create([
'email' => 'joan@example.com',
]);
$this->post('/login', [
'email' => 'joan@example.com',
'password' => 'password',
])->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
});
test('users cannot login with wrong password', function () {
$user = User::factory()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
});
test('users can logout', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/logout')
->assertRedirect('/');
$this->assertGuest();
});Testejar APIs JSON#
Les peticions JSON utilitzen els mètodes getJson(), postJson(), putJson() i deleteJson(). Aquests mètodes afegeixen automàticament els headers Accept: application/json i Content-Type: application/json, cosa que fa que Laravel retorni errors de validació en format JSON en lloc de redireccions:
test('api returns paginated articles', function () {
Article::factory()->published()->count(25)->create();
$this->getJson('/api/articles')
->assertOk()
->assertJsonCount(15, 'data') // 15 per pàgina per defecte
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'slug', 'excerpt', 'published_at'],
],
'meta' => ['current_page', 'last_page', 'total'],
]);
});
test('api returns single article', function () {
$article = Article::factory()->published()->create([
'title' => 'Laravel és genial',
]);
$this->getJson("/api/articles/{$article->id}")
->assertOk()
->assertJson([
'data' => [
'id' => $article->id,
'title' => 'Laravel és genial',
],
]);
});
test('api creates article with valid data', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/articles', [
'title' => 'Nou article via API',
'body' => 'Contingut de l\'article creat via API.',
])
->assertCreated()
->assertJson([
'data' => [
'title' => 'Nou article via API',
],
]);
});
test('api returns validation errors as json', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/articles', [])
->assertUnprocessable()
->assertJsonValidationErrors(['title', 'body']);
});
test('api requires authentication', function () {
$this->getJson('/api/user')
->assertUnauthorized();
});La diferència entre assertJson() i assertExactJson() és important. assertJson() verifica que la resposta conté les claus i valors especificats (pot tenir claus addicionals). assertExactJson() verifica que la resposta és exactament igual al JSON especificat (ni una clau de més ni de menys).
Testejar middleware#
El middleware es pot testejar indirectament (verificant el seu efecte en les peticions) o directament (desactivant-lo per aïllar el controlador):
// Testejar que el middleware funciona (test indirecte)
test('admin routes require admin role', function () {
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get('/admin/dashboard')
->assertForbidden();
});
test('admin can access admin routes', function () {
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->get('/admin/dashboard')
->assertOk();
});
// Desactivar middleware per testejar el controlador aïlladament
test('controller logic works without middleware', function () {
$this->withoutMiddleware()
->get('/admin/dashboard')
->assertOk();
});
// Desactivar un middleware específic
test('bypasses throttle for testing', function () {
$user = User::factory()->create();
$this->withoutMiddleware(\Illuminate\Routing\Middleware\ThrottleRequests::class)
->actingAs($user)
->post('/api/action')
->assertOk();
});Testejar redireccions i sessions#
Les redireccions i les dades de sessió són fonamentals en aplicacions web tradicionals. Laravel proporciona assertions específiques per verificar-les:
test('successful action redirects with success message', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'title' => 'Nou article',
'body' => 'Contingut suficientment llarg per la validació.',
])
->assertRedirect('/articles')
->assertSessionHas('success');
});
test('form preserves old input on validation error', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', [
'title' => 'Títol correcte',
'body' => '',
])
->assertSessionHasErrors('body');
// Laravel preserva automàticament l'input anterior
// perquè el formulari el pugui mostrar
});
test('flash messages are available after redirect', function () {
$user = User::factory()->create();
$article = Article::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->delete("/articles/{$article->id}")
->assertRedirect('/articles')
->assertSessionHas('success', 'Article eliminat.');
});Testejar pujada de fitxers#
Laravel permet simular la pujada de fitxers amb UploadedFile::fake(). Combinat amb Storage::fake(), pots testejar la pujada sense escriure realment fitxers al disc:
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('users can upload avatar', function () {
Storage::fake('public');
$user = User::factory()->create();
$this->actingAs($user)
->post('/profile/avatar', [
'avatar' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
])
->assertRedirect('/profile');
// Verificar que el fitxer existeix al disc fals
Storage::disk('public')->assertExists("avatars/{$user->id}.jpg");
});
test('avatar must be an image', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/profile/avatar', [
'avatar' => UploadedFile::fake()->create('document.pdf', 500),
])
->assertSessionHasErrors('avatar');
});
test('avatar must be smaller than 2MB', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/profile/avatar', [
'avatar' => UploadedFile::fake()->image('huge.jpg')->size(3000),
])
->assertSessionHasErrors('avatar');
});Assertions de resposta#
Laravel proporciona una gran quantitat d'assertions per verificar les respostes HTTP. Aquí tens les més utilitzades agrupades per categoria:
// Status HTTP
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertAccepted(); // 202
$response->assertNoContent(); // 204
$response->assertMovedPermanently(); // 301
$response->assertRedirect('/path'); // 302
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
$response->assertUnprocessable(); // 422
$response->assertServerError(); // 500
$response->assertStatus(418); // Qualsevol codi
// Contingut
$response->assertSee('text'); // Text visible
$response->assertDontSee('text'); // Text absent
$response->assertSeeText('text'); // Text sense HTML
$response->assertSeeInOrder(['a', 'b']); // Ordre correcte
// Vistes
$response->assertViewIs('nom.vista');
$response->assertViewHas('key');
$response->assertViewHas('key', 'value');
$response->assertViewMissing('key');
// JSON
$response->assertJson(['key' => 'value']);
$response->assertExactJson([...]);
$response->assertJsonCount(3, 'data');
$response->assertJsonStructure(['data' => ['id', 'title']]);
$response->assertJsonPath('data.0.title', 'Primer');
$response->assertJsonMissing(['key' => 'value']);
$response->assertJsonValidationErrors(['field']);
$response->assertJsonMissingValidationErrors(['field']);
// Sessió
$response->assertSessionHas('key');
$response->assertSessionHas('key', 'value');
$response->assertSessionHasErrors('field');
$response->assertSessionHasErrors(['field1', 'field2']);
$response->assertSessionHasNoErrors();
$response->assertSessionMissing('key');
// Headers
$response->assertHeader('Content-Type', 'application/json');
$response->assertHeaderMissing('X-Custom');
// Cookies
$response->assertCookie('name');
$response->assertCookie('name', 'value');
$response->assertCookieMissing('name');Aquestes assertions fan que els tests siguin molt expressius. En lloc de descodificar manualment la resposta i comparar valors, cada assertion descriu exactament què s'espera de la resposta. Quan un test falla, el missatge d'error indica clarament què no coincideix, facilitant la depuració.
Testejar amb temps congelat#
Moltes funcionalitats depenen del temps: subscripcions que expiren, tokens que caduquen, contingut que es publica a una data programada. Els helpers de temps de Laravel permeten controlar el rellotge durant els tests:
use Illuminate\Support\Carbon;
test('trial expires after 14 days', function () {
$user = User::factory()->create();
// Avançar 13 dies: encara dins del trial
$this->travel(13)->days();
expect($user->fresh()->onTrial())->toBeTrue();
// Avançar 1 dia més (14 total): trial expirat
$this->travel(1)->days();
expect($user->fresh()->onTrial())->toBeFalse();
});
test('scheduled articles are published at the correct time', function () {
$article = Article::factory()->create([
'published_at' => Carbon::parse('2024-06-15 10:00:00'),
]);
// Abans de la publicació
$this->travelTo(Carbon::parse('2024-06-15 09:59:59'));
expect($article->fresh()->isPublished())->toBeFalse();
// Moment de la publicació
$this->travelTo(Carbon::parse('2024-06-15 10:00:01'));
expect($article->fresh()->isPublished())->toBeTrue();
});
test('password reset token expires after 60 minutes', function () {
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
// 59 minuts: token encara vàlid
$this->travel(59)->minutes();
// ... verificar que el token funciona
// 61 minuts: token expirat
$this->travel(2)->minutes();
// ... verificar que el token ja no funciona
});El mètode freezeTime() congela el temps al moment actual, cosa que és útil per a tests on necessites que now() retorni sempre el mateix valor durant tot el test. travelBack() restaura el rellotge al temps real.