Zum Inhalt springen

PHPUnit Complete Handbook in German

Veröffentlicht am Feb 22, 2026 | ca. 4 Min. Lesezeit |

For a long time, there was no comprehensive German-language handbook for PHPUnit. The official documentation is in English and often very brief. Conference talks rarely go beyond the basics. This handbook fills that gap — for everyone who wants to learn PHPUnit from scratch or deepen their knowledge.

Table of Contents

  1. Introduction and Fundamentals
  2. Installation and Configuration
  3. The First Unit Test — Step by Step
  4. Assertions — All Verification Methods at a Glance
  5. Data Providers — Parameterized Tests
  6. Test Doubles: Mocks, Stubs and Fakes
  7. Exception Testing
  8. Testing Service Objects
  9. Database Tests
  10. API Mocking — Creating Pseudo Endpoints
  11. Testing Microservices
  12. Symfony Integration in Detail
  13. Code Coverage and Quality Metrics
  14. CI/CD Integration
  15. Best Practices and Project Structure
  16. Appendix: Important Attributes (PHPUnit 12+)

1. Introduction and Fundamentals

What is PHPUnit?

PHPUnit is the standard testing framework for PHP, developed and maintained by Sebastian Bergmann. It is based on the xUnit architecture and allows you to write automated tests for PHP code. Instead of manually clicking through the application and checking whether everything works, you write tests that do this automatically — every time you trigger the command.

Why Automated Tests?

We all know the scenario: a function is changed and suddenly something breaks in a completely different place. Automated tests prevent exactly that:

  • Prevent regression: Changes don't break existing code because tests report it immediately.
  • Enable refactoring: You can safely restructure code because tests confirm everything still works.
  • Documentation: Tests describe what the code should do — better than any comment.
  • Faster feedback: Instead of 10 minutes of manual testing, the test suite runs in seconds.
  • Confidence: You deploy on Friday afternoon without being afraid.

Types of Tests

Test Type What is tested? Speed Dependencies
Unit Test A single class/method in isolation Very fast (ms) None (everything mocked)
Integration Test Interaction of multiple classes, DB, APIs Medium (seconds) Database, services
Functional/E2E Test Complete request-response cycle Slow Full application

The test pyramid: You write many unit tests (fast, cheap), some integration tests and few E2E tests. Unit tests form the foundation.

Terminology

Term Explanation
Test Case A class that extends TestCase and contains test methods
Test Suite A collection of test cases, configured in phpunit.xml
Assertion A verification, e.g. assertSame(5, $result) — the core of every test
Fixture The defined initial state (setUp/tearDown)
Mock A fake object that verifies method calls
Stub A fake object that returns predefined values
Data Provider Supplies multiple data sets for the same test
Code Coverage Percentage of code executed by tests

2. Installation and Configuration

Prerequisites

PHPUnit Version Minimum PHP Version Support Until
PHPUnit 11 PHP 8.2 Discontinued (02/2026)
PHPUnit 12 PHP 8.3 February 2027
PHPUnit 13 PHP 8.4 February 2028

Installation via Composer

composer require --dev phpunit/phpunit

PHPUnit 10+ requires PHP 8.1 or higher. Configuration is done via 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="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         executionOrder="depends,defects"
         failOnRisky="true"
         failOnWarning="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>src</directory>
        </include>
        <exclude>
            <directory>src/Migrations</directory>
        </exclude>
    </source>
    <php>
        <env name="APP_ENV" value="test"/>
    </php>
</phpunit>

Note: PHPUnit 10 changed the configuration — <coverage> was replaced by <source>, and options like convertDeprecationsToExceptions no longer exist.

Directory Structure

tests/
├── Unit/           # Isolated tests without external dependencies
├── Integration/    # Tests with database, filesystem etc.
└── Functional/     # HTTP tests (Symfony WebTestCase)

Running Tests

# Run all tests
vendor/bin/phpunit

# Run only unit tests
vendor/bin/phpunit --testsuite Unit

# Only a specific test class
vendor/bin/phpunit tests/Unit/Service/CalculatorTest.php

# Only a specific method
vendor/bin/phpunit --filter testAddsTwoNumbers

# Stop at the first failure
vendor/bin/phpunit --stop-on-failure

3. The First Unit Test — Step by Step

The Class Under Test

A classic teaching example — a calculator that demonstrates all important concepts:

<?php

declare(strict_types=1);

namespace App\Service;

class Calculator
{
    public function add(float $a, float $b): float
    {
        return $a + $b;
    }

    public function subtract(float $a, float $b): float
    {
        return $a - $b;
    }

