Testowanie w Symfony 2026: PHPUnit, KernelTestCase i testy funkcjonalne

Testowanie aplikacji Symfony z PHPUnit 12, KernelTestCase i WebTestCase. Kompletny przewodnik po testach jednostkowych, integracyjnych i funkcjonalnych.

Testowanie w Symfony 2026: PHPUnit, KernelTestCase i testy funkcjonalne

Testowanie aplikacji Symfony z wykorzystaniem PHPUnit 12 zapewnia uporządkowane podejście do weryfikacji zachowania aplikacji — od testów jednostkowych po pełne symulacje żądań HTTP. Infrastruktura testowa frameworka ściśle integruje się z kontenerem usług, co umożliwia łatwe testowanie serwisów, kontrolerów i operacji bazodanowych przy minimalnej ilości kodu pomocniczego.

Stos testowy w Symfony 7.3

Symfony 7.3 oferuje wsparcie dla PHPUnit 12, ulepszoną izolację testów poprzez ResetInterface oraz komponent CrawlerSelector umożliwiający bardziej czytelne asercje w testach funkcjonalnych. WebTestCase obsługuje teraz natywnie asercje HTTP/2 push.

Testy jednostkowe serwisów z PHPUnit 12

Testy jednostkowe weryfikują poszczególne klasy w izolacji. Dla serwisów Symfony, które nie mają zależności od kontenera, standardowe testy PHPUnit działają bez jakiegokolwiek zaangażowania frameworka.

tests/Unit/Service/PriceCalculatorTest.phpphp
namespace App\Tests\Unit\Service;

use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;

final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        // Create instance directly - no container needed
        $this->calculator = new PriceCalculator(vatRate: 0.20);
    }

    public function testCalculateTotalWithVat(): void
    {
        $result = $this->calculator->calculateTotal(100.00);
        
        // Assert exact decimal value with delta for float comparison
        $this->assertEqualsWithDelta(120.00, $result, 0.001);
    }

    public function testCalculateTotalWithZeroAmount(): void
    {
        $result = $this->calculator->calculateTotal(0.00);
        
        $this->assertEqualsWithDelta(0.00, $result, 0.001);
    }
}

Czyste testy jednostkowe wykonują się szybciej niż testy integracyjne i precyzyjnie wskazują źródło błędów. Należy je stosować dla klas zawierających logikę biznesową transformującą dane lub wykonującą obliczenia.

Testy integracyjne z KernelTestCase

Gdy serwisy zależą od kontenera Symfony — połączeń bazodanowych, adapterów cache lub innych serwisów — KernelTestCase uruchamia minimalny kernel i zapewnia dostęp do kontenera usług.

tests/Integration/Repository/UserRepositoryTest.phpphp
namespace App\Tests\Integration\Repository;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class UserRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private UserRepository $repository;

    protected function setUp(): void
    {
        // Boot kernel and fetch services from container
        self::bootKernel();
        $container = static::getContainer();
        
        $this->em = $container->get(EntityManagerInterface::class);
        $this->repository = $container->get(UserRepository::class);
    }

    public function testFindByEmailReturnsUserWhenExists(): void
    {
        // Arrange: create and persist test user
        $user = new User();
        $user->setEmail('test@example.com');
        $user->setPassword('hashed_password');
        
        $this->em->persist($user);
        $this->em->flush();

        // Act: query through repository
        $found = $this->repository->findByEmail('test@example.com');

        // Assert: verify retrieval
        $this->assertNotNull($found);
        $this->assertSame('test@example.com', $found->getEmail());
    }

    protected function tearDown(): void
    {
        // Clean up to prevent test pollution
        $this->em->createQuery('DELETE FROM App\Entity\User')->execute();
        parent::tearDown();
    }
}

Metoda getContainer() zwraca kontener testowy, który udostępnia prywatne serwisy do celów testowych. Różni się to od kontenera produkcyjnego, gdzie prywatne serwisy pozostają niedostępne.

Izolacja bazy danych z wykorzystaniem transakcji

Izolacja testów zapobiega wpływowi danych z jednego testu na inny. DAMADoctrineTestBundle opakowuje każdy test w transakcję, która automatycznie wykonuje rollback.

tests/Integration/Service/OrderServiceTest.phpphp
namespace App\Tests\Integration\Service;

use App\Entity\Order;
use App\Service\OrderService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class OrderServiceTest extends KernelTestCase
{
    private OrderService $orderService;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();
        
        $this->orderService = $container->get(OrderService::class);
        $this->em = $container->get(EntityManagerInterface::class);
    }

    public function testCreateOrderPersistsToDatabase(): void
    {
        // With DAMA bundle, this persists within a transaction
        $order = $this->orderService->create(
            customerId: 1,
            items: [['sku' => 'ABC123', 'quantity' => 2]]
        );

        // Verify persistence
        $this->em->clear();
        $persisted = $this->em->find(Order::class, $order->getId());
        
        $this->assertNotNull($persisted);
        $this->assertCount(1, $persisted->getItems());
    }
    
    // Transaction rolls back after test - no cleanup needed
}

