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
- Introduction and Fundamentals
- Installation and Configuration
- The First Unit Test — Step by Step
- Assertions — All Verification Methods at a Glance
- Data Providers — Parameterized Tests
- Test Doubles: Mocks, Stubs and Fakes
- Exception Testing
- Testing Service Objects
- Database Tests
- API Mocking — Creating Pseudo Endpoints
- Testing Microservices
- Symfony Integration in Detail
- Code Coverage and Quality Metrics
- CI/CD Integration
- Best Practices and Project Structure
- 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
srcstructure --src/Service/Calculator.phpbecomestests/Unit/Service/CalculatorTest.php
The Most Important Rules
- One test, one scenario: Each test checks exactly ONE thing. Not three assertions for three different scenarios in one test.
- Tests must be isolated: No test may depend on another. The order must not matter.
- No logic in tests: Tests should be simple and linear. No if-else, no loops (except in data providers).
- Program against interfaces: When a service expects
TaxServiceInterfaceinstead ofTaxService, it can be trivially mocked. - Test data is explicit: Write the values directly in the test. The test should be understandable on its own.
- 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.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.