    public function divide(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new \DivisionByZeroError(
                'Division durch Null ist nicht erlaubt.'
            );
        }

        return $a / $b;
    }

    public function percentage(float $value, float $percent): float
    {
        return $value * ($percent / 100);
    }
}

The Corresponding Test

Every test follows the AAA pattern (Arrange, Act, Assert): first you prepare the data (Arrange), then you execute the action (Act), and finally you verify the result (Assert).

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\Calculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase
{
    private Calculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new Calculator();
    }

    #[Test]
    public function addsTwoPositiveNumbers(): void
    {
        $result = $this->calculator->add(2, 3);

        $this->assertSame(5.0, $result);
    }

    #[Test]
    public function addsTwoNegativeNumbers(): void
    {
        $result = $this->calculator->add(-2, -3);
        $this->assertSame(-5.0, $result);
    }

    #[Test]
    public function dividesCorrectly(): void
    {
        $result = $this->calculator->divide(10, 3);
        $this->assertEqualsWithDelta(3.333, $result, 0.001);
    }

    #[Test]
    public function divideByZeroThrowsException(): void
    {
        $this->expectException(\DivisionByZeroError::class);
        $this->expectExceptionMessage('Division durch Null');

        $this->calculator->divide(10, 0);
    }
}

Lifecycle Methods (Fixtures)

Method When is it called? Typical use
setUp() Before EVERY single test Create objects, prepare test data
tearDown() After EVERY single test Delete temporary files, close connections
setUpBeforeClass() Once before ALL tests in the class Open database connection, expensive setups
tearDownAfterClass() Once after ALL tests in the class Close database connection

Important: Every test must work independently. Tests must NOT influence each other. That's why you use setUp() instead of the constructor — so every test gets a fresh object.


4. Assertions — All Verification Methods at a Glance

Assertions are the heart of every test. PHPUnit offers over 60 different assertions. Here are the most important ones, grouped by use case:

Equality and Identity

Assertion Checks
assertSame($expected, $actual) Identical (===)
assertEquals($expected, $actual) Equal (==)
assertNotSame($expected, $actual) Not identical (!==)
assertEqualsWithDelta($expected, $actual, $delta) Float comparison with tolerance

Booleans and Null

Assertion Checks
assertTrue($value) Is true
assertFalse($value) Is false
assertNull($value) Is null
assertNotNull($value) Is not null
assertEmpty($value) Is empty
assertNotEmpty($value) Is not empty

Strings

Assertion Checks
assertStringContainsString($needle, $haystack) String contains substring
assertStringStartsWith($prefix, $string) String starts with ...
assertStringEndsWith($suffix, $string) String ends with ...
assertMatchesRegularExpression($pattern, $string) Regex match

Arrays and Collections

Assertion Checks
assertCount($expected, $array) Number of elements
assertContains($needle, $array) Array contains element
assertArrayHasKey($key, $array) Key exists
assertContainsOnly($type, $array) All elements are of type

Types and Objects

Assertion Checks
assertInstanceOf($class, $object) Object type
assertIsArray($value) Is an array
assertIsString($value) Is a string
assertIsInt($value) Is an integer
assertObjectHasProperty($prop, $obj) Object has property

Files

Assertion Checks
assertFileExists($path) File exists
assertFileIsReadable($path) File is readable
assertFileEquals($expected, $actual) Files are identical
assertDirectoryExists($path) Directory exists

Important: You should prefer assertSame() over assertEquals(). assertSame() checks type and value, assertEquals() only the value. This prevents subtle errors from type juggling.


5. Data Providers — Parameterized Tests

Data providers avoid duplication in tests with different input values. Instead of writing a separate test for each input value, you define one test and supply it with different data sets:

Simple Data Provider

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

class CalculatorTest extends TestCase
{
    public static function additionProvider(): array
    {
        return [
            'zwei positive'    => [1, 2, 3.0],
            'zwei negative'    => [-1, -2, -3.0],
            'positiv+negativ'  => [5, -3, 2.0],
            'mit Null'         => [0, 0, 0.0],
            'Gleitkomma'       => [0.1, 0.2, 0.3],
        ];
    }

    #[Test]
    #[DataProvider('additionProvider')]
    public function addsNumbers(float $a, float $b, float $expected): void
    {
        $calc = new Calculator();
        $this->assertEqualsWithDelta($expected, $calc->add($a, $b), 0.0001);
    }
}

Data Provider with Generator

For very large numbers of test cases, you can use a generator to save memory:

