Symfony テスト完全ガイド 2026年版:PHPUnit、KernelTestCase、機能テストの実践的手法

Symfony 7.3とPHPUnit 12を使用した包括的なテスト手法を解説。ユニットテスト、統合テスト、機能テストの実装パターンを学びます。

Symfony テスト完全ガイド 2026年版:PHPUnit、KernelTestCase、機能テストの実践的手法

SymfonyとPHPUnit 12を使用したテストは、ユニットテストからHTTPリクエストシミュレーションまで、アプリケーションの動作を検証するための体系的なアプローチを提供します。フレームワークのテストインフラストラクチャはサービスコンテナと密接に統合されており、最小限のボイラープレートでサービス、コントローラー、データベース操作をテストすることが可能です。

Symfony 7.3のテストスタック

Symfony 7.3はPHPUnit 12のサポート、ResetInterfaceによる改善されたテスト分離、より可読性の高い機能テストアサーションのためのCrawlerSelectorコンポーネントを搭載しています。WebTestCaseはHTTP/2プッシュアサーションを標準でサポートしています。

PHPUnit 12でのサービスユニットテスト

ユニットテストは、個々のクラスを分離して検証します。コンテナに依存しないSymfonyサービスの場合、標準的なPHPUnitテストはフレームワークの関与なしで動作します。

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

純粋なユニットテストは統合テストよりも高速に実行され、障害を正確に特定できます。データを変換したり計算を実行するビジネスロジッククラスに対して使用することが推奨されます。

KernelTestCaseによる統合テスト

サービスがSymfonyコンテナ(データベース接続、キャッシュアダプタ、その他のサービス)に依存している場合、KernelTestCaseは最小限のカーネルを起動し、サービスコンテナへのアクセスを提供します。

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()メソッドはテストコンテナを返し、テスト目的でプライベートサービスを公開します。これは、プライベートサービスがアクセス不可のままである実行時コンテナとは異なります。

トランザクションによるデータベース分離

テスト分離は、あるテストのデータが別のテストに影響を与えることを防ぎます。DAMADoctrineTestBundleは各テストを自動的にロールバックするトランザクションでラップします。

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
}

phpunit.xml.distでエクステンションを追加してバンドルを設定します。これにより、各テストは手動のクリーンアップコードなしで完全に分離された状態で実行されます。

Symfonyの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

WebTestCaseによる機能テスト

機能テストはHTTPリクエストをシミュレートし、レスポンスを検証します。WebTestCaseは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);
    }
}

$client->request()メソッドは、HTTPメソッド、URI、パラメータ、ファイル、サーバー変数、ボディコンテンツを受け取ります。Symfonyはミドルウェア、セキュリティ、ルーティングを含む完全なカーネルを通じてリクエストを処理します。

認証付きリクエストのテスト

認証が必要なエンドポイントの場合、Symfonyのセキュリティコンポーネントはテストクライアント上でloginUser()メソッドを提供しています。

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()メソッドはログインフォームを経由せずにセキュリティトークンを設定するため、テストがより高速かつ焦点を絞ったものになります。

フォームテストとCSRF処理

フォームのテストにはCSRFトークンの処理が必要です。クローラーコンポーネントはフォーム要素を抽出し、適切なトークン処理でそれらを送信します。

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()メソッドは送信ボタンのテキストでフォームを見つけます。表示可能なボタンがないフォームの場合は、CSSセレクタを使用したselectForm()を使用します。

コンソールコマンドのテスト

コマンドはデータインポートやスケジュールされたジョブなどの重要な操作を実行することが多いです。CommandTesterはコマンドを実行し、出力をキャプチャします。

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

対話型入力のあるコマンドの場合は、execute()の2番目の引数として['interactive' => false]を渡すか、setInputs()を使用してユーザーの応答をシミュレートします。

非同期ジョブのMessengerテスト

Symfony Messengerハンドラーのテストでは、メッセージが正しくディスパッチされ、ハンドラーが期待どおりに処理することを検証する必要があります。

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

config/packages/test/messenger.yamlでインメモリトランスポートを設定し、実際のキュー処理なしでディスパッチされたメッセージをキャプチャします。

コードカバレッジ設定

PHPUnitコードカバレッジレポートは、テストされていないコードパスを特定します。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>

php bin/phpunit --coverage-html var/coverageを使用してカバレッジ付きでテストを実行します。HTMLレポートは行ごとのカバレッジ状況を表示します。

まとめ

  • コンテナ依存のない純粋なユニットテストにはTestCaseを使用します
  • テストにコンテナからのサービスが必要な場合はKernelTestCaseを使用します
  • HTTPリクエストシミュレーションとレスポンスアサーションにはWebTestCaseを使用します
  • テスト間の自動トランザクションロールバックのためにDAMADoctrineTestBundleを設定します
  • ログインフォームをシミュレートするのではなく、loginUser()で認証をテストします
  • 入出力キャプチャを伴うコンソールコマンド検証にはCommandTesterを使用します
  • Messengerテスト用にテスト環境でインメモリトランスポートを設定します
  • 明確性のためにテストをUnit、Integration、Functionalディレクトリに整理します

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

共有

関連記事