Kiểm Thử Symfony năm 2026: PHPUnit, KernelTestCase và Functional Tests

Hướng dẫn toàn diện về kiểm thử ứng dụng Symfony với PHPUnit 12, KernelTestCase cho integration testing, và WebTestCase cho functional tests theo best practices.

Kiểm thử Symfony với PHPUnit 12, KernelTestCase và Functional Tests

Kiểm thử Symfony với PHPUnit 12 cung cấp phương pháp có cấu trúc để xác thực hành vi ứng dụng từ unit test đến mô phỏng HTTP request đầy đủ. Cơ sở hạ tầng testing của framework tích hợp chặt chẽ với service container, giúp việc kiểm thử service, controller và các thao tác database trở nên đơn giản với boilerplate tối thiểu.

Testing Stack trong Symfony 7.3

Symfony 7.3 hỗ trợ PHPUnit 12, cải thiện test isolation thông qua ResetInterface, và component CrawlerSelector cho các assertion functional test dễ đọc hơn. WebTestCase hiện hỗ trợ assertion HTTP/2 push ngay từ đầu.

Unit Testing Services với PHPUnit 12

Unit test xác minh các class riêng lẻ một cách độc lập. Đối với các service Symfony không có dependency vào container, test PHPUnit tiêu chuẩn hoạt động mà không cần sự tham gia của framework.

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);
    }
}

Unit test thuần túy chạy nhanh hơn integration test và xác định lỗi một cách chính xác. Sử dụng chúng cho các class business logic biến đổi dữ liệu hoặc thực hiện tính toán.

Integration Testing với KernelTestCase

Khi các service phụ thuộc vào Symfony container—kết nối database, cache adapter, hoặc các service khác—KernelTestCase khởi động kernel tối thiểu và cung cấp quyền truy cập vào service container.

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();
    }
}

Phương thức getContainer() trả về container test, cho phép truy cập các service private cho mục đích testing. Điều này khác với runtime container, nơi các service private vẫn không thể truy cập được.

Cô Lập Database với Transactions

Test isolation ngăn dữ liệu từ một test ảnh hưởng đến test khác. DAMADoctrineTestBundle bọc mỗi test trong một transaction được rollback tự động.

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
}

Cấu hình bundle trong phpunit.xml.dist bằng cách thêm extension. Mỗi test sau đó chạy trong isolation hoàn toàn mà không cần mã cleanup thủ công.

Sẵn sàng chinh phục phỏng vấn Symfony?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Functional Testing với WebTestCase

Functional test mô phỏng HTTP request và xác minh response. WebTestCase cung cấp client tạo request đến controller mà không cần khởi động web server.

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);
    }
}

Phương thức $client->request() nhận HTTP method, URI, parameters, files, server variables và body content. Symfony xử lý request thông qua kernel đầy đủ, bao gồm middleware, security và routing.

Kiểm Thử Request Đã Xác Thực

Đối với các endpoint yêu cầu xác thực, component security của Symfony cung cấp phương thức loginUser() trên test client.

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);
    }
}

Phương thức loginUser() thiết lập security token mà không cần thông qua form đăng nhập, giúp test nhanh hơn và tập trung hơn.

Kiểm Thử Form và Xử Lý CSRF

Kiểm thử form yêu cầu xử lý token CSRF. Component crawler trích xuất các element form và submit chúng với xử lý token phù hợp.

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');
    }
}

Phương thức selectButton() tìm form theo text của nút submit. Đối với form không có nút hiển thị, sử dụng selectForm() với CSS selector.

Kiểm Thử Console Commands

Các command thường thực hiện các thao tác quan trọng như import dữ liệu hoặc scheduled jobs. CommandTester thực thi command và capture output.

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());
    }
}

Đối với command có input tương tác, truyền ['interactive' => false] làm đối số thứ hai cho execute(), hoặc sử dụng setInputs() để mô phỏng phản hồi của người dùng.

Kiểm Thử Messenger cho Async Jobs

Kiểm thử handler Symfony Messenger yêu cầu xác minh rằng message được dispatch đúng cách và handler xử lý chúng như mong đợi.

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());
    }
}

Cấu hình in-memory transport trong config/packages/test/messenger.yaml để capture các message được dispatch mà không cần xử lý queue thực tế.

Cấu Hình Code Coverage

Báo cáo code coverage PHPUnit xác định các đường dẫn code chưa được kiểm thử. Cấu hình coverage trong 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>

Chạy test với coverage bằng lệnh php bin/phpunit --coverage-html var/coverage. Báo cáo HTML hiển thị trạng thái coverage theo từng dòng.

Kết Luận

  • Sử dụng TestCase cho unit test thuần túy không có dependency container
  • Sử dụng KernelTestCase khi test cần service từ container
  • Sử dụng WebTestCase cho mô phỏng HTTP request và assertion response
  • Cấu hình DAMADoctrineTestBundle để rollback transaction tự động giữa các test
  • Kiểm thử xác thực với loginUser() thay vì mô phỏng form đăng nhập
  • Sử dụng CommandTester để xác minh console command với capture input/output
  • Cấu hình in-memory transport trong môi trường test cho kiểm thử Messenger
  • Tổ chức test vào các thư mục Unit, Integration và Functional để rõ ràng

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#symfony
#phpunit
#testing
#php
#tdd

Chia sẻ

Bài viết liên quan