public static function largeDatasetProvider(): \Generator
{
    for ($i = 0; $i < 1000; $i++) {
        yield "Testfall $i" => [$i, $i * 2, $i * 3];
    }
}

Data Provider with External Data

CSV files or JSON can also be used as data sources:

public static function csvProvider(): array
{
    $data = [];
    $file = fopen(__DIR__ . '/testdata/calculations.csv', 'r');
    fgetcsv($file); // Header überspringen

    while (($row = fgetcsv($file)) !== false) {
        $data[$row[0]] = [(float) $row[1], (float) $row[2], (float) $row[3]];
    }

    fclose($file);

    return $data;
}

Caution: Data provider methods must be static since PHPUnit 10+! Without static you'll get a deprecation warning (PHPUnit 12) or an error (PHPUnit 13).


6. Test Doubles: Mocks, Stubs and Fakes

In reality, almost every class has dependencies: another class, a database, an API. In a true unit test, however, you want to test ONLY the one class — without the dependencies. That's what test doubles are for.

Understanding the Differences

Type What does it do? Created with
Stub Returns predefined return values. Does NOT check whether methods are called. createStub()
Mock Returns values AND verifies that specific methods are called. createMock()
Fake A simplified, working implementation (e.g. in-memory repository). Manual class

Stubs in Detail

Stubs are suitable when you only want to simulate dependencies without checking whether they are called:

<?php

declare(strict_types=1);

// src/Service/PriceCalculator.php
class PriceCalculator
{
    public function __construct(
        private TaxServiceInterface $taxService
    ) {}

    public function calculateGross(float $net): float
    {
        $taxRate = $this->taxService->getCurrentRate();

        return $net * (1 + $taxRate / 100);
    }
}

// tests/Unit/Service/PriceCalculatorTest.php
#[Test]
public function calculatesGrossPriceWithTax(): void
{
    $taxService = $this->createStub(TaxServiceInterface::class);
    $taxService->method('getCurrentRate')
        ->willReturn(19.0);

    $calculator = new PriceCalculator($taxService);

    $result = $calculator->calculateGross(100.0);
    $this->assertSame(119.0, $result);
}

Mocks in Detail

Mocks are suitable when you want to verify THAT and HOW a method is called:

<?php

declare(strict_types=1);

#[Test]
public function sendsEmailWhenOrderIsCompleted(): void
{
    $order = new Order(42, 'kunde@example.de');
    $repository = $this->createStub(OrderRepository::class);
    $repository->method('find')->willReturn($order);

    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())
        ->method('send')
        ->with(
            'kunde@example.de',
            'Bestellung abgeschlossen',
            $this->stringContains('#42')
        );

    $processor = new OrderProcessor($mailer, $repository);
    $processor->completeOrder(42);
}

Invocation Counts

Method Meaning
$this->once() Exactly once
$this->exactly(3) Exactly 3 times
$this->atLeastOnce() At least once
$this->atMost(2) At most 2 times
$this->never() Never (must not be called)

Callback-Based Return Values

Sometimes you need to calculate the return value dynamically:

$repository = $this->createStub(UserRepository::class);
$repository->method('findByEmail')
    ->willReturnCallback(function (string $email): ?User {
        if ($email === 'admin@example.de') {
            return new User('admin@example.de', 'Admin');
        }

        return null;
    });

Consecutive Return Values

When a method is called multiple times and should return something different each time:

$mock->method('isAvailable')
    ->willReturnOnConsecutiveCalls(true, true, false);

Fakes — Hand-Written Test Doubles

Sometimes a mock is too cumbersome. Then you write a fake:

<?php

declare(strict_types=1);

class InMemoryUserRepository implements UserRepositoryInterface
{
    /** @var User[] */
    private array $users = [];

    public function save(User $user): void
    {
        $this->users[$user->getId()] = $user;
    }

    public function find(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

    public function findByEmail(string $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->getEmail() === $email) {
                return $user;
            }
        }

        return null;
    }
}

When to use Stub, Mock, or Fake?

  • Stub: You need return values but don't check the interaction.
  • Mock: You want to verify THAT and HOW something is called.
  • Fake: The logic is too complex for stubs/mocks (e.g. a complete repository).

7. Exception Testing

Code should not only work in the normal case, but also react correctly to errors. PHPUnit offers several ways to test exceptions:

Simple Exception Test

#[Test]
public function throwsExceptionForInvalidEmail(): void
{
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Ungültige E-Mail');

    new EmailAddress('keine-email');
}

Checking the Exception Code

