
Contao 5 Unit Testing: Code-Absicherung mit PHPUnit & Test-Case

Professionelles Contao 5 Unit Testing ist in den Jahren 2025 und 2026 keine Option mehr, sondern Pflicht. Während früher oft "auf gut Glück" programmiert wurde, erfordert die moderne Architektur mit Contao 5.7 und dem kommenden Contao 6 eine strikte Absicherung. Wer Contao 5 Unit Testing ignoriert, produziert technische Schulden, die spätestens beim Update auf Symfony 7 oder 8 teuer werden.
In diesem Teil nutzen wir das Paket contao/test-case, um unsere Business-Logik (AvailabilityChecker) gegen Bugs zu impfen. Wir isolieren Abhängigkeiten und simulieren Datenbanken, damit deine Tests in Millisekunden laufen.
1. Die Test-Umgebung vorbereiten
Der Core von Contao nutzt inzwischen PHPUnit 12. Das bedeutet für dein Contao 5 Unit Testing, dass du dich an modernen Standards orientieren musst. Alte Tests, die noch auf PHPUnit 9 basieren, werden in Contao 5.7 und 6.0 nicht mehr laufen.
Viele Entwickler scheuen Unit-Tests in Contao, weil das CMS früher stark von globalen Zuständen ($GLOBALS) abhängig war. Mit dem Paket contao/test-case isolieren wir diese Abhängigkeiten. Es ist das Herzstück unserer Code-Absicherung.

