Wann echter DB-Zugriff sinnvoll ist
Repository-Tests mit Mocks prüfen die falsche Schicht. Ein Mock-Repository kann kein SQL-Syntax-Problem, kein Index-Problem und keinen N+1-Bug entdecken. Echter Datenbankzugriff in Tests ist teuer – aber an den richtigen Stellen unersetzlich. Die Kunst liegt darin, zu wissen, wo genau diese Stellen sind und wie man Datenbankisolation zuverlässig umsetzt.
Inhaltsverzeichnis
- 1. Wann echter DB-Zugriff sinnvoll ist
- 2. Datenbankisolation: Das zentrale Problem
- 3. Transaktions-Rollback: Isolierung ohne Datenverlust
- 4. Fixtures und Datenvorbereitung
- 5. Doctrine ORM: Repository-Integrationstests
- 6. SQLite-in-Memory als Alternative
- 7. Typische Fehler bei Datenbanktests
- 8. Teststrategien im Vergleich
- 9. Zusammenfassung
- 10. FAQ
1. Wann echter DB-Zugriff sinnvoll ist
Die Entscheidung "echter DB-Zugriff oder Mock" ist keine Frage des Prinzips, sondern des Zwecks. Wenn der Test prüft, ob Business-Logik in einer Service-Klasse korrekt funktioniert, ist ein Mock-Repository richtig – der Test soll die Logik isolieren, nicht die Datenbankschicht. Wenn der Test aber prüft, ob ein Repository korrekt persistiert, korrekt lädt, korrekte SQL-Queries erzeugt und korrekte Ergebnisse zurückgibt, dann muss echter DB-Zugriff stattfinden.
Es gibt vier Klassen von Fehlern, die nur mit echter Datenbank gefunden werden: Erstens SQL-Fehler – falsche Spaltennamen, fehlende Joins, Syntaxfehler in Raw-Queries. Zweitens Mapping-Fehler – ORM-Konfigurationsfehler, die dazu führen, dass Objekte falsch gespeichert oder geladen werden. Drittens Performance-Probleme – N+1-Queries, fehlende Indizes, ungeeignete Lazy-Loading-Strategien. Viertens Constraint-Verletzungen – Unique-Constraints, Foreign-Key-Constraints, die bei bestimmten Datenkombinationen Fehler erzeugen. Für alle vier Fehlerklassen ist echter DB-Zugriff unverzichtbar.
Die Faustregel: Repository-Klassen und Datenzugriffsschicht-Code braucht echte Datenbank-Tests. Service-Klassen, die Repositories als Abhängigkeiten nutzen, können mit Mock-Repositories getestet werden. Domain-Logik, die keine Datenbank kennt, braucht weder echte DB noch Mock-Repository – nur Eingabewerte und Assertions auf Ausgabewerte.
2. Datenbankisolation: Das zentrale Problem
Das fundamentale Problem mit Datenbanktests ist Isolation: Jeder Test soll mit einem definierten Datenbankzustand starten und nach dem Test keinen Zustand hinterlassen, der andere Tests beeinflusst. Ohne Isolation werden Tests voneinander abhängig – der Fehler in einem Test manifestiert sich im nächsten, oder Tests bestehen nur in einer bestimmten Ausführungsreihenfolge. Das ist der häufigste Grund, warum Teams Datenbanktests aufgeben: nicht weil echte DB-Zugriffe falsch wären, sondern weil Isolation nicht sauber implementiert wurde.
Es gibt drei Hauptstrategien für Datenbankisolation: Transaktions-Rollback (schnellste Option, funktioniert für die meisten Tests), Datenbank-Truncate nach jedem Test (langsamer, aber notwendig wenn Tests selbst Transaktionen verwenden), und separate Test-Datenbank mit Reset vor der gesamten Test-Suite (für Integrationstests mit externen Abhängigkeiten). Die Wahl hängt davon ab, ob der getestete Code selbst Transaktionen verwaltet.
3. Transaktions-Rollback: Isolierung ohne Datenverlust
Der Transaktions-Rollback-Ansatz ist die effizienteste Isolationsstrategie: Vor dem Test wird eine Transaktion geöffnet, der Test fügt Daten ein und führt Operationen aus, nach dem Test wird die Transaktion zurückgerollt. Die Datenbank ist danach exakt in dem Zustand, in dem sie vor dem Test war – ohne DELETE-Operationen, ohne TRUNCATE, ohne Schema-Reset. Das ist in der Regel um Größenordnungen schneller als andere Isolationsstrategien.
Der wichtige Einschränkung: Dieser Ansatz funktioniert nicht, wenn der getestete Code selbst Transaktionen committed. Ein Command, der $entityManager->flush() aufruft und danach selbst committet, würde die äußere Test-Transaktion ebenfalls committen (bei MySQL/InnoDB) oder einen Fehler erzeugen. In diesen Fällen muss man auf Truncate-After-Test oder Savepoints umsteigen. Mit Doctrine ORM und SQLite gibt es einen weiteren Sonderfall: SQLite unterstützt keine geschachtelten Transaktionen nativ, aber SAVEPOINT als Emulation.
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Base class for database integration tests using transaction rollback isolation.
* Each test runs inside a transaction that is rolled back after the test.
*/
abstract class DatabaseTestCase extends TestCase
{
protected Connection $connection;
private bool $transactionStarted = false;
protected function setUp(): void
{
parent::setUp();
// Get the shared DB connection from the container / bootstrap
$this->connection = $this->getConnection();
// Begin transaction to isolate this test
$this->connection->beginTransaction();
$this->transactionStarted = true;
}
protected function tearDown(): void
{
// Roll back all changes — DB is back to pre-test state
if ($this->transactionStarted && $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
/**
* Returns the database connection from the test container.
*/
abstract protected function getConnection(): Connection;
}
// Concrete test using transaction rollback isolation
final class UserRepositoryTest extends DatabaseTestCase
{
private UserRepository $repository;
protected function setUp(): void
{
parent::setUp(); // starts transaction
$this->repository = new UserRepository($this->connection);
}
/** @test */
public function save_persists_user_and_assigns_id(): void
{
$user = User::create('anna@example.com', 'Anna');
$this->repository->save($user);
// User is in DB within the transaction
$this->assertNotNull($user->getId());
$this->assertGreaterThan(0, $user->getId());
}
/** @test */
public function find_by_email_returns_matching_user(): void
{
// Arrange: insert fixture data in the open transaction
$this->connection->executeStatement(
'INSERT INTO users (email, name) VALUES (?, ?)',
['test@example.com', 'Test User']
);
$found = $this->repository->findByEmail('test@example.com');
$this->assertNotNull($found);
$this->assertSame('Test User', $found->getName());
// tearDown rolls back — no cleanup needed
}
/** @test */
public function find_by_email_returns_null_for_missing_user(): void
{
$result = $this->repository->findByEmail('nobody@example.com');
$this->assertNull($result);
}
protected function getConnection(): Connection
{
return TestKernel::getContainer()->get(Connection::class);
}
}
4. Fixtures und Datenvorbereitung
Fixtures sind vordefinierte Daten, die vor einem Test in die Datenbank eingefügt werden, um einen bekannten Ausgangszustand herzustellen. In PHP gibt es mehrere Ansätze: SQL-Fixtures als direkte INSERT-Statements im Test, PHP-Fixtures über Factory-Klassen, und Doctrine-Fixtures-Bundle für komplexe Szenarien. Die einfachste und transparenteste Variante für Unit-nahe Integrationstests ist die direkte SQL-Insertion im setUp() – jeder Test sieht genau die Daten, die er braucht, keine mehr.
Factory-Pattern für Fixtures ist sauberer als direkte SQL-Inserts: Eine UserFactory::create()-Methode legt einen Test-User mit Standardwerten an und ermöglicht es, nur die relevanten Felder zu überschreiben. Das reduziert Duplizierung und macht Tests lesbarer. Mit dem league/factory-muffin-Paket oder der Symfony-Erweiterung zenstruck/foundry lassen sich Factory-Patterns systematisch für alle Entities aufbauen und in Rollback-Tests einsetzen.
<?php
declare(strict_types=1);
namespace Tests\Support;
use Doctrine\ORM\EntityManagerInterface;
use App\Domain\User;
use App\Domain\Order;
/**
* Factory helpers for creating test fixtures cleanly.
* All created entities exist only within the test's rollback transaction.
*/
final class TestFixtures
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
/**
* Creates and persists a user with sensible defaults.
*
* @param array<string, mixed> $overrides
*/
public function createUser(array $overrides = []): User
{
$user = User::create(
email: $overrides['email'] ?? 'user-' . uniqid() . '@example.com',
name: $overrides['name'] ?? 'Test User',
);
if (isset($overrides['status'])) {
$user->setStatus($overrides['status']);
}
$this->em->persist($user);
$this->em->flush();
return $user;
}
/**
* Creates and persists an order for a given user.
*
* @param array<string, mixed> $overrides
*/
public function createOrder(User $user, array $overrides = []): Order
{
$order = Order::draft($user);
foreach ($overrides['items'] ?? [] as $item) {
$order->addItem($item);
}
$this->em->persist($order);
$this->em->flush();
return $order;
}
}
// Usage in tests:
final class OrderRepositoryTest extends DatabaseTestCase
{
private TestFixtures $fixtures;
private OrderRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->fixtures = new TestFixtures($this->getEntityManager());
$this->repo = new OrderRepository($this->getEntityManager());
}
/** @test */
public function finds_orders_by_user(): void
{
$user = $this->fixtures->createUser(['email' => 'buyer@example.com']);
$order1 = $this->fixtures->createOrder($user);
$order2 = $this->fixtures->createOrder($user);
$found = $this->repo->findByUser($user);
$this->assertCount(2, $found);
$this->assertCollectionContainsIds([$order1->getId(), $order2->getId()], $found);
}
}
5. Doctrine ORM: Repository-Integrationstests
Doctrine-Repositories sind ein besonders häufiges Test-Target. Der typische Fehler: Repositories mit einem Mock-EntityManager testen. Das testet die interne Implementierung, nicht das tatsächliche Verhalten. Ein Doctrine-Repository erzeugt DQL-Abfragen, die in SQL übersetzt werden, gegen eine echte Datenbank ausgeführt werden und echte Ergebnisse zurückgeben. Keinen dieser Schritte kann ein Mock korrekt simulieren.
Für Doctrine-Integrationstests eignet sich Symfony\Bundle\FrameworkBundle\Test\KernelTestCase mit dem echten Container, der einen echten EntityManager enthält. Der EntityManager arbeitet gegen eine dedizierte Test-Datenbank, die dieselbe Schema-Version wie die Produktionsdatenbank hat. Mit dem Transaktions-Rollback-Pattern läuft jeder Test in seiner eigenen Transaktion – ohne gegenseitige Beeinflussung. Der EntityManager-Cache muss nach dem Rollback geleert werden ($em->clear()), damit nachfolgende Tests nicht gecachte Entities aus dem vorherigen Test sehen.
6. SQLite-in-Memory als Alternative
SQLite-in-Memory ist eine schnelle Alternative für Datenbanktests, wenn die Anwendung mit Doctrine und Standard-SQL arbeitet. Die gesamte Datenbank existiert nur im RAM, wird für jeden Test-Run neu aufgebaut und ist damit automatisch isoliert. Die Einrichtung ist einfach: In der Test-Konfiguration die Datenbankverbindung auf pdo_sqlite und den Pfad auf :memory: setzen, das Schema mit Doctrine-Schema-Tool beim Teststart aufbauen.
Der entscheidende Nachteil: SQLite unterstützt nicht alle Features von MySQL oder PostgreSQL. Stored Procedures, bestimmte Datentypen (JSON-Operationen, ENUM, FULLTEXT), datenbankspezifische Funktionen und Locking-Verhalten unterscheiden sich. Tests, die SQLite-in-Memory nutzen, können MySQL-spezifische Bugs nicht finden. Die Empfehlung: SQLite-in-Memory für Geschwindigkeit und Einfachheit in Entwicklung, echte MySQL/PostgreSQL-Datenbank in CI für Integrationstests.
| Strategie | Isolation | Geschwindigkeit | Einschränkung |
|---|---|---|---|
| Transaktions-Rollback | Sehr gut | Sehr schnell | Nicht wenn Test selbst committet |
| Truncate nach Test | Gut | Langsamer | Foreign-Key-Reihenfolge beachten |
| SQLite-in-Memory | Perfekt (neuer DB) | Sehr schnell | Keine MySQL-spezifischen Features |
| Separate Test-DB | Gut | Mittel | Setup-Aufwand, Schema-Sync |
| Mock-Repository | Perfekt (kein DB) | Schnellste Option | Testet keine DB-Logik |
7. Typische Fehler bei Datenbanktests
Der häufigste Fehler: Tests, die voneinander abhängen, weil Datenbankzustand nicht isoliert wird. Test A legt einen User an, Test B findet diesen User und erwartet ihn. Test A läuft nicht: Test B schlägt fehl. Oder umgekehrt: Test A erwartet, dass kein User existiert, Test B hat aber einen angelegt. Diese Abhängigkeit manifestiert sich oft erst, wenn die Ausführungsreihenfolge durch PHPUnits Prozessaltelierung verändert wird.
Ein zweiter häufiger Fehler: Der EntityManager-Cache wird zwischen Tests nicht geleert. Doctrine cached Entities in seinem Identity-Map. Wenn Test A eine Entity lädt, modifiziert und dann der Rollback stattfindet, aber $em->clear() nicht aufgerufen wird, kann Test B eine gecachte Version der Entity aus Test A sehen – aus dem Speicher, nicht aus der Datenbank. $em->clear() muss nach dem Rollback aufgerufen werden. Ein dritter Fehler: SQLite-in-Memory für Tests zu nutzen und dann über MySQL-Bugs zu wundern, die in der Produktion auftreten, aber in den Tests nicht sichtbar waren.
<?php
declare(strict_types=1);
namespace Tests\Integration;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Base class for Doctrine repository tests.
* Uses transaction rollback for fast, reliable isolation.
*/
abstract class DoctrineRepositoryTestCase extends KernelTestCase
{
protected EntityManagerInterface $em;
protected function setUp(): void
{
parent::setUp();
self::bootKernel(['environment' => 'test']);
$this->em = self::getContainer()->get(EntityManagerInterface::class);
// Begin wrapping transaction for isolation
$this->em->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
// Roll back all test data
$this->em->getConnection()->rollBack();
// CRITICAL: clear identity map so next test doesn't see stale cached entities
$this->em->clear();
parent::tearDown();
}
/**
* Flushes pending changes to DB (within rollback transaction).
* Call this after persist() to make entities queryable in the test.
*/
protected function flushAndClear(): void
{
$this->em->flush();
$this->em->clear(); // Clear cache so next find goes to DB, not memory
}
}
// Example repository test
final class ProductRepositoryTest extends DoctrineRepositoryTestCase
{
private ProductRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = self::getContainer()->get(ProductRepository::class);
}
/** @test */
public function finds_active_products_ordered_by_name(): void
{
// Arrange: create test data inside the rollback transaction
$this->em->persist(Product::create('Zebra Widget', status: 'active'));
$this->em->persist(Product::create('Alpha Gadget', status: 'active'));
$this->em->persist(Product::create('Hidden Item', status: 'inactive'));
$this->flushAndClear(); // flush so queries hit DB, clear cache
$products = $this->repo->findAllActive();
$this->assertCount(2, $products);
$this->assertSame('Alpha Gadget', $products[0]->getName());
$this->assertSame('Zebra Widget', $products[1]->getName());
// tearDown rolls back — no cleanup needed
}
}
8. Teststrategien im Vergleich
Die Wahl der Teststrategie für datenbankzugreifenden Code hängt von der Frage ab, was getestet werden soll. Für Business-Logik in Service-Klassen ist ein Mock-Repository die richtige Wahl – schnell, isoliert, fokussiert. Für Datenzugriffslogik in Repository-Klassen ist echter DB-Zugriff mit Transaktions-Rollback die richtige Wahl. Für komplexe Szenarien mit mehreren zusammenwirkenden Repositories ist ein Integrationstest mit echtem Container und Rollback das richtige Werkzeug.
Die wichtigste Erkenntnis: Die Entscheidung ist keine Entweder-oder-Frage. Eine vollständige Test-Suite für eine Anwendung mit Datenbankzugriff enthält alle drei Ebenen. Mock-Repositories in Service-Tests für Geschwindigkeit. Echte DB in Repository-Tests für Korrektheit. Integrationstests für das Zusammenspiel. Und immer: klare Isolation, damit Tests unabhängig und reproduzierbar bleiben.
9. Zusammenfassung
Datenbanktests mit PHPUnit sind dann sinnvoll, wenn SQL-Korrektheit, ORM-Mapping, Constraint-Verhalten oder Query-Ergebnisse geprüft werden müssen. Der Transaktions-Rollback-Ansatz ist die effizienteste Isolationsstrategie. Doctrine-RepositoryTests mit echtem EntityManager und Rollback finden Bugs, die kein Mock finden kann. SQLite-in-Memory ist schnell, aber kann keine MySQL-spezifischen Bugs aufdecken. Der EntityManager-Cache muss nach dem Rollback geleert werden. Fixtures über Factory-Pattern halten Tests lesbar und wartbar.
Echter DB-Zugriff in Tests ist kein Luxus – er ist die einzige Art, die Datenzugriffsschicht wirklich zu testen. Die Frage ist nicht ob, sondern wo und wie.
PHPUnit Datenbanktests — Das Wichtigste auf einen Blick
Wann echter DB-Zugriff
Repository-Klassen, SQL-Korrektheit, ORM-Mapping, Constraints. Service-Klassen mit Mock-Repositories testen.
Transaktions-Rollback
Vor Test Transaktion öffnen, nach Test rollbacken. Schnellste Isolation. Nicht geeignet wenn Test selbst committet.
EntityManager-Cache leeren
$em->clear() nach Rollback und nach flush()+clear()-Zyklen. Verhindert Sehen gecachter Entities aus vorherigen Tests.
Factory statt SQL-Fixtures
Factory-Klassen für Test-Entities: weniger Duplizierung, lesbarere Tests. Fixtures laufen in der Test-Transaktion.
Mironsoft
PHP-Entwicklung, Doctrine ORM und Datenbanktest-Infrastruktur
Repository-Tests, die echte DB-Bugs finden?
Wir richten Datenbanktest-Infrastruktur mit Transaktions-Rollback, Factory-Fixtures und CI-Integration ein – damit Repository-Tests zuverlässig, schnell und isoliert laufen.
Test-Infrastruktur
Rollback-Basisklassen, EntityManager-Reset und Factory-Pattern für alle Entities
Repository-Tests
Integrationstests für alle Doctrine-Repositories mit echten SQL-Abfragen
CI-Setup
Test-Datenbank in CI-Pipeline, Schema-Migration und parallele Test-Ausführung