#[Test]
public function throwsExceptionWithCorrectCode(): void
{
    $this->expectException(PaymentException::class);
    $this->expectExceptionCode(402);
    $this->expectExceptionMessageMatches('/Zahlung.*fehlgeschlagen/');

    $gateway->processPayment(-50);
}

Exception with Callback (more flexible)

When you need to examine the exception more closely:

#[Test]
public function exceptionContainsContext(): void
{
    try {
        $service->processOrder(999);
        $this->fail('Erwartete Exception wurde nicht geworfen');
    } catch (OrderNotFoundException $e) {
        $this->assertSame(999, $e->getOrderId());
        $this->assertStringContainsString('nicht gefunden', $e->getMessage());
    }
}

Watch the order! With expectException(), the expectation must come BEFORE the code that throws the exception. Code after the triggering call will NOT be executed.


8. Testing Service Objects

In a typical Symfony application, business logic is encapsulated in services. Services have dependencies (other services, repositories, mailers, etc.) that are injected via dependency injection. This is exactly what makes them testable.

A Realistic Example

<?php

declare(strict_types=1);

namespace App\Service;

class InvoiceService
{
    public function __construct(
        private InvoiceRepositoryInterface $repository,
        private TaxCalculatorInterface $taxCalculator,
        private PdfGeneratorInterface $pdfGenerator,
        private MailerInterface $mailer,
    ) {}

    public function createAndSendInvoice(
        Customer $customer,
        array $lineItems
    ): Invoice {
        $netTotal = array_sum(array_map(
            fn(LineItem $item) => $item->getPrice() * $item->getQuantity(),
            $lineItems
        ));

        $tax = $this->taxCalculator->calculate($netTotal, $customer->getCountry());

        $invoice = new Invoice($customer, $lineItems, $netTotal, $tax);
        $this->repository->save($invoice);

        $pdf = $this->pdfGenerator->generate($invoice);

        $this->mailer->sendWithAttachment(
            $customer->getEmail(),
            'Ihre Rechnung #' . $invoice->getNumber(),
            $pdf
        );

        return $invoice;
    }
}

The Complete Test

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(InvoiceService::class)]
class InvoiceServiceTest extends TestCase
{
    private InvoiceService $service;
    private $repository;
    private $taxCalculator;
    private $pdfGenerator;
    private $mailer;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(InvoiceRepositoryInterface::class);
        $this->taxCalculator = $this->createStub(TaxCalculatorInterface::class);
        $this->pdfGenerator = $this->createStub(PdfGeneratorInterface::class);
        $this->mailer = $this->createMock(MailerInterface::class);

        $this->service = new InvoiceService(
            $this->repository,
            $this->taxCalculator,
            $this->pdfGenerator,
            $this->mailer,
        );
    }

    #[Test]
    public function createsInvoiceWithCorrectTotals(): void
    {
        $customer = new Customer('Max', 'max@example.de', 'DE');
        $items = [
            new LineItem('Widget', 10.00, 2),
            new LineItem('Gadget', 25.00, 1),
        ];

        $this->taxCalculator->method('calculate')
            ->willReturn(8.55);

        $this->pdfGenerator->method('generate')
            ->willReturn('PDF-BYTES');

        $this->repository->expects($this->once())->method('save');
        $this->mailer->expects($this->once())
            ->method('sendWithAttachment')
            ->with(
                'max@example.de',
                $this->stringContains('Rechnung'),
                'PDF-BYTES'
            );

        $invoice = $this->service->createAndSendInvoice($customer, $items);

        $this->assertSame(45.00, $invoice->getNetTotal());
        $this->assertSame(8.55, $invoice->getTax());
    }
}

Principle: Test behavior, not implementation. The test checks WHAT the service does (correct totals, email is sent), not HOW it does it.


9. Database Tests

Database tests are integration tests: you test whether queries and Doctrine/repository code work correctly with a real database.

Strategy: Test Database

You need a separate test database. Never use the production or development database for tests!

# .env.test
DATABASE_URL="mysql://root:root@127.0.0.1:3306/myapp_test"

# Create database and schema
php bin/console doctrine:database:create --env=test
php bin/console doctrine:schema:create --env=test

Base Class for Database Tests

An abstract base class with transaction rollback ensures the database is clean after every test:

<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

abstract class DatabaseTestCase extends KernelTestCase
{
    protected EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();

        $this->em = static::getContainer()
            ->get(EntityManagerInterface::class);

        $this->em->beginTransaction();
    }

    protected function tearDown(): void
    {
        if ($this->em->getConnection()->isTransactionActive()) {
            $this->em->rollback();
        }

        $this->em->close();
        parent::tearDown();
    }
}

