Módulo 13: Testing

Objetivos del módulo

  • Distinguir los tipos de test de una aplicación Symfony y saber cuándo usar cada uno
  • Instalar y configurar PHPUnit con symfony/test-pack y phpunit.dist.xml
  • Preparar una base de datos de test aislada, con rollback automático y datos creados con Foundry
  • Escribir tests unitarios de servicios usando stubs y mocks
  • Escribir tests de integración con KernelTestCase y el contenedor de servicios
  • Escribir tests funcionales con WebTestCase: peticiones, crawler, formularios, usuarios autenticados y APIs JSON
  • Probar comandos de consola
  • Conocer los asserts específicos de Symfony, medir la cobertura y ejecutar los tests en GitHub Actions
  • Saber qué aportan Panther y Playwright para los tests end-to-end

1. ¿Por qué y qué testear?

📘 Concepto: Un test automatizado es código que ejecuta tu código y comprueba que el resultado es el esperado. Es como el control de calidad de una fábrica: no garantiza que nunca haya fallos, pero detecta la mayoría antes de que el producto llegue al cliente.

Sin tests, cada cambio es un salto al vacío: “¿habré roto el login al tocar el formulario de artículos?”. Con tests, ejecutas un comando y en segundos lo sabes. Además, los tests sirven de documentación viva: muestran cómo se usa cada pieza.

Tipos de test en Symfony

Tipo Qué prueba Clase base Velocidad Ejemplo en el blog
Unitario Una clase aislada, sin framework ni BD PHPUnit\Framework\TestCase Milisegundos Slugger, ReadingTimeCalculator
Integración Varias piezas reales juntas (servicios + BD) KernelTestCase Rápido ArticleRepository::paginatePublished()
Funcional / aplicación Una petición HTTP completa, de la ruta a la respuesta WebTestCase Medio “El listado muestra 10 artículos”
End-to-end (E2E) Un navegador real, con JavaScript Panther / Playwright Lento “El botón de Turbo carga más artículos”
flowchart TB
    E2E["E2E (pocos)"] --> F["Funcionales (bastantes)"]
    F --> I["Integración (bastantes)"]
    I --> U["Unitarios (muchos)"]

💡 Consejo: Sigue la pirámide de tests: muchos unitarios (baratos y rápidos), bastantes funcionales que cubran los flujos importantes y unos pocos E2E para lo que solo se puede comprobar con JavaScript real.


2. Instalación y configuración

El test pack

composer require --dev symfony/test-pack

Instala, entre otros:

Paquete Para qué
phpunit/phpunit El framework de tests (PHPUnit 12/13)
symfony/browser-kit El “navegador simulado” de WebTestCase
symfony/css-selector Seleccionar elementos con selectores CSS en el crawler

La receta de Flex crea:

  • phpunit.dist.xml: la configuración de PHPUnit.
  • tests/bootstrap.php: carga el autoload y las variables de entorno.
  • .env.test: variables propias del entorno test.
  • bin/phpunit: un atajo para ejecutar PHPUnit.

phpunit.dist.xml

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         colors="true"
         failOnDeprecation="true"
         failOnNotice="true"
         failOnWarning="true"
         bootstrap="tests/bootstrap.php"
         cacheDirectory=".phpunit.cache"
>
    <php>
        <ini name="display_errors" value="1" />
        <ini name="error_reporting" value="-1" />
        <!-- fuerza el entorno "test" -->
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
    </php>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <source ignoreSuppressionOfDeprecations="true"
            ignoreIndirectDeprecations="true"
            restrictNotices="true"
            restrictWarnings="true">
        <include>
            <directory>src</directory>
        </include>
    </source>

    <extensions>
        <!-- aquí registrarás DAMA y Foundry -->
    </extensions>
</phpunit>

📘 Concepto: El fichero se llama .dist porque es la configuración distribuida con el proyecto (va al repositorio). Si un desarrollador necesita algo distinto en su máquina, crea phpunit.xml (sin .dist), que tiene prioridad y está en .gitignore.

Fíjate en failOnDeprecation="true": si tu código (o el de Symfony por culpa de tu código) dispara un aviso de obsolescencia, el test falla. Es la mejor forma de llegar preparado a la siguiente versión mayor de Symfony.

Ejecutar los tests

# todos los tests
php bin/phpunit

# una carpeta o un fichero
php bin/phpunit tests/Controller
php bin/phpunit tests/Service/SluggerTest.php

# solo los tests cuyo nombre coincide
php bin/phpunit --filter testHomepage

# salida en formato "documentación"
php bin/phpunit --testdox

Salida esperada:

PHPUnit 12.x by Sebastian Bergmann and contributors.

......                                                              6 / 6 (100%)

Time: 00:01.214, Memory: 48.00 MB

OK (6 tests, 14 assertions)

El entorno test y .env.test

