การทดสอบ Symfony ปี 2026: PHPUnit, KernelTestCase และ Functional Tests

คู่มือฉบับสมบูรณ์สำหรับการทดสอบแอปพลิเคชัน Symfony ด้วย PHPUnit 12, KernelTestCase สำหรับ integration testing และ WebTestCase สำหรับ functional tests ตาม best practices

การทดสอบ Symfony ด้วย PHPUnit 12, KernelTestCase และ Functional Tests

การทดสอบ Symfony ด้วย PHPUnit 12 มอบแนวทางที่มีโครงสร้างสำหรับการตรวจสอบพฤติกรรมของแอปพลิเคชัน ตั้งแต่ unit test ไปจนถึงการจำลอง HTTP request แบบเต็มรูปแบบ โครงสร้างพื้นฐานการทดสอบของ framework ผสานรวมอย่างแน่นแฟ้นกับ service container ทำให้การทดสอบ service, controller และการดำเนินการ database เป็นเรื่องง่ายโดยมี boilerplate น้อยที่สุด

Testing Stack ใน Symfony 7.3

Symfony 7.3 มาพร้อมกับการรองรับ PHPUnit 12, การปรับปรุง test isolation ผ่าน ResetInterface และ component CrawlerSelector สำหรับการเขียน assertion ใน functional test ที่อ่านง่ายขึ้น WebTestCase รองรับ assertion HTTP/2 push ตั้งแต่เริ่มต้น

Unit Testing Services ด้วย PHPUnit 12

Unit test ตรวจสอบ class แต่ละตัวแยกกัน สำหรับ Symfony service ที่ไม่มี dependency กับ container, test PHPUnit มาตรฐานสามารถทำงานได้โดยไม่ต้องใช้ 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 บริสุทธิ์ทำงานเร็วกว่า integration test และสามารถระบุตำแหน่งที่เกิดข้อผิดพลาดได้อย่างแม่นยำ ควรใช้สำหรับ class ที่มี business logic ในการแปลงข้อมูลหรือทำการคำนวณ

Integration Testing ด้วย KernelTestCase

เมื่อ service ต้องพึ่งพา Symfony container ไม่ว่าจะเป็นการเชื่อมต่อ database, cache adapter หรือ service อื่นๆ KernelTestCase จะ boot kernel แบบน้อยที่สุดและให้การเข้าถึง 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();
    }
}

เมธอด getContainer() คืนค่า container สำหรับ test ซึ่งเปิดเผย private service สำหรับวัตถุประสงค์ในการทดสอบ สิ่งนี้แตกต่างจาก runtime container ที่ private service ยังคงไม่สามารถเข้าถึงได้

การแยก Database ด้วย Transactions

Test isolation ป้องกันไม่ให้ข้อมูลจาก test หนึ่งส่งผลกระทบต่อ test อื่น DAMADoctrineTestBundle ห่อแต่ละ test ใน transaction ที่ 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
}

กำหนดค่า bundle ใน phpunit.xml.dist โดยการเพิ่ม extension แต่ละ test จะทำงานในการแยกอย่างสมบูรณ์โดยไม่ต้องมีโค้ด cleanup แบบ manual

พร้อมที่จะพิชิตการสัมภาษณ์ Symfony แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

Functional Testing ด้วย WebTestCase

Functional test จำลอง HTTP request และตรวจสอบ response WebTestCase ให้ client ที่ส่ง request ไปยัง controller โดยไม่ต้องเริ่มต้น 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);
    }
}

เมธอด $client->request() รับ HTTP method, URI, parameters, files, server variables และ body content Symfony ประมวลผล request ผ่าน kernel ทั้งหมด รวมถึง middleware, security และ routing

การทดสอบ Request ที่ต้องยืนยันตัวตน

สำหรับ endpoint ที่ต้องการการยืนยันตัวตน security component ของ Symfony มีเมธอด loginUser() บน 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);
    }
}

เมธอด loginUser() ตั้งค่า security token โดยไม่ต้องผ่านฟอร์มเข้าสู่ระบบ ทำให้ test เร็วขึ้นและมุ่งเน้นมากขึ้น

การทดสอบ Form และการจัดการ CSRF

การทดสอบ form ต้องจัดการกับ token CSRF Component crawler ดึงเอา element ของ form และ submit พร้อมกับการจัดการ token ที่เหมาะสม

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

เมธอด selectButton() ค้นหา form จากข้อความของปุ่ม submit สำหรับ form ที่ไม่มีปุ่มที่มองเห็น ให้ใช้ selectForm() กับ CSS selector

การทดสอบ Console Commands

Command มักดำเนินการที่สำคัญเช่น import ข้อมูลหรือ scheduled jobs CommandTester execute command และ 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());
    }
}

สำหรับ command ที่มี input แบบ interactive ให้ส่ง ['interactive' => false] เป็น argument ที่สองให้กับ execute() หรือใช้ setInputs() เพื่อจำลองการตอบกลับของผู้ใช้

การทดสอบ Messenger สำหรับ Async Jobs

การทดสอบ handler ของ Symfony Messenger ต้องตรวจสอบว่า message ถูก dispatch อย่างถูกต้องและ handler ประมวลผลตามที่คาดหวัง

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

กำหนดค่า in-memory transport ใน config/packages/test/messenger.yaml เพื่อ capture message ที่ถูก dispatch โดยไม่ต้องประมวลผล queue จริง

การกำหนดค่า Code Coverage

รายงาน code coverage ของ PHPUnit ระบุเส้นทางโค้ดที่ยังไม่ได้ทดสอบ กำหนดค่า coverage ใน 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>

รัน test พร้อม coverage โดยใช้ php bin/phpunit --coverage-html var/coverage รายงาน HTML แสดงสถานะ coverage ทีละบรรทัด

สรุป

  • ใช้ TestCase สำหรับ unit test บริสุทธิ์ที่ไม่มี dependency กับ container
  • ใช้ KernelTestCase เมื่อ test ต้องการ service จาก container
  • ใช้ WebTestCase สำหรับการจำลอง HTTP request และ assertion ของ response
  • กำหนดค่า DAMADoctrineTestBundle สำหรับ rollback transaction อัตโนมัติระหว่าง test
  • ทดสอบการยืนยันตัวตนด้วย loginUser() แทนที่จะจำลองฟอร์มเข้าสู่ระบบ
  • ใช้ CommandTester สำหรับการตรวจสอบ console command พร้อม capture input/output
  • กำหนดค่า in-memory transport ใน environment ของ test สำหรับการทดสอบ Messenger
  • จัดระเบียบ test ลงในไดเรกทอรี Unit, Integration และ Functional เพื่อความชัดเจน

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#symfony
#phpunit
#testing
#php
#tdd

แชร์

บทความที่เกี่ยวข้อง