Repository Test Example

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Repository;

use App\Entity\Product;
use App\Repository\ProductRepository;
use App\Tests\Integration\DatabaseTestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductRepositoryTest extends DatabaseTestCase
{
    private ProductRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = static::getContainer()
            ->get(ProductRepository::class);
    }

    #[Test]
    public function findsProductBySlug(): void
    {
        $product = new Product();
        $product->setName('Test-Widget');
        $product->setSlug('test-widget');
        $product->setPrice(29.99);

        $this->em->persist($product);
        $this->em->flush();

        $found = $this->repository->findBySlug('test-widget');

        $this->assertNotNull($found);
        $this->assertSame('Test-Widget', $found->getName());
        $this->assertSame(29.99, $found->getPrice());
    }

    #[Test]
    public function returnsNullForUnknownSlug(): void
    {
        $found = $this->repository->findBySlug('gibt-es-nicht');
        $this->assertNull($found);
    }
}

Fixtures with Foundry

Instead of creating test data manually, you can use zenstruck/foundry — the de facto standard for test fixtures in Symfony:

composer require --dev zenstruck/foundry
php bin/console make:factory Product
// Use in test:
#[Test]
public function findsOnlyActiveProducts(): void
{
    ProductFactory::createMany(3);
    ProductFactory::createOne(['active' => false]);

    $active = $this->repository->findAllActive();
    $this->assertCount(3, $active);
}

Tip: The transaction rollback in tearDown() is the fastest way to clean the database after each test. No TRUNCATE statements, no reloading fixtures — just rollback.


10. API Mocking — Creating Pseudo Endpoints

When the application calls external APIs (e.g. Google Maps, Stripe, OpenAI), you do NOT want to hit the real API in tests. Reasons: cost, speed, reliability and reproducibility.

Symfony's MockHttpClient

Symfony provides a built-in MockHttpClient that simulates HTTP responses:

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class GeocodingService
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private string $googleApiKey,
    ) {}

    public function geocode(string $address): array
    {
        $response = $this->httpClient->request('GET',
            'https://maps.googleapis.com/maps/api/geocode/json', [
                'query' => [
                    'address' => $address,
                    'key' => $this->googleApiKey,
                ],
            ]
        );

        $data = $response->toArray();

        if ($data['status'] !== 'OK' || empty($data['results'])) {
            throw new GeocodingException(
                'Geocoding fehlgeschlagen: ' . ($data['status'] ?? 'unbekannt')
            );
        }

        $location = $data['results'][0]['geometry']['location'];

        return [
            'lat' => $location['lat'],
            'lng' => $location['lng'],
        ];
    }
}

The Test with MockHttpClient

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\GeocodingService;
use App\Exception\GeocodingException;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

class GeocodingServiceTest extends TestCase
{
    #[Test]
    public function geocodesAddressSuccessfully(): void
    {
        $googleResponse = json_encode([
            'status' => 'OK',
            'results' => [[
                'geometry' => [
                    'location' => ['lat' => 52.5200, 'lng' => 13.4050],
                ],
            ]],
        ]);

        $mockClient = new MockHttpClient([
            new MockResponse($googleResponse, [
                'http_code' => 200,
                'response_headers' => ['content-type' => 'application/json'],
            ]),
        ]);

        $service = new GeocodingService($mockClient, 'fake-api-key');
        $result = $service->geocode('Berlin, Deutschland');

        $this->assertEqualsWithDelta(52.52, $result['lat'], 0.01);
        $this->assertEqualsWithDelta(13.405, $result['lng'], 0.01);
    }

    #[Test]
    public function throwsExceptionForInvalidAddress(): void
    {
        $mockClient = new MockHttpClient([
            new MockResponse(json_encode([
                'status' => 'ZERO_RESULTS',
                'results' => [],
            ])),
        ]);

        $service = new GeocodingService($mockClient, 'fake-api-key');

        $this->expectException(GeocodingException::class);
        $service->geocode('xyzzy gibberish');
    }
}

Callback-Based MockHttpClient

For more dynamic scenarios, you can use a callback that inspects the request:

$mockClient = new MockHttpClient(function (string $method, string $url) {
    if (str_contains($url, '/geocode/')) {
        return new MockResponse(json_encode([
            'status' => 'OK',
            'results' => [['geometry' => ['location' => ['lat' => 52.52, 'lng' => 13.40]]]],
        ]));
    }

    return new MockResponse('Not Found', ['http_code' => 404]);
});