Konfiguracja bundla w pliku phpunit.xml.dist polega na dodaniu rozszerzenia. Każdy test wykonuje się wtedy w pełnej izolacji bez konieczności pisania kodu czyszczącego.

Gotowy na rozmowy o Symfony?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Testy funkcjonalne z WebTestCase

Testy funkcjonalne symulują żądania HTTP i weryfikują odpowiedzi. WebTestCase zapewnia klienta wykonującego żądania do kontrolerów bez uruchamiania serwera web.

tests/Functional/Controller/ProductControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

final class ProductControllerTest extends WebTestCase
{
    public function testListProductsReturnsJsonArray(): void
    {
        // Create client and make GET request
        $client = static::createClient();
        $client->request('GET', '/api/products');

        // Assert response status
        $this->assertResponseIsSuccessful();
        $this->assertResponseStatusCodeSame(Response::HTTP_OK);
        
        // Assert JSON structure
        $this->assertResponseHeaderSame('Content-Type', 'application/json');
        
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertIsArray($data);
        $this->assertArrayHasKey('products', $data);
    }

    public function testCreateProductRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/products', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => 'New Product', 'price' => 29.99]));

        // Unauthenticated requests should return 401
        $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
    }
}

Metoda $client->request() przyjmuje metodę HTTP, URI, parametry, pliki, zmienne serwera i zawartość body. Symfony przetwarza żądanie przez pełny kernel, włączając middleware, bezpieczeństwo i routing.

Testowanie żądań uwierzytelnionych

Dla endpointów wymagających uwierzytelnienia komponent bezpieczeństwa Symfony udostępnia metodę loginUser() na kliencie testowym.

tests/Functional/Controller/AdminControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class AdminControllerTest extends WebTestCase
{
    public function testAdminDashboardRequiresAdminRole(): void
    {
        $client = static::createClient();
        $container = static::getContainer();
        
        // Fetch a test user with ROLE_ADMIN
        $userRepository = $container->get(UserRepository::class);
        $adminUser = $userRepository->findOneBy(['email' => 'admin@example.com']);
        
        // Authenticate the client
        $client->loginUser($adminUser);
        
        // Access protected route
        $client->request('GET', '/admin/dashboard');
        
        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Admin Dashboard');
    }

    public function testRegularUserCannotAccessAdmin(): void
    {
        $client = static::createClient();
        $container = static::getContainer();
        
        $userRepository = $container->get(UserRepository::class);
        $regularUser = $userRepository->findOneBy(['email' => 'user@example.com']);
        
        $client->loginUser($regularUser);
        $client->request('GET', '/admin/dashboard');
        
        // Should be forbidden, not redirected to login
        $this->assertResponseStatusCodeSame(403);
    }
}

Metoda loginUser() konfiguruje token bezpieczeństwa bez przechodzenia przez formularz logowania, co przyspiesza testy i czyni je bardziej ukierunkowanymi.

Testowanie formularzy i obsługa CSRF

Testowanie formularzy wymaga obsługi tokenów CSRF. Komponent crawler wyodrębnia elementy formularza i przesyła je z prawidłową obsługą tokenów.

tests/Functional/Controller/RegistrationControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class RegistrationControllerTest extends WebTestCase
{
    public function testRegistrationFormSubmission(): void
    {
        $client = static::createClient();
        
        // Load the registration page
        $crawler = $client->request('GET', '/register');
        
        // Extract the form and fill fields
        $form = $crawler->selectButton('Register')->form([
            'registration_form[email]' => 'newuser@example.com',
            'registration_form[plainPassword]' => 'SecurePass123!',
            'registration_form[agreeTerms]' => true,
        ]);
        
        // Submit - CSRF token is included automatically
        $client->submit($form);
        
        // Assert redirect after successful registration
        $this->assertResponseRedirects('/login');
        
        // Follow redirect and verify flash message
        $client->followRedirect();
        $this->assertSelectorTextContains('.alert-success', 'Registration successful');
    }

    public function testRegistrationValidationErrors(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/register');
        
        // Submit with invalid email
        $form = $crawler->selectButton('Register')->form([
            'registration_form[email]' => 'not-an-email',
            'registration_form[plainPassword]' => '123', // Too short
            'registration_form[agreeTerms]' => false,
        ]);
        
        $client->submit($form);
        
        // Should stay on same page with errors
        $this->assertResponseIsSuccessful();
        $this->assertSelectorExists('.invalid-feedback');
    }
}

Metoda selectButton() znajduje formularz po tekście przycisku submit. Dla formularzy bez widocznych przycisków należy użyć selectForm() z selektorem CSS.

Testowanie poleceń konsolowych