Los tests se ejecutan en el entorno test (lo fuerza APP_ENV en phpunit.dist.xml). Eso significa:

  • Se cargan .env, .env.test y .env.test.local (no se carga .env.local, para que los tests sean reproducibles en cualquier máquina).
  • La configuración bajo when@test: en config/packages/*.yaml se aplica.
  • El profiler y la barra de depuración suelen estar desactivados, y el hash de contraseñas usa un coste mínimo para ir más rápido.
# config/packages/security.yaml (lo añade la receta)
when@test:
    security:
        password_hashers:
            Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
                algorithm: auto
                cost: 4          # el mínimo para bcrypt: tests mucho más rápidos
                time_cost: 3
                memory_cost: 10

Y para que los correos y mensajes de Messenger no salgan de verdad durante los tests:

# config/packages/messenger.yaml
when@test:
    framework:
        messenger:
            transports:
                async: 'in-memory://'   # los mensajes se quedan en memoria

⚠️ Importante: .env.test se sube al repositorio. Los valores que dependan de cada máquina (usuario y contraseña de tu base de datos local) van en .env.test.local, que no se sube.


3. La base de datos de test

Una base de datos separada

Nunca ejecutes tests contra tu base de datos de desarrollo: los tests crean y borran datos sin piedad. La receta de DoctrineBundle ya lo resuelve con un sufijo:

# config/packages/doctrine.yaml (fragmento de la receta)
when@test:
    doctrine:
        dbal:
            # "blog" en dev => "blog_test" en test
            dbname_suffix: '_test%env(default::TEST_TOKEN)%'

📘 Concepto: TEST_TOKEN lo definen herramientas como ParaTest cuando ejecutan tests en paralelo, para que cada proceso use su propia base de datos (blog_test1, blog_test2…). Si no lo usas, queda vacío y la base de datos es simplemente blog_test.

Crea la base de datos y el esquema una vez:

php bin/console --env=test doctrine:database:create
php bin/console --env=test doctrine:schema:create
# o, si prefieres probar también tus migraciones:
php bin/console --env=test doctrine:migrations:migrate --no-interaction

Aislamiento entre tests con DAMADoctrineTestBundle

Cada test debe empezar con la base de datos en el mismo estado. Si un test crea un artículo y el siguiente cuenta artículos, el segundo dependería del primero. DAMADoctrineTestBundle resuelve esto con un truco elegante: abre una transacción antes de cada test y hace rollback al terminar.

composer require --dev dama/doctrine-test-bundle
<!-- phpunit.dist.xml -->
<extensions>
    <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>

💡 Consejo: El rollback es muchísimo más rápido que borrar y recrear tablas. En una suite con cientos de tests funcionales, la diferencia puede ser de minutos a segundos.

Datos de prueba con Foundry

En el módulo 06 usaste Foundry para las fixtures. Las mismas factories son perfectas en los tests, porque cada test crea exactamente los datos que necesita y se lee como una historia.

composer require --dev zenstruck/foundry

Activa Foundry globalmente con su extensión de PHPUnit:

<!-- phpunit.dist.xml -->
<extensions>
    <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
    <bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension"/>
</extensions>

Y marca con #[ResetDatabase] las clases de test que usan la base de datos:

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class ArticleControllerTest extends WebTestCase
{
    // ...
}

#[ResetDatabase] crea la base de datos y el esquema antes del primer test. Si detecta DAMADoctrineTestBundle, deja que sea este el que limpie entre tests con el rollback (lo más rápido).

⚠️ Importante: En proyectos con versiones antiguas de Foundry o de PHPUnit verás los traits use Factories, ResetDatabase; dentro de la clase de test. Hacen lo mismo; el atributo y la extensión son la forma actual.

Recordatorio de una factory del blog:

<?php
// src/Factory/ArticleFactory.php
declare(strict_types=1);

namespace App\Factory;

use App\Entity\Article;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;

/** @extends PersistentObjectFactory<Article> */
final class ArticleFactory extends PersistentObjectFactory
{
    public static function class(): string
    {
        return Article::class;
    }

    protected function defaults(): array|callable
    {
        $title = self::faker()->sentence(6);

        return [
            'title' => $title,
            'slug' => self::faker()->unique()->slug(),
            'content' => self::faker()->paragraphs(5, true),
            'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTimeBetween('-1 year')),
            'publishedAt' => new \DateTimeImmutable('-1 day'),
            'author' => UserFactory::new(),
            'category' => CategoryFactory::new(),
        ];
    }

    // "estados" con nombre para que los tests se lean mejor
    public function draft(): static
    {
        return $this->with(['publishedAt' => null]);
    }

    public function scheduledFor(\DateTimeImmutable $date): static
    {
        return $this->with(['publishedAt' => null, 'scheduledAt' => $date]);
    }
}
// Uso en un test
ArticleFactory::createMany(5);                                 // 5 publicados
ArticleFactory::new()->draft()->create(['title' => 'Borrador']);
$ana = UserFactory::createOne(['email' => 'ana@blog.test', 'roles' => ['ROLE_AUTHOR']]);
ArticleFactory::createMany(3, ['author' => $ana]);

📘 Concepto: En el proyecto del curso, Article tiene además una propiedad scheduledAt (fecha programada de publicación, null si no está programado). La usaremos en este módulo y en el de comandos de consola.


4. Tests unitarios de servicios

Un test unitario prueba una clase sin arrancar Symfony. Es el tipo de test más rápido y el que más precisión da cuando falla.

Un servicio sin dependencias

<?php
// src/Service/ReadingTimeCalculator.php
declare(strict_types=1);

namespace App\Service;

final class ReadingTimeCalculator
{
    public function __construct(private readonly int $wordsPerMinute = 200)
    {
    }

    /** Minutos de lectura estimados (mínimo 1) */
    public function minutes(string $text): int
    {
        $words = str_word_count(strip_tags($text));

        return max(1, (int) ceil($words / $this->wordsPerMinute));
    }
}
<?php
// tests/Service/ReadingTimeCalculatorTest.php
declare(strict_types=1);