Loading Response Fixtures from Files

For large API responses, store the JSON responses as files:

$json = file_get_contents(__DIR__ . '/../../Fixtures/google_geocode_berlin.json');
$mockClient = new MockHttpClient([
    new MockResponse($json, ['http_code' => 200]),
]);

11. Testing Microservices

When the architecture consists of multiple microservices communicating via HTTP or message queues, you face special testing challenges.

Testing Service Clients in Isolation

Each microservice typically has a client that encapsulates the communication:

<?php

declare(strict_types=1);

class UserServiceClient
{
    public function __construct(
        private HttpClientInterface $client,
        private string $baseUrl,
    ) {}

    public function getUser(int $id): UserDto
    {
        $response = $this->client->request('GET',
            $this->baseUrl . '/api/users/' . $id
        );

        if ($response->getStatusCode() === 404) {
            throw new UserNotFoundException($id);
        }

        $data = $response->toArray();

        return new UserDto(
            id: $data['id'],
            name: $data['name'],
            email: $data['email'],
        );
    }
}
#[Test]
public function getsUserById(): void
{
    $mockClient = new MockHttpClient([
        new MockResponse(json_encode([
            'id' => 1,
            'name' => 'Max Mustermann',
            'email' => 'max@example.de',
        ]), ['http_code' => 200]),
    ]);

    $client = new UserServiceClient($mockClient, 'http://fake-url');
    $user = $client->getUser(1);

    $this->assertSame('Max Mustermann', $user->name);
    $this->assertSame('max@example.de', $user->email);
}

Symfony Messenger (Asynchronous Communication)

When microservices communicate via Symfony Messenger, you can use the InMemoryTransport:

# config/packages/test/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: 'in-memory://'
<?php

declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

class OrderServiceMessengerTest extends KernelTestCase
{
    #[Test]
    public function dispatchesPaymentMessageWhenOrderIsPlaced(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $orderService = $container->get(OrderService::class);
        $orderService->placeOrder(new OrderDto(/* ... */));

        /** @var InMemoryTransport $transport */
        $transport = $container->get('messenger.transport.async');
        $messages = $transport->getSent();

        $this->assertCount(1, $messages);
        $this->assertInstanceOf(
            ProcessPaymentMessage::class,
            $messages[0]->getMessage()
        );
    }
}

Contract Tests

Contract tests ensure that two services agree on the format of communication:

#[Test]
public function userServiceResponseMatchesExpectedContract(): void
{
    $expectedSchema = [
        'id' => 'integer',
        'name' => 'string',
        'email' => 'string',
        'created_at' => 'string',
    ];

    $response = json_decode(
        file_get_contents(__DIR__ . '/../../Fixtures/user_response.json'),
        true
    );

    foreach ($expectedSchema as $field => $type) {
        $this->assertArrayHasKey($field, $response);
        $this->assertSame($type, gettype($response[$field]),
            "Feld '$field' hat falschen Typ"
        );
    }
}

Microservice testing strategy: Unit tests for client classes with MockHttpClient, integration tests for Messenger messages, contract tests for API contracts, and only a few E2E tests that test all services together.


12. Symfony Integration in Detail

Symfony brings its own test classes that build on PHPUnit and provide access to the service container, HTTP client and crawler.

The Three Symfony Test Classes

Class Extends Use
KernelTestCase TestCase Access to service container, for service/repository tests
WebTestCase KernelTestCase HTTP client for testing controllers, forms, redirects
ApiTestCase * WebTestCase Specifically for JSON/REST APIs (API Platform)

* ApiTestCase comes from API Platform, not from Symfony Core.

KernelTestCase for Service Tests

<?php

declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class BlogServiceTest extends KernelTestCase
{
    public function testGetPostsReturnsSortedResults(): void
    {
        self::bootKernel();
        $service = static::getContainer()->get(BlogService::class);

        $posts = $service->getPosts();

        $this->assertNotEmpty($posts);
        for ($i = 1; $i < count($posts); $i++) {
            $this->assertGreaterThanOrEqual(
                strtotime($posts[$i]['date']),
                strtotime($posts[$i - 1]['date'])
            );
        }
    }
}

WebTestCase for Controller Tests

<?php

declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductControllerTest extends WebTestCase
{
    #[Test]
    public function productListPageReturns200(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/products');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Produkte');
        $this->assertSelectorExists('.product-card');
    }

    #[Test]
    public function contactFormSubmission(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/contact');

        $form = $crawler->selectButton('Absenden')->form([
            'contact[name]' => 'Max Mustermann',
            'contact[email]' => 'max@example.de',
            'contact[message]' => 'Testanfrage',
        ]);
        $client->submit($form);

        $this->assertResponseRedirects('/contact/success');
        $client->followRedirect();
        $this->assertSelectorTextContains('.alert-success', 'Danke');
    }

    #[Test]
    public function adminAreaRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin/dashboard');

        $this->assertResponseRedirects('/login');
    }
}

API Tests (JSON Responses)

<?php

declare(strict_types=1);

class ProductApiTest extends WebTestCase
{
    #[Test]
    public function apiReturnsProductList(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/products', [], [], [
            'HTTP_ACCEPT' => 'application/json',
        ]);

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

        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertIsArray($data);
        $this->assertNotEmpty($data);
    }
}

Authentication in Tests

#[Test]
public function adminCanAccessDashboard(): void
{
    $client = static::createClient();

    $userRepository = static::getContainer()
        ->get(UserRepository::class);
    $admin = $userRepository->findOneByEmail('admin@example.de');

    $client->loginUser($admin);

    $client->request('GET', '/admin/dashboard');
    $this->assertResponseIsSuccessful();
}

Overriding Services in Tests

Sometimes you want to replace a service with a fake in a functional test:

#[Test]
public function usesCustomServiceInTest(): void
{
    $client = static::createClient();

    $mockPayment = $this->createStub(PaymentGateway::class);
    $mockPayment->method('charge')->willReturn(true);

    // IMPORTANT: services_test.yaml must mark the service as public
    static::getContainer()->set(PaymentGateway::class, $mockPayment);

    $client->request('POST', '/checkout/pay', /* ... */);
    $this->assertResponseIsSuccessful();
}
# config/services_test.yaml
services:
    App\Service\PaymentGateway:
        public: true

13. Code Coverage and Quality Metrics

Setting Up Code Coverage

Code coverage measures what percentage of code is executed by tests. You need either Xdebug (slower, but also usable for debugging) or PCOV (faster, only for coverage):

# Install PCOV (recommended for CI)
pecl install pcov
echo 'extension=pcov.so' >> php.ini

# Generate coverage as HTML report
vendor/bin/phpunit --coverage-html coverage/

# Coverage as text in the console
vendor/bin/phpunit --coverage-text

# Clover format for CI/CD tools
vendor/bin/phpunit --coverage-clover coverage.xml

Coverage Attributes

With attributes you define which code a test should cover:

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\CoversNothing;

// Covers the entire class
#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase { /* ... */ }

// Covers only a specific method
#[CoversMethod(Calculator::class, 'add')]
public function addsNumbers(): void { /* ... */ }

// For integration tests that don't directly cover code
#[CoversNothing]
class SmokeTest extends WebTestCase { /* ... */ }

What is Good Coverage?

Coverage Rating Recommendation
< 30% Critical Urgently write tests for core logic
30-60% Needs improvement Focus on business logic and critical paths
60-80% Good Realistic goal for most projects
80-90% Very good Ideal, but not at any cost
> 90% Excellent Often only worthwhile for libraries/critical modules

100% is not the goal! 100% coverage does not mean the code is bug-free. It's better to thoroughly test the important scenarios than to cover every getter method.


14. CI/CD Integration

Tests reach their full value only when they run automatically on every commit. Here are the configurations for the most common CI systems.

GitHub Actions

# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]

jobs:
  phpunit:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: myapp_test
        ports:
          - 3306:3306
        options: --health-cmd='mysqladmin ping' --health-interval=10s
    strategy:
      matrix:
        php: ['8.3', '8.4']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: pcov
      - run: composer install --no-interaction
      - run: php bin/console doctrine:schema:create --env=test
      - run: vendor/bin/phpunit --coverage-clover coverage.xml
      - uses: codecov/codecov-action@v3
        with:
          file: coverage.xml

GitLab CI

# .gitlab-ci.yml
phpunit:
  stage: test
  image: php:8.4-cli
  services:
    - mysql:8.0
  variables:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: myapp_test
    DATABASE_URL: 'mysql://root:root@mysql:3306/myapp_test'
  before_script:
    - apt-get update && apt-get install -y git unzip libzip-dev
    - docker-php-ext-install zip pdo pdo_mysql
    - curl -sS https://getcomposer.org/installer | php
    - php composer.phar install --no-interaction
  script:
    - vendor/bin/phpunit --testsuite Unit
    - vendor/bin/phpunit --testsuite Integration --coverage-text
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Bitbucket Pipelines

image: php:8.2-cli