Polecenia często wykonują krytyczne operacje, takie jak importy danych czy zadania zaplanowane. CommandTester uruchamia polecenia i przechwytuje ich wyjście.

tests/Command/ImportUsersCommandTest.phpphp
namespace App\Tests\Command;

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

final class ImportUsersCommandTest extends KernelTestCase
{
    public function testImportUsersWithValidCsv(): void
    {
        self::bootKernel();
        $application = new Application(self::$kernel);
        
        $command = $application->find('app:import-users');
        $tester = new CommandTester($command);
        
        // Execute with arguments and options
        $tester->execute([
            'file' => 'tests/fixtures/users.csv',
            '--dry-run' => true,
        ]);
        
        // Assert exit code (0 = success)
        $this->assertSame(0, $tester->getStatusCode());
        
        // Assert output contains expected text
        $output = $tester->getDisplay();
        $this->assertStringContainsString('Imported 5 users', $output);
    }

    public function testImportUsersFailsWithMissingFile(): void
    {
        self::bootKernel();
        $application = new Application(self::$kernel);
        
        $command = $application->find('app:import-users');
        $tester = new CommandTester($command);
        
        $tester->execute(['file' => 'nonexistent.csv']);
        
        // Non-zero exit code indicates failure
        $this->assertSame(1, $tester->getStatusCode());
        $this->assertStringContainsString('File not found', $tester->getDisplay());
    }
}

Dla poleceń z interaktywnym wejściem należy przekazać ['interactive' => false] jako drugi argument metody execute() lub użyć setInputs() do symulowania odpowiedzi użytkownika.

Testowanie Messenger dla zadań asynchronicznych

Testowanie handlerów Symfony Messenger wymaga weryfikacji, że wiadomości są prawidłowo wysyłane, a handlery przetwarzają je zgodnie z oczekiwaniami.

tests/Integration/Message/SendWelcomeEmailHandlerTest.phpphp
namespace App\Tests\Integration\Message;

use App\Message\SendWelcomeEmail;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Transport\InMemoryTransport;

final class SendWelcomeEmailHandlerTest extends KernelTestCase
{
    public function testWelcomeEmailIsDispatched(): void
    {
        self::bootKernel();
        $container = static::getContainer();
        
        $bus = $container->get(MessageBusInterface::class);
        
        // Dispatch the message
        $bus->dispatch(new SendWelcomeEmail(userId: 123));
        
        // Retrieve the in-memory transport configured in config/packages/test/messenger.yaml
        /** @var InMemoryTransport $transport */
        $transport = $container->get('messenger.transport.async');
        
        // Assert message was sent
        $this->assertCount(1, $transport->getSent());
        
        $envelope = $transport->getSent()[0];
        $message = $envelope->getMessage();
        
        $this->assertInstanceOf(SendWelcomeEmail::class, $message);
        $this->assertSame(123, $message->getUserId());
    }
}

Należy skonfigurować transport in-memory w pliku config/packages/test/messenger.yaml, aby przechwytywać wysyłane wiadomości bez faktycznego przetwarzania przez kolejkę.

Konfiguracja pokrycia kodu

Raporty pokrycia kodu PHPUnit identyfikują nieprzetestowane ścieżki kodu. Konfigurację pokrycia umieszcza się w pliku phpunit.xml.dist.

xml
<!-- phpunit.xml.dist -->
<?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="tests/bootstrap.php"
         colors="true"
         executionOrder="depends,defects"
         cacheResult="true">
    
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="Functional">
            <directory>tests/Functional</directory>
        </testsuite>
    </testsuites>
    
    <source>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/DataFixtures</directory>
            <directory>src/Migrations</directory>
        </exclude>
    </source>
    
    <coverage>
        <report>
            <html outputDirectory="var/coverage"/>
            <clover outputFile="var/coverage/clover.xml"/>
        </report>
    </coverage>
</phpunit>

Testy z pokryciem uruchamia się poleceniem php bin/phpunit --coverage-html var/coverage. Raport HTML pokazuje status pokrycia dla każdej linii kodu.

Podsumowanie

  • Klasa TestCase służy do czystych testów jednostkowych bez zależności od kontenera
  • Klasa KernelTestCase jest odpowiednia, gdy testy wymagają serwisów z kontenera
  • Klasa WebTestCase służy do symulacji żądań HTTP i asercji odpowiedzi
  • DAMADoctrineTestBundle zapewnia automatyczny rollback transakcji między testami
  • Uwierzytelnianie testuje się za pomocą loginUser() zamiast symulowania formularzy logowania
  • CommandTester służy do weryfikacji poleceń konsolowych z przechwytywaniem wejścia/wyjścia
  • Transporty in-memory w środowisku testowym umożliwiają testowanie Messenger
  • Testy warto organizować w katalogi Unit, Integration i Functional dla przejrzystości

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Udostępnij

Powiązane artykuły