Schritt 1: Installation der Tools
Da wir uns an modernen Standards orientieren, installieren wir PHPUnit und das Test-Case-Bundle:
composer require --dev contao/test-case phpunit/phpunitSchritt 2: Die Konfiguration (phpunit.xml.dist)
Erstelle die Datei phpunit.xml.dist im Root deines Bundles. Wir konfigurieren hier direkt die Testsuite und setzen Umgebungsvariablen für den Kernel.
1<?xml version="1.0" encoding="UTF-8"?>
2<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
4 colors="true"
5 bootstrap="vendor/autoload.php"
6 cacheDirectory=".phpunit.cache">
7
8 <php>
9 <ini name="display_errors" value="1" />
10 <ini name="error_reporting" value="-1" />
11 <server name="APP_ENV" value="test" force="true" />
12 <server name="KERNEL_CLASS" value="Contao\CoreBundle\HttpKernel\Kernel" />
13 </php>
14
15 <testsuites>
16 <testsuite name="BeachsideBundle Test Suite">
17 <directory>tests</directory>
18 </testsuite>
19 </testsuites>
20</phpunit>Damit steht das Gerüst. Wir sind bereit, unsere Business-Logik gegen Bugs zu impfen.
2. Die Test-Klasse erstellen
In Teil 5 haben wir den AvailabilityChecker geschrieben. Dieser Service prüft, ob ein Zeitraum frei ist. Das ist kritische Logik – ein Bug hier kostet bares Geld (Doppelbuchungen!).
Wir erstellen nun die Test-Klasse unter /tests/Service/AvailabilityCheckerTest.php. Wir erben von Contao\TestCase\ContaoTestCase. Diese Basisklasse erweitert PHPUnit und stellt uns Methoden bereit, die speziell auf die Architektur von Contao zugeschnitten sind.
1<?php
2
3namespace Acme\BeachsideBundle\Tests\Service;
4
5use Acme\BeachsideBundle\Entity\BeachChair;
6use Acme\BeachsideBundle\Repository\BookingRepository;
7use Acme\BeachsideBundle\Service\AvailabilityChecker;
8use Contao\TestCase\ContaoTestCase;
9use PHPUnit\Framework\MockObject\MockObject;
10
11class AvailabilityCheckerTest extends ContaoTestCase
12{
13 // Wir nutzen PHP 8 Types für sauberen Code
14 private MockObject&BookingRepository $repositoryMock;
15 private AvailabilityChecker $checker;
16
17 protected function setUp(): void
18 {
19 // 1. Wir mocken das Repository.
20 // Wir wollen NICHT die echte Datenbank testen (das wäre langsam und fehleranfällig).
21 $this->repositoryMock = $this->createMock(BookingRepository::class);
22
23 // 2. Wir injizieren den Mock in unseren Service (Dependency Injection).
24 $this->checker = new AvailabilityChecker($this->repositoryMock);
25 }
26}3. Datenbank-Antworten simulieren
Unser AvailabilityChecker ruft $repository->hasOverlap() auf. Im Rahmen von Contao 5 Unit Testing müssen wir definieren, was dieser Aufruf zurückgeben soll. Wir "faken" die Antwort.
Wir testen das Szenario: "Der Zeitraum ist frei (Keine Überlappung)."
Füge diese Methode zur Klasse hinzu:
1public function testIsAvailableReturnsTrueIfNoOverlap(): void
2 {
3 // 1. Arrange (Vorbereitung der Testdaten)
4 $chair = new BeachChair();
5 $start = new \DateTimeImmutable('2026-08-01');
6 $end = new \DateTimeImmutable('2026-08-07');
7
8 // 2. Expectation (Erwartungshaltung an den Mock)
9 // Wir erwarten, dass hasOverlap() genau EINMAL aufgerufen wird.
10 // WICHTIG: Wir prüfen auch, ob die Parameter (Timestamps) stimmen!
11 $this->repositoryMock->expects($this->once())
12 ->method('hasOverlap')
13 ->with(
14 $this->equalTo($chair),
15 $this->equalTo($start->getTimestamp()), // Check: Wurde DateTime korrekt in Int gewandelt?
16 $this->equalTo($end->getTimestamp())
17 )
18 ->willReturn(false); // Die simulierte DB sagt: "Keine Überlappung gefunden"
19
20 // 3. Act (Ausführung)
21 $result = $this->checker->isAvailable($chair, $start, $end);
22
23 // 4. Assert (Prüfung des Ergebnisses)
24 $this->assertTrue($result, 'Der Strandkorb sollte verfügbar sein, wenn die DB false zurückgibt.');
25 }Hier prüfen wir nicht nur das Ergebnis, sondern auch die Integration: Ruft der Service das Repository korrekt auf?
4. Negative Tests & Guard Clauses
Ein robuster Service muss falsche Eingaben abfangen. In Teil 5 hatten wir: if ($start > $end) return false;. Lass uns sicherstellen, dass dieser Schutzmechanismus funktioniert – und zwar ohne dass eine teure Datenbankabfrage passiert.
1public function testReturnsFalseIfStartDateIsAfterEndDate(): void
2 {
3 $chair = new BeachChair();
4 $start = new \DateTimeImmutable('2026-08-10');
5 $end = new \DateTimeImmutable('2026-08-01'); // Fehler: Ende vor Start!
6
7 // WICHTIG: Das Repository darf NICHT aufgerufen werden!
8 // Das beweist, dass unser "Guard Clause" greift und Performance spart.
9 $this->repositoryMock->expects($this->never())
10 ->method('hasOverlap');
11
12 $result = $this->checker->isAvailable($chair, $start, $end);
13
14 $this->assertFalse($result, 'Sollte false zurückgeben bei ungültigem Zeitraum.');
15 }
16
17 public function testIsAvailableReturnsFalseIfOverlapExists(): void
18 {
19 // Szenario: DB sagt "Voll".
20 $this->repositoryMock->method('hasOverlap')->willReturn(true);
21
22 $result = $this->checker->isAvailable(
23 new BeachChair(),
24 new \DateTimeImmutable('now'),
25 new \DateTimeImmutable('+1 day')
26 );
27
28 $this->assertFalse($result, 'Sollte false zurückgeben, wenn Überlappung existiert.');
29 }Damit ist die Logik unseres Services zu 100% abgesichert.
5. Statische Aufrufe bändigen (Adapters)
Contao nutzt oft noch statische Aufrufe (z.B. Config::get()). contao/test-case bietet hierfür createContaoFrameworkMock und Adapter-Stubs. Das ist ein Alleinstellungsmerkmal von Contao 5 Unit Testing.
contao/test-case löst das elegant über Adapter.
Angenommen, unser Service würde Config::get('dateFormat') nutzen. So testen wir das:
1public function testWithLegacyConfigUsage(): void
2 {
3 // 1. Adapter erstellen (Mock für Config Klasse)
4 $configAdapter = $this->createAdapterMock(['get']);
5 $configAdapter->method('get')
6 ->with('dateFormat')
7 ->willReturn('Y-m-d');
8
9 // 2. Contao Framework Mock hochfahren und Adapter injizieren
10 $framework = $this->createContaoFrameworkMock([
11 'Contao\Config' => $configAdapter
12 ]);
13
14 // Simuliert, dass Contao initialisiert ist
15 $framework->expects($this->atLeastOnce())->method('initialize');
16
17 // ... Hier würde der Code folgen, der Config::get() nutzt ...
18 }Mit createConfiguredAdapterStub kannst du das sogar noch kompakter schreiben, wenn du nur einfache Rückgabewerte brauchst.
6. Contao Models und __get/__set
Contao-Models (PageModel, MemberModel) nutzen magische Methoden für den Zugriff auf Datenbankfelder. Das macht Tests oft schwierig, da die IDE und PHPUnit diese dynamischen Eigenschaften nicht kennen.