definitions:
  caches:
    composer: vendor

pipelines:
  default:
    - step:
        name: Tests & Quality
        caches:
          - composer
        script:
          - apt-get update && apt-get install -y git unzip libicu-dev
          - docker-php-ext-install intl pdo_mysql
          - curl -sS https://getcomposer.org/installer | php
          - php composer.phar install --prefer-dist --no-progress
          - php composer.phar audit
          - vendor/bin/phpunit --coverage-text --colors=never
        services:
          - mysql

  pull-requests:
    '**':
      - step:
          name: PR Checks
          caches:
            - composer
          script:
            - apt-get update && apt-get install -y git unzip libicu-dev
            - docker-php-ext-install intl pdo_mysql
            - curl -sS https://getcomposer.org/installer | php
            - php composer.phar install --prefer-dist --no-progress
            - vendor/bin/phpunit --testsuite=Unit

definitions:
  services:
    mysql:
      image: mariadb:10.11
      variables:
        MYSQL_ROOT_PASSWORD: root
        MYSQL_DATABASE: app_test

Pipeline Strategies

  • Default branch: Full test run with coverage + security audit
  • Pull requests: Only unit tests for fast feedback
  • Caching: Composer dependencies are cached, saving 30-60 seconds per build
  • Services: MariaDB/MySQL as service containers for integration tests

Parallelization

For large test suites, you can parallelize tests:

# With Paratest (multiple PHP processes simultaneously)
composer require --dev brianium/paratest

# 4 parallel processes
vendor/bin/paratest -p 4 --testsuite Unit

15. Best Practices and Project Structure

Naming Conventions

  • Test class: Name of the tested class + Test -- e.g. CalculatorTest
  • Test method: Describes the expected behavior: addsPositiveNumbers(), throwsExceptionForNegativePrice(), sendsEmailOnCompletion()
  • Directories: Mirror the src structure -- src/Service/Calculator.php becomes tests/Unit/Service/CalculatorTest.php

The Most Important Rules

  1. One test, one scenario: Each test checks exactly ONE thing. Not three assertions for three different scenarios in one test.
  2. Tests must be isolated: No test may depend on another. The order must not matter.
  3. No logic in tests: Tests should be simple and linear. No if-else, no loops (except in data providers).
  4. Program against interfaces: When a service expects TaxServiceInterface instead of TaxService, it can be trivially mocked.
  5. Test data is explicit: Write the values directly in the test. The test should be understandable on its own.
  6. Minimize setUp(): Only put in what really ALL tests in the class need.

What to Test (and What Not)

Test Do NOT test
Business logic in services Getters and setters without logic
Validation rules Framework code (Symfony itself)
Edge cases (null, empty arrays, 0) Trivial constructors
Error handling (exceptions) Third-party libraries
Repository queries (integration) Private methods directly
API contracts Configuration files

Setting Up Composer Scripts

Add shortcuts to composer.json so the team uses consistent commands:

{
    "scripts": {
        "test": "vendor/bin/phpunit",
        "test:unit": "vendor/bin/phpunit --testsuite Unit",
        "test:integration": "vendor/bin/phpunit --testsuite Integration",
        "test:coverage": "vendor/bin/phpunit --coverage-html coverage/",
        "test:fast": "vendor/bin/phpunit --stop-on-failure --testsuite Unit"
    }
}
# Then simply:
composer test
composer test:unit
composer test:coverage

16. Appendix: Important Attributes (PHPUnit 12+)

Since PHPUnit 12, PHP attributes are the only way to bind metadata to tests. Here is the complete reference:

Attribute Replaces Annotation Description
#[Test] @test Marks a method as a test
#[DataProvider('name')] @dataProvider Links to data provider
#[Depends('testName')] @depends Test depends on another test
#[CoversClass(X::class)] @covers Test covers class X
#[CoversMethod(X::class, 'y')] @covers X::y Test covers method
#[CoversNothing] @coversNothing No coverage tracking
#[Group('slow')] @group Groups tests (for filtering)
#[RequiresPhp('>=8.4')] @requires PHP Skips with wrong PHP
#[RequiresPhpExtension('redis')] @requires ext Skips when extension is missing
#[BackupGlobals(true)] @backupGlobals Backs up global variables
#[RunInSeparateProcess] @runInSep... Runs in own PHP process

This handbook has covered all important PHPUnit concepts — from simple unit tests through database tests and API mocking to Symfony integration and CI/CD pipelines. The most important tip to close with: start small! For the next feature, write the test first, then the code (Test-Driven Development). After a few weeks, it becomes a habit.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.