namespace App\Tests\Service;

use App\Service\ReadingTimeCalculator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ReadingTimeCalculatorTest extends TestCase
{
    public function testShortTextTakesAtLeastOneMinute(): void
    {
        $calculator = new ReadingTimeCalculator();

        self::assertSame(1, $calculator->minutes('Hola mundo'));
    }

    #[DataProvider('provideTexts')]
    public function testMinutes(int $words, int $expected): void
    {
        $calculator = new ReadingTimeCalculator(wordsPerMinute: 100);
        $text = str_repeat('palabra ', $words);

        self::assertSame($expected, $calculator->minutes($text));
    }

    /** @return iterable<string, array{int, int}> */
    public static function provideTexts(): iterable
    {
        yield 'exacto' => [200, 2];
        yield 'redondea hacia arriba' => [201, 3];
        yield 'vacío' => [0, 1];
    }

    public function testIgnoresHtmlTags(): void
    {
        $calculator = new ReadingTimeCalculator(wordsPerMinute: 1);

        self::assertSame(2, $calculator->minutes('<p>Dos <strong>palabras</strong></p>'));
    }
}

⚠️ Importante: Desde PHPUnit 12 las anotaciones en comentarios (@dataProvider, @test…) ya no funcionan: usa los atributos de PHPUnit\Framework\Attributes (#[DataProvider], #[Test], #[Group]…). Los métodos data provider deben ser public static.

Stubs y mocks

Cuando el servicio depende de otros (repositorio, bus de mensajes, reloj…), los sustituyes por dobles de test:

📘 Concepto: Un stub es un doble que solo devuelve respuestas preparadas (“cuando te pidan los artículos pendientes, devuelve estos dos”). Un mock además verifica cómo se le llama (“espero que dispatch() se llame exactamente dos veces”). Usa createStub() para lo primero y createMock() con expects() para lo segundo.

El servicio a probar publica los artículos programados cuya fecha ya ha llegado:

<?php
// src/Service/ArticlePublisher.php
declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use App\Message\SendNewArticleNotification;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Clock\ClockInterface;
use Symfony\Component\Messenger\MessageBusInterface;

final class ArticlePublisher
{
    public function __construct(
        private readonly ArticleRepository $articles,
        private readonly EntityManagerInterface $em,
        private readonly MessageBusInterface $bus,
        private readonly ClockInterface $clock,
    ) {
    }

    public function publish(Article $article): void
    {
        if (null !== $article->getPublishedAt()) {
            throw new \LogicException(sprintf('El artículo "%s" ya está publicado.', $article->getTitle()));
        }

        $article->setPublishedAt($this->clock->now());
        $article->setScheduledAt(null);
    }

    /** Publica los artículos programados vencidos y devuelve cuántos ha publicado */
    public function publishDue(): int
    {
        $due = $this->articles->findDueForPublication($this->clock->now());

        foreach ($due as $article) {
            $this->publish($article);
        }
        $this->em->flush();

        // tras el flush, avisamos (email asíncrono del módulo 11)
        foreach ($due as $article) {
            $this->bus->dispatch(new SendNewArticleNotification($article->getId()));
        }

        return count($due);
    }
}

El test unitario, con dobles para todo lo externo:

<?php
// tests/Service/ArticlePublisherTest.php
declare(strict_types=1);

namespace App\Tests\Service;

use App\Entity\Article;
use App\Message\SendNewArticleNotification;
use App\Repository\ArticleRepository;
use App\Service\ArticlePublisher;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;

final class ArticlePublisherTest extends TestCase
{
    public function testPublishDueArticles(): void
    {
        $now = new MockClock('2026-10-03 09:00:00');   // el tiempo "congelado"

        $a1 = (new Article())->setTitle('Uno')->setScheduledAt(new \DateTimeImmutable('2026-10-03 08:00'));
        $a2 = (new Article())->setTitle('Dos')->setScheduledAt(new \DateTimeImmutable('2026-10-02 20:00'));

        // STUB: solo devuelve datos
        $repository = $this->createStub(ArticleRepository::class);
        $repository->method('findDueForPublication')->willReturn([$a1, $a2]);

        // MOCK: verificamos que se hace flush una sola vez
        $em = $this->createMock(EntityManagerInterface::class);
        $em->expects($this->once())->method('flush');

        // MOCK: verificamos que se envían 2 mensajes del tipo correcto
        $bus = $this->createMock(MessageBusInterface::class);
        $bus->expects($this->exactly(2))
            ->method('dispatch')
            ->with($this->isInstanceOf(SendNewArticleNotification::class))
            ->willReturnCallback(fn (object $message): Envelope => new Envelope($message));

        $publisher = new ArticlePublisher($repository, $em, $bus, $now);

        self::assertSame(2, $publisher->publishDue());
        self::assertEquals($now->now(), $a1->getPublishedAt());
        self::assertNull($a1->getScheduledAt());
    }

    public function testCannotPublishTwice(): void
    {
        $article = (new Article())->setTitle('Ya publicado')->setPublishedAt(new \DateTimeImmutable());

        $publisher = new ArticlePublisher(
            $this->createStub(ArticleRepository::class),
            $this->createStub(EntityManagerInterface::class),
            $this->createStub(MessageBusInterface::class),
            new MockClock(),
        );

        $this->expectException(\LogicException::class);
        $this->expectExceptionMessage('ya está publicado');

        $publisher->publish($article);
    }
}

💡 Consejo: Inyectar Psr\Clock\ClockInterface en lugar de hacer new \DateTimeImmutable() dentro del servicio es lo que permite congelar el tiempo con MockClock. Cualquier lógica que dependa de “ahora” (publicaciones programadas, caducidad de tokens…) debería usar el reloj inyectado.

⚠️ Importante: No abuses de los mocks. Si para probar una clase necesitas simular diez dependencias, el test será frágil y difícil de leer; probablemente esa clase hace demasiado, o es mejor cubrirla con un test de integración.


5. Tests de integración con KernelTestCase

KernelTestCase arranca el kernel de Symfony y te da acceso al contenedor de servicios real, con la base de datos de test.

<?php
// tests/Repository/ArticleRepositoryTest.php
declare(strict_types=1);

namespace App\Tests\Repository;

use App\Dto\ArticleListQuery;
use App\Factory\ArticleFactory;
use App\Factory\CategoryFactory;
use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class ArticleRepositoryTest extends KernelTestCase
{
    private ArticleRepository $repository;

    protected function setUp(): void
    {
        self::bootKernel();
        // static::getContainer() da acceso incluso a servicios privados
        $this->repository = static::getContainer()->get(ArticleRepository::class);
    }

    public function testPaginatePublishedIgnoresDraftsAndFutureArticles(): void
    {
        ArticleFactory::createMany(3);                                             // publicados
        ArticleFactory::new()->draft()->create();                                  // borrador
        ArticleFactory::createOne(['publishedAt' => new \DateTimeImmutable('+1 week')]); // futuro

        $paginator = $this->repository->paginatePublished(new ArticleListQuery());

        self::assertCount(3, $paginator);
    }

    public function testFilterByCategory(): void
    {
        $symfony = CategoryFactory::createOne(['name' => 'Symfony', 'slug' => 'symfony']);
        ArticleFactory::createMany(2, ['category' => $symfony]);
        ArticleFactory::createMany(4);   // otras categorías aleatorias

        $paginator = $this->repository->paginatePublished(new ArticleListQuery(category: 'symfony'));

        self::assertCount(2, $paginator);
    }
}

📘 Concepto: En el entorno test, static::getContainer() devuelve un contenedor especial que permite obtener servicios privados (en dev/prod solo puedes pedir al contenedor los públicos). Así puedes probar cualquier servicio sin cambiar su visibilidad.

Para servicios que necesitan “el tiempo”, Symfony ofrece el trait ClockSensitiveTrait, que congela el reloj global de la aplicación:

use Symfony\Component\Clock\Test\ClockSensitiveTrait;

final class ArticlePublisherIntegrationTest extends KernelTestCase
{
    use ClockSensitiveTrait;

    public function testPublishDue(): void
    {
        static::mockTime('2026-10-03 09:00:00');   // a partir de aquí, "ahora" es esta fecha

        ArticleFactory::new()->scheduledFor(new \DateTimeImmutable('2026-10-03 08:00'))->create();
        ArticleFactory::new()->scheduledFor(new \DateTimeImmutable('2026-10-04 08:00'))->create();

        $publisher = static::getContainer()->get(ArticlePublisher::class);

        self::assertSame(1, $publisher->publishDue());
    }
}

6. Tests funcionales con WebTestCase

Un test funcional simula un navegador: hace una petición HTTP a tu aplicación (sin servidor web real) y te deja inspeccionar la respuesta y el HTML.

El primer test funcional

<?php
// tests/Controller/BlogControllerTest.php
declare(strict_types=1);

namespace App\Tests\Controller;

use App\Factory\ArticleFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class BlogControllerTest extends WebTestCase
{
    public function testHomepageListsPublishedArticles(): void
    {
        $client = static::createClient();   // ¡siempre antes de crear datos con Foundry!

        ArticleFactory::createMany(3);
        ArticleFactory::new()->draft()->create(['title' => 'Mi borrador secreto']);

        $crawler = $client->request('GET', '/');

        $this->assertResponseIsSuccessful();                          // 2xx
        $this->assertPageTitleContains('Blog');
        $this->assertSelectorCount(3, 'article.card');
        $this->assertSelectorTextNotContains('main', 'Mi borrador secreto');

        // el crawler permite recorrer el HTML como con jQuery
        self::assertCount(3, $crawler->filter('article.card h2 a'));
    }

    public function testArticlePage(): void
    {
        $client = static::createClient();
        ArticleFactory::createOne(['title' => 'Hola Symfony', 'slug' => 'hola-symfony']);

        $client->request('GET', '/articulos/hola-symfony');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Hola Symfony');
    }

    public function testUnknownArticleReturns404(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articulos/no-existe');

        $this->assertResponseStatusCodeSame(404);
    }
}

⚠️ Importante: static::createClient() arranca el kernel. Las factories de Foundry también lo necesitan, así que crea primero el cliente y después los datos. Si lo haces al revés, Symfony lanza Booting the kernel before calling createClient() is not supported.

public function testNavigateFromListToArticle(): void
{
    $client = static::createClient();
    ArticleFactory::createOne(['title' => 'Testing en Symfony']);

    $crawler = $client->request('GET', '/');

    // hacer clic en un enlace por su texto
    $client->clickLink('Testing en Symfony');

    $this->assertResponseIsSuccessful();
    $this->assertRouteSame('app_article_show');
}

public function testAdminRedirectsToLogin(): void
{
    $client = static::createClient();
    $client->request('GET', '/admin');

    $this->assertResponseRedirects('/login');   // 302 hacia /login

    $client->followRedirect();                  // seguir UNA redirección
    $this->assertSelectorExists('form input[name="_username"]');
}

Si quieres que el cliente siga todas las redirecciones automáticamente, llama a $client->followRedirects() al principio del test.

El crawler en detalle

Método Qué hace
$crawler->filter('css') Selecciona nodos con un selector CSS
->first(), ->last(), ->eq(2) Un nodo concreto
->text() Texto del nodo (sin etiquetas)
->attr('href') Valor de un atributo
->count() Número de nodos encontrados
->each(fn ($node) => ...) Recorre los nodos
$crawler->selectLink('Texto')->link() Obtiene un enlace para $client->click()
$crawler->selectButton('Guardar')->form() Obtiene el formulario de un botón

7. Tests de formularios

Usa submitForm() indicando el texto del botón y los valores de los campos (con el nombre HTML completo, p. ej. article[title]).

Login con el formulario

<?php
// tests/Controller/SecurityControllerTest.php
declare(strict_types=1);

namespace App\Tests\Controller;

use App\Factory\UserFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class SecurityControllerTest extends WebTestCase
{
    public function testLoginWithValidCredentials(): void
    {
        $client = static::createClient();
        UserFactory::createOne(['email' => 'ana@blog.test', 'password' => 'secreto123']);

        $client->request('GET', '/login');
        $client->submitForm('Entrar', [
            '_username' => 'ana@blog.test',
            '_password' => 'secreto123',
        ]);

        $this->assertResponseRedirects('/');
        $client->followRedirect();
        $this->assertSelectorTextContains('nav', 'ana@blog.test');
    }

    public function testLoginWithWrongPassword(): void
    {
        $client = static::createClient();
        UserFactory::createOne(['email' => 'ana@blog.test', 'password' => 'secreto123']);

        $client->request('GET', '/login');
        $client->submitForm('Entrar', ['_username' => 'ana@blog.test', '_password' => 'mala']);

        $client->followRedirect();
        $this->assertSelectorExists('.alert-danger');
    }
}

Crear un artículo con el formulario

public function testAuthorCreatesArticle(): void
{
    $client = static::createClient();
    $author = UserFactory::createOne(['roles' => ['ROLE_AUTHOR']]);
    $category = CategoryFactory::createOne(['name' => 'Symfony']);
    $client->loginUser($author);

    $client->request('GET', '/autor/articulos/nuevo');
    $client->submitForm('Guardar', [
        'article[title]' => 'Mi artículo de prueba',
        'article[content]' => 'Un contenido suficientemente largo para pasar la validación.',
        'article[category]' => (string) $category->getId(),
    ]);

    $this->assertResponseRedirects();
    ArticleFactory::assert()->count(1, ['title' => 'Mi artículo de prueba']);
}

public function testInvalidFormShowsErrors(): void
{
    $client = static::createClient();
    $client->loginUser(UserFactory::createOne(['roles' => ['ROLE_AUTHOR']]));

    $client->request('GET', '/autor/articulos/nuevo');
    $client->submitForm('Guardar', ['article[title]' => '']);

    $this->assertResponseIsUnprocessable();          // 422: el formulario no es válido
    $this->assertSelectorExists('.invalid-feedback');
}

📘 Concepto: Cuando un formulario enviado no es válido, $this->render() con un formulario en el controlador responde con 422 automáticamente (así Turbo sabe que debe mostrar los errores). Por eso el test comprueba assertResponseIsUnprocessable().

💡 Consejo: ArticleFactory::assert() es un atajo de Foundry para comprobar la base de datos (count(), exists(), empty()…). También puedes pedir el repositorio con static::getContainer()->get(ArticleRepository::class) y comprobar a mano.


8. Tests con usuarios autenticados

Rellenar el formulario de login en cada test es lento y repetitivo. loginUser() autentica directamente a un usuario en el cliente:

public function testAuthorCannotEditOthersArticles(): void
{
    $client = static::createClient();
    $ana = UserFactory::createOne(['roles' => ['ROLE_AUTHOR']]);
    $luis = UserFactory::createOne(['roles' => ['ROLE_AUTHOR']]);
    $article = ArticleFactory::createOne(['author' => $luis]);

    $client->loginUser($ana);
    $client->request('GET', sprintf('/autor/articulos/%d/editar', $article->getId()));

    $this->assertResponseStatusCodeSame(403);   // el voter lo impide
}

public function testAdminCanModerateComments(): void
{
    $client = static::createClient();
    $client->loginUser(UserFactory::createOne(['roles' => ['ROLE_ADMIN']]));

    $client->request('GET', '/admin/comentarios');

    $this->assertResponseIsSuccessful();
}

Si tu aplicación tiene varios firewalls, indica en cuál autenticar: $client->loginUser($user, 'main').

⚠️ Importante: loginUser() no funciona con firewalls stateless (como el de la API del módulo 12), porque no hay sesión donde guardar el usuario. En la API, envía el token en la cabecera, como verás a continuación.


9. Tests de una API JSON

jsonRequest() codifica el cuerpo en JSON y añade las cabeceras Content-Type y Accept adecuadas:

<?php
// tests/Api/ArticleApiTest.php
declare(strict_types=1);

namespace App\Tests\Api;

use App\Entity\ApiToken;
use App\Factory\ArticleFactory;
use App\Factory\UserFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class ArticleApiTest extends WebTestCase
{
    private KernelBrowser $client;

    protected function setUp(): void
    {
        $this->client = static::createClient();
    }

    public function testListIsPaginated(): void
    {
        ArticleFactory::createMany(15);

        $this->client->request('GET', '/api/articles?limit=10');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/json');

        $data = json_decode($this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR);
        self::assertCount(10, $data['items']);
        self::assertSame(15, $data['meta']['total']);
        self::assertSame(2, $data['meta']['pages']);
        self::assertArrayNotHasKey('content', $data['items'][0]);   // el grupo article:list no lo incluye
    }

    public function testCreateRequiresToken(): void
    {
        $this->client->jsonRequest('POST', '/api/articles', ['title' => 'Sin token']);

        $this->assertResponseStatusCodeSame(401);
    }

    public function testCreateArticle(): void
    {
        $token = $this->createTokenFor(['ROLE_AUTHOR']);

        $this->client->jsonRequest('POST', '/api/articles', [
            'title' => 'Creado desde un test',
            'content' => 'Contenido de más de veinte caracteres.',
        ], ['HTTP_AUTHORIZATION' => 'Bearer '.$token]);

        $this->assertResponseStatusCodeSame(201);
        $this->assertResponseHasHeader('Location');

        $data = json_decode($this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR);
        self::assertSame('creado-desde-un-test', $data['slug']);
    }

    public function testValidationErrors(): void
    {
        $token = $this->createTokenFor(['ROLE_AUTHOR']);

        $this->client->jsonRequest('POST', '/api/articles', ['title' => '', 'content' => 'corto'],
            ['HTTP_AUTHORIZATION' => 'Bearer '.$token]);

        $this->assertResponseIsUnprocessable();
        $data = json_decode($this->client->getResponse()->getContent(), true, flags: \JSON_THROW_ON_ERROR);
        $fields = array_column($data['violations'], 'propertyPath');
        self::assertContains('title', $fields);
        self::assertContains('content', $fields);
    }

    /** Crea un usuario con un token de API y devuelve el token en claro */
    private function createTokenFor(array $roles): string
    {
        $user = UserFactory::createOne(['roles' => $roles]);
        [$apiToken, $plain] = ApiToken::generate($user);

        $em = static::getContainer()->get(EntityManagerInterface::class);
        $em->persist($apiToken);
        $em->flush();

        return $plain;
    }
}

📘 Concepto: En el tercer y cuarto argumento de request()/jsonRequest() las cabeceras HTTP se pasan como variables de servidor: en mayúsculas, con guiones bajos y el prefijo HTTP_. Así, Authorization se escribe HTTP_AUTHORIZATION. La excepción es CONTENT_TYPE, que no lleva prefijo.


10. Tests de comandos de consola

Los comandos (los crearás en el módulo 14) también se prueban desde un KernelTestCase. Con CommandTester, que funciona en todas las versiones recientes:

<?php
// tests/Command/PublishScheduledCommandTest.php
declare(strict_types=1);

namespace App\Tests\Command;

use App\Factory\ArticleFactory;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
use Zenstruck\Foundry\Attribute\ResetDatabase;

#[ResetDatabase]
final class PublishScheduledCommandTest extends KernelTestCase
{
    public function testPublishesDueArticles(): void
    {
        self::bootKernel();
        ArticleFactory::new()->scheduledFor(new \DateTimeImmutable('-1 hour'))->many(2)->create();
        ArticleFactory::new()->scheduledFor(new \DateTimeImmutable('+1 day'))->create();

        $application = new Application(self::$kernel);
        $tester = new CommandTester($application->find('app:articles:publish-scheduled'));

        $tester->execute([]);                       // argumentos y opciones: ['--dry-run' => true]

        $tester->assertCommandIsSuccessful();
        self::assertStringContainsString('2 artículos publicados', $tester->getDisplay());
    }
}

En Symfony 8.1 tienes además un atajo más directo, runCommand(), con asserts propios:

public function testDryRun(): void
{
    $result = static::runCommand('app:articles:publish-scheduled', ['--dry-run' => true]);

    $this->assertCommandIsSuccessful($result);
    self::assertStringContainsString('Modo simulación', $result->getOutput());
}

Para comandos interactivos, pasa las respuestas: $tester->setInputs(['ana@blog.test', 'yes']) antes de execute(), o el tercer argumento de runCommand().


11. Asserts específicos de Symfony

WebTestCase y KernelTestCase añaden asserts pensados para aplicaciones web. Los más útiles:

Assert Comprueba
assertResponseIsSuccessful() Código 2xx
assertResponseStatusCodeSame(404) Un código exacto
assertResponseRedirects('/login') Redirección (opcionalmente a una URL y con un código)
assertResponseIsUnprocessable() Código 422
assertResponseHasHeader('Location') Que exista una cabecera
assertResponseHeaderSame('content-type', 'application/json') Valor exacto de una cabecera
assertResponseFormatSame('json') Formato de la respuesta
assertResponseHasCookie('PHPSESSID') Cookie en la respuesta
assertSelectorExists('.alert') Que exista un elemento
assertSelectorNotExists('.alert-danger') Que no exista
assertSelectorCount(3, 'article.card') Número de elementos
assertSelectorTextContains('h1', 'Hola') Texto del primer elemento que coincide
assertAnySelectorTextContains('li', 'Doctrine') Texto en alguno de los elementos
assertSelectorTextSame('h1', 'Hola') Texto exacto
assertPageTitleContains('Blog') Contenido de <title>
assertInputValueSame('article[title]', 'X') Valor de un campo
assertCheckboxChecked('remember_me') Casilla marcada
assertRouteSame('app_article_show') Ruta que atendió la petición
assertSessionHasFlashMessage('success') Mensaje flash en la sesión
assertBrowserCookieValueSame('lang', 'es') Cookie guardada en el cliente
assertEmailCount(1) Correos enviados
assertQueuedEmailCount(1) Correos encolados
assertEmailSubjectContains($email, 'Nuevo artículo') Asunto de un correo
assertEmailHtmlBodyContains($email, 'Leer') Cuerpo HTML de un correo

Ejemplo de correos (con self::getMailerMessage() obtienes el último enviado):

$this->assertEmailCount(1);
$email = self::getMailerMessage();
$this->assertEmailAddressContains($email, 'to', 'suscriptor@blog.test');
$this->assertEmailSubjectContains($email, 'Nuevo artículo');

12. Cobertura de código

📘 Concepto: La cobertura indica qué porcentaje de líneas de src/ se ejecutan al menos una vez durante los tests. Es una pista útil (si un fichero tiene 0 %, nadie lo prueba), pero un 100 % de cobertura no garantiza que los tests comprueben algo con sentido.

Necesitas un driver de cobertura: Xdebug (en modo coverage) o PCOV (más rápido, solo hace cobertura).

# con Xdebug
XDEBUG_MODE=coverage php bin/phpunit --coverage-html var/coverage

# resumen en la terminal
XDEBUG_MODE=coverage php bin/phpunit --coverage-text

En PowerShell:

$env:XDEBUG_MODE = 'coverage'; php bin/phpunit --coverage-html var/coverage

Abre var/coverage/index.html en el navegador: verás cada fichero con las líneas cubiertas en verde y las no cubiertas en rojo. La sección <source> de phpunit.dist.xml define qué carpetas se miden (src/).

💡 Consejo: En lugar de perseguir un porcentaje global, asegúrate de cubrir bien las reglas de negocio (voters, servicios de publicación, validaciones) y los flujos críticos (login, crear artículo, comentar).


13. Tests en integración continua con GitHub Actions

Que los tests pasen “en mi máquina” no basta: deben ejecutarse automáticamente en cada push y en cada pull request. Este workflow levanta un PostgreSQL como servicio, instala PHP y ejecuta la suite:

# .github/workflows/tests.yaml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  phpunit:
    runs-on: ubuntu-latest

    services:
      database:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: blog
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10

    env:
      APP_ENV: test
      # las variables de entorno reales tienen prioridad sobre .env.test
      DATABASE_URL: "postgresql://app:app@127.0.0.1:5432/blog?serverVersion=17&charset=utf8"

    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          extensions: intl, pdo_pgsql
          coverage: pcov

      - name: Caché de Composer
        uses: actions/cache@v6
        with:
          path: ~/.composer/cache/files
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Instalar dependencias
        run: composer install --no-progress --prefer-dist --no-interaction

      - name: Preparar la base de datos de test
        run: |
          php bin/console doctrine:database:create --if-not-exists
          php bin/console doctrine:migrations:migrate --no-interaction

      - name: Compilar assets (AssetMapper)
        run: php bin/console asset-map:compile

      - name: Ejecutar tests
        run: php bin/phpunit --coverage-text

📘 Concepto: Gracias a dbname_suffix, aunque DATABASE_URL apunte a blog, en el entorno test se usa blog_test. Por eso el paso doctrine:database:create sigue siendo necesario.

⚠️ Importante: Ejecutar las migraciones (en vez de doctrine:schema:create) en la CI tiene una ventaja extra: si alguien olvida generar una migración, los tests fallarán porque falta una tabla o columna. Puedes reforzarlo con php bin/console doctrine:schema:validate.

💡 Consejo: Añade al mismo workflow los pasos de calidad que verás en el proyecto final: vendor/bin/phpstan analyse, vendor/bin/php-cs-fixer check y php bin/console lint:twig templates.


14. Tests end-to-end: Panther y Playwright

WebTestCase no ejecuta JavaScript: si tu página depende de Stimulus o de Turbo para mostrar algo, el crawler no lo verá. Para eso necesitas un navegador real.

  • Symfony Panther (composer require --dev symfony/panther) controla Chrome o Firefox reales con la misma API que ya conoces (PantherTestCase, createPantherClient(), crawler, submitForm()), y añade esperas como waitFor('.toast').
  • Playwright es una herramienta de Microsoft muy popular para E2E, escrita en JavaScript/TypeScript. Se ejecuta contra la aplicación levantada (por ejemplo con symfony serve) y es independiente de PHP.
use Symfony\Component\Panther\PantherTestCase;

final class CommentLiveTest extends PantherTestCase
{
    public function testCommentAppearsWithoutReload(): void
    {
        $client = static::createPantherClient();
        $client->request('GET', '/articulos/hola-symfony');

        $client->submitForm('Comentar', ['comment[content]' => '¡Gran artículo!']);
        $client->waitForElementToContain('#comments', '¡Gran artículo!');

        $this->assertSelectorTextContains('#comments', '¡Gran artículo!');
    }
}

💡 Consejo: Los tests E2E son lentos y más frágiles. Resérvalos para los pocos flujos en los que el JavaScript es esencial; todo lo demás, cúbrelo con WebTestCase.


15. Buenas prácticas y errores comunes

Problema Causa Solución
Los tests pasan solos pero fallan juntos Dependen del estado de la BD de otro test DAMADoctrineTestBundle + #[ResetDatabase]
Booting the kernel before calling createClient()... Foundry o bootKernel() antes de createClient() Crea el cliente lo primero
loginUser() no tiene efecto en la API El firewall es stateless Envía HTTP_AUTHORIZATION
Tests lentísimos con usuarios Coste de hash alto cost: 4 en when@test
Un test falla “por una deprecation” failOnDeprecation="true" Corrige la deprecation: te lo agradecerás al actualizar
Fecha “ahora” impredecible new \DateTimeImmutable() dentro del servicio Inyecta ClockInterface y usa MockClock / mockTime()
El selector no encuentra lo que se ve en el navegador El contenido lo genera JavaScript Test E2E con Panther o Playwright

Más recomendaciones:

  • Un test, un comportamiento. El nombre del método debe describirlo: testAuthorCannotEditOthersArticles.
  • Estructura cada test en tres bloques: preparar (datos), actuar (petición) y comprobar (asserts).
  • Prueba también los casos de error: 403, 404, 422… Son los que más se rompen sin que nadie se dé cuenta.
  • No pruebes el framework (no hace falta comprobar que #[Route] funciona); prueba tu lógica.

16. Ejercicios

Ejercicio 1: Test unitario del slugger

Escribe tests/Service/SluggerTest.php con un data provider que compruebe al menos cinco casos: acentos ("Introducción" → "introduccion"), eñes, espacios múltiples, signos de puntuación y mayúsculas.

Ejercicio 2: El voter de artículos

Escribe un test unitario de ArticleVoter (módulo 10). Crea los objetos User y Article a mano, construye un UsernamePasswordToken y comprueba con $voter->vote() que: el autor puede editar, otro autor no, y un ROLE_ADMIN sí.

Ejercicio 3: Repositorio de comentarios

Con KernelTestCase y Foundry, prueba un método CommentRepository::findApprovedForArticle() que solo devuelva los comentarios aprobados de un artículo, ordenados del más antiguo al más nuevo.

Ejercicio 4: Flujo de comentarios

Escribe un test funcional en el que un usuario autenticado envía un comentario desde la página de un artículo, se le redirige a la misma página, ve un mensaje flash de “pendiente de moderación” y el comentario no aparece todavía publicado.

Ejercicio 5: Seguridad del panel de administración

Escribe un test con un data provider que recorra las URL de /admin (categorías, comentarios…) y compruebe: anónimo → redirección a /login; ROLE_AUTHOR → 403; ROLE_ADMIN → 200.

Ejercicio 6: API completa

Completa ArticleApiTest con tests para GET /api/articles/{id} (200 y 404 en JSON), PATCH de un artículo propio (200), PATCH de uno ajeno (403) y DELETE (204, y después un GET devuelve 404).

Ejercicio 7: Correo al publicar

Escribe un test de integración que publique un artículo con ArticlePublisher, procese el transporte in-memory y compruebe con assertEmailCount() y assertEmailSubjectContains() que se ha enviado el correo de aviso.

Ejercicio 8: CI en tu repositorio

Añade el workflow de la sección 13 a tu proyecto, rompe a propósito un test, haz push y comprueba que GitHub marca el commit en rojo. Después arréglalo y añade una insignia (badge) del workflow al README.md del proyecto.


Resumen

Concepto Sintaxis / herramienta
Instalar composer require --dev symfony/test-pack
Configuración phpunit.dist.xml, tests/bootstrap.php, .env.test
Ejecutar php bin/phpunit [ruta] [--filter nombre] [--testdox]
BD de test dbname_suffix: '_test...', --env=test doctrine:database:create
Rollback por test dama/doctrine-test-bundle (extensión de PHPUnit)
Datos Foundry: FoundryExtension, #[ResetDatabase], XxxFactory::createMany()
Test unitario extends TestCase, #[DataProvider]
Dobles createStub() (respuestas), createMock() + expects() (verificación)
Tiempo ClockInterface, MockClock, ClockSensitiveTrait::mockTime()
Integración extends KernelTestCase, static::getContainer()->get(...)
Funcional extends WebTestCase, static::createClient(), $client->request()
Navegación clickLink(), followRedirect(), followRedirects()
Formularios $client->submitForm('Botón', ['form[campo]' => 'valor'])
Autenticación $client->loginUser($user) (no en firewalls stateless)
API JSON $client->jsonRequest('POST', $url, $data, ['HTTP_AUTHORIZATION' => ...])
Comandos CommandTester o static::runCommand() (8.1)
Cobertura XDEBUG_MODE=coverage php bin/phpunit --coverage-html var/coverage
CI GitHub Actions con servicio de BD y setup-php
E2E Symfony Panther, Playwright