Contao 5 bietet hierfür createClassWithPropertiesMock.
Beispiel: Wir wollen prüfen, ob der User Zugriff auf eine Seite hat.
1public function testPageModelAccess(): void
2 {
3 // Wir erstellen ein PageModel-Stub mit definierten Eigenschaften.
4 // Zugriff via $page->id oder $page->title funktioniert jetzt wie in echt.
5 $pageMock = $this->createClassWithPropertiesMock(
6 \Contao\PageModel::class,
7 [
8 'id' => 42,
9 'title' => 'Strandkorb Übersicht',
10 'published' => true
11 ]
12 );
13
14 $this->assertSame(42, $pageMock->id);
15 $this->assertTrue($pageMock->published);
16
17 // Das ist essenziell, wenn dein Service $page->title ausliest.
18 }Dies ist besonders wichtig für die Absicherung von Business-Logik, die intensiv mit Datenbankentitäten arbeitet.
7. Tests ausführen & CI Integration
Tests bringen nur etwas, wenn sie ausgeführt werden. Ein Pull Request im Contao Core wird beispielsweise nur akzeptiert, wenn alle Unit-Tests grün sind.
Manuelle Ausführung:
vendor/bin/phpunit tests/Service/AvailabilityCheckerTest.phpComposer Script (Empfohlen): Füge dies in deine composer.json ein, um es deinem Team einfach zu machen:
"scripts": {
"test": "phpunit --colors=always"
}Jetzt reicht ein einfaches composer test.
Profi-Tipp für 2026: Da Sicherheitslücken wie XSS in Templates (CVE-2025-65961) ein Thema sind, solltest du auch Rendering-Tests in Erwägung ziehen, bei denen du prüfst, ob Twig-Templates korrekt escapen. Das führt hier aber zu weit – für den Start sichert der Unit-Test des Services deine Logik perfekt ab.
Unterschied zwischen Contao 5.3 LTS und Contao 5.7 beim Testing
Da du wahrscheinlich mit Contao 5.3 LTS startest, aber Contao 5.7 bereits am Horizont steht (Release Februar 2026), ist es wichtig, die Unterschiede im Testing zu kennen. Die gute Nachricht: Die Konzepte bleiben gleich, aber die Tools werden schärfer.
PHPUnit Version:
Contao 5.3 LTS: Nutzt standardmäßig noch PHPUnit 9 oder 10 (je nach Projekt-Setup). Die Tests laufen stabil, aber du hast noch Zugriff auf ältere Methoden, die als "deprecated" markiert sind.
Contao 5.7: Hier ist PHPUnit 11 (und bald 12) der neue Standard. Das Entwicklerteam hat über 20 Stunden investiert, um die Core-Tests zu migrieren. Für dich bedeutet das: Wenn du jetzt schon auf PHPUnit 10+ setzt und Attribute statt Annotationen verwendest (z.B.
#[Test]statt@test), bist du zukunftssicher.
contao/test-caseFeatures:In Contao 5.7 profitierst du von verbesserten Mocking-Strategien, die speziell für die neueren PHPUnit-Versionen optimiert wurden. Die Klasse
ContaoTestCasewurde weiter verfeinert, um noch robuster mit dem Dependency-Injection-Container umzugehen.Die Trennung von Logik und Templates (Output Encoding) wird in 5.7 strikter. Das macht Tests einfacher, weil du weniger "HTML-Chaos" im Unit-Test berücksichtigen musst, sondern dich rein auf die Datenlogik konzentrierst.
Zukunftssicherheit:
Tests, die du heute für 5.3 schreibst, werden zu 99% auch in 5.7 laufen, sofern du keine veralteten Funktionen (Deprecations) nutzt. Der Sprung auf Contao 6 (geplant für 2026/2027) wird dann voraussichtlich nur noch Twig unterstützen, was deine jetzigen Service-Tests aber nicht betrifft, da diese völlig unabhängig vom Frontend sind.
Starte mit Contao 5.3 LTS, aber schreibe deine Tests so modern wie möglich (PHPUnit 10+ Style). Damit ist das Upgrade auf 5.7 später ein Kinderspiel.
Teil der Serie
Contao 5 Masterclass: The Beachside Project
Contao 5 Bundle Entwicklung: Die Masterclass für echte Entwickler Pillar
Contao 5 Bundle Setup: Das Fundament für professionelle Erweiterungen
Contao 5 Doctrine Entities: Moderne Datenmodellierung statt SQL-Chaos
Contao 5 DCA: Perfekte Backend-Masken für Entities erstellen
Contao 5 Doctrine Relations: Wir bauen die Buchungs-Logik
Contao 5 Service Layer: Business-Logik sauber kapseln
Contao 5 Unit Testing: Code-Absicherung mit PHPUnit & Test-Case
Contao 5 Twig Templates: Frontend-Ausgabe mit Fragment Controllern
Contao 5 Symfony Forms: Professionelle Formularverarbeitung
Contao 5 Notification Center: Zentrale Kommunikation & E-Mail-Workflows
Contao 5 Backend Dashboards & Custom Routing
Contao 5 Console Commands: Automatisierung & Hintergrund-Jobs
Contao 5 Headless CMS API: REST-Schnittstellen bauen
Häufig gestellte Fragen (FAQ)
Zusammenfassung & Ausblick
Du hast jetzt einen "kugelsicheren" Service dank Contao 5 Unit Testing.
Repository gemockt (keine DB nötig).
Logik isoliert getestet.
Legacy-Aufrufe (Framework) abgefangen.
Dein Backend ist sicher. Aber sieht der Kunde auch etwas? In Teil 7 bauen wir das Frontend mit modernen Twig-Templates.
Bereit für Teil 7? Frontend Ausgabe & Twig

Dietrich Bojko
Senior Webentwickler
Webinteger arbeitet seit vielen Jahren produktiv mit
Linux-basierten Entwicklungsumgebungen unter Windows.
Der Fokus liegt auf
performanten Setups mit WSL 2, Docker, PHP, Node.js und modernen
Build-Tools in realen Projekten –
nicht auf theoretischen Beispielkonfigurationen.
Die Artikel dieser Serie entstehen direkt aus dem täglichen Einsatz in Kunden- und Eigenprojekten und dokumentieren bewusst auch typische Fehler, Engpässe und bewährte Workarounds.


