Testing amb base de dades
Com testejar amb base de dades a Laravel: RefreshDatabase, LazilyRefreshDatabase, factories en tests, assertions de base de dades i estratègies de rendiment.
Per què testejar amb base de dades?#
Moltes funcionalitats d'una aplicació Laravel depenen de la base de dades: crear registres, consultar dades, actualitzar camps, verificar relacions i aplicar restriccions d'unicitat. Testejar aquestes funcionalitats sense base de dades seria com provar un cotxe sense motor: podries verificar que el volant gira, però no sabries si el cotxe realment avança.
Laravel facilita enormement el testing amb base de dades gràcies a tres components que treballen junts: els traits de refresh que garanteixen una base de dades neta per a cada test, les factories que generen dades de prova realistes amb una sola línia, i les assertions de base de dades que permeten verificar l'estat de les taules de manera expressiva.
El repte principal del testing amb base de dades és l'aïllament: cada test ha de començar amb un estat net i no ha de veure les dades creades per tests anteriors. Si un test crea un usuari amb l'email "joan@example.com" i el següent test espera que no existeixi cap usuari amb aquest email, el segon test fallarà. Laravel resol aquest problema automàticament amb els traits de refresh.
RefreshDatabase#
El trait RefreshDatabase és l'eina principal per mantenir la base de dades neta entre tests. Funciona executant les migracions al principi de la suite de tests i després embolcallant cada test individual en una transacció que es reverteix quan el test acaba. Això significa que els canvis fets per un test (inserts, updates, deletes) es desfan automàticament, deixant la base de dades exactament com estava.
use Illuminate\Foundation\Testing\RefreshDatabase;
// PHPUnit
class ArticleTest extends TestCase
{
use RefreshDatabase;
public function test_articles_can_be_created(): void
{
$this->post('/articles', [
'title' => 'El meu article',
'body' => 'Contingut de l\'article.',
]);
$this->assertDatabaseHas('articles', [
'title' => 'El meu article',
]);
}
}
// Pest - a nivell de fitxer
uses(RefreshDatabase::class);
test('articles can be created', function () {
$this->post('/articles', [
'title' => 'El meu article',
'body' => 'Contingut de l\'article.',
]);
$this->assertDatabaseHas('articles', [
'title' => 'El meu article',
]);
});Si utilitzes Pest amb la configuració global a tests/Pest.php, no cal repetir uses(RefreshDatabase::class) a cada fitxer de test funcional. La configuració global l'aplica automàticament a tots els tests de Feature/:
// tests/Pest.php
uses(Tests\TestCase::class, RefreshDatabase::class)
->in('Feature');LazilyRefreshDatabase#
LazilyRefreshDatabase és una alternativa a RefreshDatabase que només executa les migracions si algun test realment interactua amb la base de dades. Si tens una suite amb tests que no necessiten base de dades barrejats amb tests que sí, LazilyRefreshDatabase evita el cost de les migracions per als tests que no les necessiten:
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
uses(LazilyRefreshDatabase::class);
// Aquest test NO provoca migracions perquè no toca la BD
test('homepage returns ok', function () {
$this->get('/')->assertOk();
});
// Aquest test SÍ provoca migracions perquè crea un usuari
test('users can register', function () {
$this->post('/register', [
'name' => 'Joan',
'email' => 'joan@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$this->assertDatabaseHas('users', ['email' => 'joan@example.com']);
});DatabaseTransactions#
El trait DatabaseTransactions és similar a RefreshDatabase però no executa les migracions. Assumeix que la base de dades ja està migrada i simplement embolcalla cada test en una transacció. Això és útil si tens una base de dades de test persistent que ja conté l'esquema:
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);La diferència pràctica és la velocitat: DatabaseTransactions és més ràpid perquè no executa migracions, però requereix que la base de dades ja tingui l'esquema correcte. RefreshDatabase és més segur perquè sempre parteix d'un esquema fresc.
Factories en tests#
Les factories són la peça fonamental per generar dades de prova. En lloc d'escriure arrays llargs amb tots els camps d'un model, les factories generen dades realistes amb valors per defecte i permeten sobreescriure només els camps rellevants per al test:
// Crear un usuari amb tots els camps per defecte
$user = User::factory()->create();
// Crear un usuari amb un camp específic
$user = User::factory()->create([
'email' => 'joan@example.com',
'role' => 'admin',
]);
// Crear múltiples registres
$users = User::factory()->count(10)->create();
// Crear sense guardar a la BD (útil per tests unitaris)
$user = User::factory()->make();Relacions amb factories#
Les factories permeten crear estructures de dades complexes amb relacions en una sola expressió. Això és extremadament útil per preparar l'escenari d'un test:
test('user profile shows article count', function () {
// Crear un usuari amb 5 articles
$user = User::factory()
->has(Article::factory()->count(5))
->create();
$this->actingAs($user)
->get('/profile')
->assertSee('5 articles publicats');
});
test('article shows author name', function () {
// Crear un article amb un autor específic
$article = Article::factory()
->for(User::factory()->create(['name' => 'Edu Lázaro']))
->create();
$this->get("/articles/{$article->slug}")
->assertSee('Edu Lázaro');
});
test('category page lists its articles', function () {
$category = Category::factory()
->has(Article::factory()->published()->count(3))
->create();
$emptyCategory = Category::factory()->create();
$this->get("/categories/{$category->slug}")
->assertOk()
->assertViewHas('articles', function ($articles) {
return $articles->count() === 3;
});
});Estats de factory#
Els estats (states) permeten definir variacions reutilitzables d'un model. En lloc de repetir els mateixos atributs a cada test, defineixes l'estat a la factory i l'apliques quan el necessites:
// database/factories/ArticleFactory.php
class ArticleFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'body' => fake()->paragraphs(3, true),
'user_id' => User::factory(),
'published_at' => null,
];
}
public function published(): static
{
return $this->state(fn () => [
'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
]);
}
public function draft(): static
{
return $this->state(fn () => [
'published_at' => null,
]);
}
public function scheduled(): static
{
return $this->state(fn () => [
'published_at' => fake()->dateTimeBetween('+1 day', '+1 year'),
]);
}
public function featured(): static
{
return $this->state(fn () => [
'is_featured' => true,
]);
}
}// Ús en tests
test('homepage shows only published articles', function () {
Article::factory()->published()->count(3)->create();
Article::factory()->draft()->count(2)->create();
Article::factory()->scheduled()->count(1)->create();
$this->get('/')
->assertViewHas('articles', function ($articles) {
return $articles->count() === 3;
});
});
test('featured articles appear in sidebar', function () {
Article::factory()->published()->featured()->count(2)->create();
Article::factory()->published()->count(5)->create();
$this->get('/')
->assertViewHas('featured', function ($featured) {
return $featured->count() === 2;
});
});Sequences#
Les sequences permeten alternar valors entre múltiples registres creats amb la mateixa factory. Això és útil quan necessites variació controlada:
test('articles alternate between categories', function () {
Article::factory()
->count(6)
->sequence(
['category' => 'PHP'],
['category' => 'Laravel'],
['category' => 'JavaScript'],
)
->create();
// Resultat: PHP, Laravel, JavaScript, PHP, Laravel, JavaScript
$this->assertDatabaseCount('articles', 6);
expect(Article::where('category', 'PHP')->count())->toBe(2);
expect(Article::where('category', 'Laravel')->count())->toBe(2);
});
// Sequence amb closure per a valors dinàmics
$users = User::factory()
->count(3)
->sequence(fn ($sequence) => [
'name' => "Usuari {$sequence->index}",
])
->create();Assertions de base de dades#
Laravel proporciona assertions específiques per verificar l'estat de la base de dades. Aquestes assertions són molt més expressives que fer consultes manualment i comprovar els resultats:
// Verificar que un registre existeix amb certs atributs
$this->assertDatabaseHas('users', [
'email' => 'joan@example.com',
'name' => 'Joan Pere',
]);
// Verificar que un registre NO existeix
$this->assertDatabaseMissing('users', [
'email' => 'deleted@example.com',
]);
// Verificar el nombre total de registres
$this->assertDatabaseCount('users', 5);
// Verificar que la taula està buida
$this->assertDatabaseEmpty('articles');
// Verificar soft delete
$this->assertSoftDeleted('articles', [
'id' => $article->id,
]);
// Verificar que NO està soft deleted
$this->assertNotSoftDeleted('articles', [
'id' => $article->id,
]);
// Verificar un model específic
$this->assertModelExists($user);
$this->assertModelMissing($deletedUser);Combinant assertions amb lògica de negoci#
Les assertions de base de dades són especialment poderoses quan les combines amb accions del test per verificar efectes secundaris complets:
test('registering a user creates profile and sends welcome email', function () {
Mail::fake();
$this->post('/register', [
'name' => 'Maria',
'email' => 'maria@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
// Verificar que l'usuari s'ha creat
$this->assertDatabaseHas('users', [
'email' => 'maria@example.com',
]);
// Verificar que el perfil associat s'ha creat
$user = User::where('email', 'maria@example.com')->first();
$this->assertDatabaseHas('profiles', [
'user_id' => $user->id,
]);
// Verificar que s'ha enviat el correu
Mail::assertSent(WelcomeEmail::class, function ($mail) {
return $mail->hasTo('maria@example.com');
});
});
test('deleting a user cascades to related records', function () {
$user = User::factory()
->has(Article::factory()->count(3))
->has(Comment::factory()->count(5))
->create();
$userId = $user->id;
$this->actingAs($user)
->delete('/account');
$this->assertDatabaseMissing('users', ['id' => $userId]);
$this->assertDatabaseMissing('articles', ['user_id' => $userId]);
$this->assertDatabaseMissing('comments', ['user_id' => $userId]);
});Configuració de la base de dades de test#
SQLite en memòria#
La configuració més ràpida per a tests és SQLite en memòria. La base de dades existeix només a la RAM, no toca el disc i es destrueix automàticament quan el procés acaba. Configura-la al fitxer .env.testing o a phpunit.xml:
DB_CONNECTION=sqlite
DB_DATABASE=:memory:SQLite en memòria té un avantatge enorme en velocitat, però també algunes limitacions. SQLite no suporta algunes funcionalitats de MySQL/PostgreSQL: columnes JSON amb operacions complexes, algunes restriccions de clau forana, algunes funcions de data i tipus de dades específics. Si la teva aplicació utilitza funcionalitats específiques de MySQL o PostgreSQL, pots trobar-te amb tests que passen amb SQLite però fallen en producció (o a l'inrevés).
Base de dades MySQL/PostgreSQL de test#
Per a tests que necessiten compatibilitat total amb la base de dades de producció, pots configurar una base de dades dedicada per a tests. Això és més lent que SQLite en memòria, però garanteix que els tests es comporten exactament com en producció:
DB_CONNECTION=mysql
DB_DATABASE=laravel_testing
DB_USERNAME=root
DB_PASSWORD=Una bona estratègia és utilitzar SQLite en memòria per defecte (la majoria de tests no necessiten funcionalitats específiques de MySQL) i crear un grup separat de tests que s'executin contra MySQL per als casos que ho requereixin.
Execució paral·lela amb bases de dades#
Quan executes tests en paral·lel amb --parallel, Laravel crea automàticament una base de dades per a cada procés. Per a SQLite en memòria, cada procés té la seva pròpia base de dades en memòria (no cal configurar res addicional). Per a MySQL/PostgreSQL, Laravel crea bases de dades amb sufixos numèrics (laravel_testing_1, laravel_testing_2, etc.):
php artisan test --parallelSi necessites executar codi específic quan es creen o es destrueixen les bases de dades paral·leles (per exemple, per configurar extensions o taules especials), pots utilitzar els callbacks ParallelTesting::setUpTestDatabase() i ParallelTesting::tearDownTestDatabase() al teu TestCase.php.
Seeders en tests#
De vegades necessites dades preconstruïdes que no canvien entre tests: categories, rols, plans de subscripció, configuracions per defecte. Els seeders permeten carregar aquestes dades base:
test('articles can be filtered by category', function () {
// Executar un seeder específic
$this->seed(CategorySeeder::class);
$php = Category::where('slug', 'php')->first();
Article::factory()->count(3)->create(['category_id' => $php->id]);
Article::factory()->count(2)->create();
$this->get("/articles?category=php")
->assertViewHas('articles', function ($articles) {
return $articles->count() === 3;
});
});
// Executar tots els seeders
test('application works with seeded data', function () {
$this->seed();
$this->get('/')->assertOk();
});Cal tenir en compte que els seeders s'executen dins de la transacció del test, cosa que significa que les dades del seeder es reverteixen juntament amb les dades del test. No cal netejar res manualment.
Bones pràctiques#
Crear només les dades necessàries#
Cada test hauria de crear només les dades que necessita per verificar el comportament que prova. Crear dades innecessàries fa els tests més lents i més difícils d'entendre:
// Malament: crea dades que no utilitza
test('user can view their profile', function () {
$user = User::factory()
->has(Article::factory()->count(10))
->has(Comment::factory()->count(50))
->create();
$this->actingAs($user)
->get('/profile')
->assertOk();
});
// Bé: crea només el que necessita
test('user can view their profile', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/profile')
->assertOk();
});Verificar l'estat final, no els passos intermedis#
Un bon test verifica el resultat final de l'operació, no cada pas intermedi. Això fa els tests més robusts davant refactoritzacions internes:
// Malament: verifica implementació interna
test('article creation process', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', ['title' => 'Test', 'body' => 'Contingut del test.'])
->assertRedirect();
// Massa acoblat a la implementació interna
expect(Article::count())->toBe(1);
expect(Article::first()->user_id)->toBe($user->id);
expect(Article::first()->slug)->toBe('test');
expect(Article::first()->status)->toBe('draft');
});
// Bé: verifica el resultat final
test('users can create articles', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/articles', ['title' => 'Test', 'body' => 'Contingut del test.'])
->assertRedirect('/articles');
$this->assertDatabaseHas('articles', [
'title' => 'Test',
'user_id' => $user->id,
]);
});Evitar tests fràgils#
Un test fràgil és un test que falla per raons que no tenen res a veure amb el que prova. Per exemple, un test que depèn de l'ordre dels registres, del format exacte d'un timestamp o del contingut d'un seeder que pot canviar:
// Fràgil: depèn de l'ordre dels registres
test('returns first article', function () {
Article::factory()->count(3)->create();
$this->getJson('/api/articles/first')
->assertJson(['id' => 1]); // L'ID pot no ser 1
});
// Robust: utilitza l'article creat directament
test('returns specific article', function () {
$article = Article::factory()->create(['title' => 'Específic']);
$this->getJson("/api/articles/{$article->id}")
->assertJson(['title' => 'Específic']);
});