schnelle Integrationstests ohne Datenbankserver
Doctrine Repositories mit reinen Mocks zu testen prüft selten die eigentliche Query. Eine In-Memory-SQLite-Datenbank führt DQL und Query-Builder-Aufrufe echt aus, ganz ohne Datenbankserver, und macht Repository-Tests in Millisekunden statt Sekunden lauffähig.
Inhaltsverzeichnis
- 1. Warum SQLite im Speicher für Repository-Tests
- 2. Test-Umgebung: eigene Doctrine-Connection konfigurieren
- 3. Schema pro Testlauf mit dem SchemaTool aufbauen
- 4. Testdaten mit Fixtures und Foundry erzeugen
- 5. Repository-Methoden gegen echte Queries testen
- 6. Isolation zwischen Tests: Transaktionen und Rollback
- 7. Grenzen von SQLite gegenüber MySQL
- 8. Typische Fehler bei SQLite-Testkonfigurationen
- 9. SQLite im Speicher im Vergleich zu Alternativen
- 10. Zusammenfassung
- 11. FAQ
1. Warum SQLite im Speicher für Repository-Tests
Ein Doctrine Repository Test mit gemocktem EntityManager prüft meist nur, ob eine Methode aufgerufen wurde, nicht ob die zugrunde liegende DQL-Query oder der Query-Builder-Aufruf tatsächlich das richtige Ergebnis liefert. Genau diese Lücke schließt eine In-Memory-SQLite-Datenbank: Die Query wird real gegen ein echtes, wenn auch minimales, relationales Schema ausgeführt, und ein fehlerhafter Join oder eine falsche Where-Bedingung fällt sofort auf, statt unbemerkt in Produktion zu landen.
Der zweite große Vorteil ist Geschwindigkeit. Eine SQLite-Datenbank mit der DSN sqlite:///:memory: existiert nur im Arbeitsspeicher des Testprozesses, es gibt keinen TCP-Handshake, keinen Verbindungsaufbau zu einem externen Server und keine Festplatten-I/O. Wo ein Repository-Test gegen MySQL mehrere hundert Millisekunden für Verbindungsaufbau und Transaktions-Overhead benötigt, läuft derselbe Test gegen SQLite im Speicher oft in unter zehn Millisekunden.
Dieser Artikel zeigt, wie man eine separate Doctrine-Connection für Tests konfiguriert, wie das Schema pro Testlauf aufgebaut wird, wie Fixtures Testdaten erzeugen und wo die Grenzen dieser Strategie liegen, wenn produktionsnahe SQL-Funktionen gebraucht werden.
2. Test-Umgebung: eigene Doctrine-Connection konfigurieren
Der erste Schritt ist eine eigene doctrine.yaml-Konfiguration für die test-Umgebung, die den pdo_sqlite-Treiber und die spezielle DSN sqlite:///:memory: verwendet. Diese Konfiguration überschreibt die produktionsnahe MySQL- oder PostgreSQL-Verbindung ausschließlich in config/packages/test/doctrine.yaml, sodass Entwicklungs- und Produktionsumgebung unverändert bleiben.
Wichtig ist, dass Symfony bei jedem neuen Testprozess auch tatsächlich eine neue Verbindung öffnet. Da :memory:-Datenbanken an die Lebensdauer der Verbindung gebunden sind, verschwindet das Schema, sobald die Verbindung geschlossen wird. Für parallele Testausführung mit paratest bedeutet das: Jeder Worker-Prozess bekommt automatisch seine eigene, isolierte SQLite-im-Speicher-Instanz, ohne dass Tests sich gegenseitig Daten überschreiben.
# config/packages/test/doctrine.yaml
doctrine:
dbal:
driver: 'pdo_sqlite'
url: 'sqlite:///:memory:'
charset: UTF8
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
3. Schema pro Testlauf mit dem SchemaTool aufbauen
Eine In-Memory-SQLite-Datenbank startet immer leer, es gibt keine Migrationen, die ausgeführt werden könnten, weil Migrationen typischerweise auf spezifische SQL-Dialekte wie MySQL zugeschnitten sind. Stattdessen nutzt man Doctrines SchemaTool, das direkt aus den Entity-Mappings ein SQL-Schema generiert und in der SQLite-Datenbank anlegt. Das umgeht Migrationsdateien vollständig und funktioniert auch dann, wenn Migrationen SQL-Dialekt-spezifische Anweisungen enthalten, die SQLite nicht versteht.
Der SchemaTool-Aufbau gehört in eine wiederverwendbare Test-Basisklasse, die alle Repository-Tests erben. In der setUp()-Methode wird das Schema für alle registrierten Entitäten erzeugt, in tearDown() wieder verworfen. So startet jeder Test mit einem garantiert leeren, aber strukturell korrekten Schema, ohne Reste aus vorherigen Testläufen.
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
abstract class SqliteRepositoryTestCase extends KernelTestCase
{
protected EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::bootKernel();
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->entityManager = $entityManager;
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($this->entityManager);
$schemaTool->dropSchema($metadata);
$schemaTool->createSchema($metadata);
}
protected function tearDown(): void
{
$this->entityManager->close();
parent::tearDown();
}
}
4. Testdaten mit Fixtures und Foundry erzeugen
Nach dem Schema-Aufbau braucht ein Doctrine Repository Test konkrete Testdaten. Die zambia Bibliothek zenstruck/foundry hat sich hierfür etabliert, weil sie Entity-Factories mit sinnvollen Standardwerten bereitstellt und nur die für den jeweiligen Test relevanten Felder explizit überschreibt. Statt jedes Pflichtfeld einer Entität manuell zu setzen, erzeugt eine ProductFactory::createOne(['stock' => 0]) ein vollständiges, valides Product mit genau einem abweichenden Feld.
Wichtig ist, dass Fixtures innerhalb der SQLite-im-Speicher-Datenbank über den EntityManager der Testklasse persistiert werden, nicht über eine separate Verbindung. Da :memory:-Datenbanken pro Verbindung existieren, würde eine zweite Verbindung ein komplett leeres, unabhängiges Schema sehen, selbst wenn zuvor Daten über die erste Verbindung geschrieben wurden. Dieser Fallstrick betrifft besonders Konsolen-Kommandos, die Fixtures separat laden wollen.
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Repository;
use App\Tests\Factory\ProductFactory;
final class ProductRepositoryTest extends SqliteRepositoryTestCase
{
public function testFindOutOfStockProducts(): void
{
ProductFactory::createOne(['name' => 'In stock item', 'stock' => 10]);
ProductFactory::createOne(['name' => 'Out of stock item', 'stock' => 0]);
ProductFactory::createOne(['name' => 'Also out of stock', 'stock' => 0]);
$repository = $this->entityManager->getRepository(\App\Entity\Product::class);
$result = $repository->findOutOfStockProducts();
self::assertCount(2, $result);
}
}
5. Repository-Methoden gegen echte Queries testen
Der eigentliche Wert eines Doctrine Repository Tests mit SQLite zeigt sich bei komplexen Query-Builder-Konstrukten mit mehreren Joins, Unterabfragen oder aggregierenden Funktionen. Ein Test, der nur prüft, dass $repository->findAll() aufgerufen wurde, sagt nichts über die Korrektheit einer selbstgeschriebenen Methode wie findTopSellingProductsInCategory() aus. Gegen eine echte, wenn auch kleine, Datenbank ausgeführt, deckt derselbe Test sofort auf, wenn ein Join die falsche Richtung hat oder eine Aggregation die falschen Zeilen zusammenfasst.
Ein bewährtes Muster: Für jede nicht-triviale Repository-Methode existiert mindestens ein Doctrine Repository Test, der sowohl den positiven Fall, in dem Daten gefunden werden, als auch den negativen Fall, in dem die Query bewusst leer bleiben soll, abdeckt. Beide Fälle zusammen fangen die meisten Query-Builder-Fehler ab, die reine Unit-Tests mit Mocks systematisch übersehen.
6. Isolation zwischen Tests: Transaktionen und Rollback
Auch innerhalb einer einzigen SQLite-im-Speicher-Datenbank müssen Tests voneinander isoliert bleiben, damit ein Test die Daten eines anderen nicht sieht. Die im vorherigen Abschnitt gezeigte Strategie mit dropSchema und createSchema in jedem setUp() ist die robusteste Variante, hat aber einen Geschwindigkeitsnachteil, wenn viele Entitäten registriert sind, weil das komplette Schema pro Test neu erzeugt wird.
Eine schnellere Alternative ist, das Schema einmalig pro Testklasse aufzubauen und jeden einzelnen Test in eine Datenbanktransaktion zu verpacken, die am Ende immer zurückgerollt wird, unabhängig davon, ob der Test erfolgreich war. Dieses Muster, oft als DAMADoctrineTestBundle bekannt, hält die Datenbank zwischen Tests unverändert, während einzelne Testfälle trotzdem vollständig isoliert bleiben, weil kein Commit jemals stattfindet.
<?php
declare(strict_types=1);
// config/packages/test/dama_doctrine_test_bundle.yaml equivalent behaviour,
// manually implemented for clarity:
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->entityManager->getConnection()->beginTransaction();
}
protected function tearDown(): void
{
$this->entityManager->getConnection()->rollBack();
$this->entityManager->close();
parent::tearDown();
}
7. Grenzen von SQLite gegenüber MySQL
SQLite ist nicht produktionsidentisch mit MySQL oder PostgreSQL, und ein Doctrine Repository Test gegen SQLite prüft deshalb nicht jede in Produktion relevante Eigenart. Full-Text-Search-Funktionen, JSON-Spaltenoperatoren, bestimmte Datums- und Zeitfunktionen sowie das Case-Sensitivity-Verhalten von String-Vergleichen unterscheiden sich zwischen den Datenbanksystemen teils erheblich. Ein Test, der in SQLite grün ist, kann in MySQL trotzdem fehlschlagen, wenn die Query auf einem MySQL-spezifischen Feature basiert, das doctrine/dbal nicht vollständig abstrahiert.
Die pragmatische Empfehlung: SQLite im Speicher für die breite Masse der Repository-Tests nutzen, bei denen es um allgemeine Query-Logik, Joins und einfache Aggregationen geht. Für Repository-Methoden, die explizit datenbankspezifische Funktionen wie MATCH AGAINST in MySQL oder JSON_EXTRACT nutzen, einen separaten, kleineren Satz an Integrationstests gegen eine echte MySQL-Instanz in Docker vorhalten, statt die Erkenntnisse aus SQLite blind zu übertragen.
8. Typische Fehler bei SQLite-Testkonfigurationen
Der häufigste Fehler ist, die produktive doctrine.yaml versehentlich auch in der Testumgebung zu verwenden, wodurch Tests gegen eine echte MySQL-Datenbank laufen, ohne dass das beabsichtigt war. Ein zweiter häufiger Fehler betrifft Foreign-Key-Constraints: SQLite erzwingt Fremdschlüsselbeziehungen standardmäßig nicht, sofern nicht explizit PRAGMA foreign_keys = ON gesetzt wird, wodurch ein Test fälschlich grün bleibt, obwohl er in MySQL an einer Constraint-Verletzung scheitern würde.
<?php
// WRONG: no explicit test doctrine.yaml — falls back to production connection
// config/packages/doctrine.yaml only, nothing under config/packages/test/
// RIGHT: explicit override, foreign keys enforced for realistic constraint checks
// config/packages/test/doctrine.yaml
// doctrine:
// dbal:
// driver: 'pdo_sqlite'
// url: 'sqlite:///:memory:'
// options:
// 1002: 'PRAGMA foreign_keys = ON;' // PDO::MYSQL_ATTR_INIT_COMMAND equivalent for SQLite
Ein dritter Fehler betrifft parallele Testausführung: Wird versehentlich eine SQLite-Datei statt :memory: verwendet, etwa sqlite:///%kernel.project_dir%/var/test.db, teilen sich alle parallelen Testprozesse dieselbe Datei und blockieren sich gegenseitig durch Schreibsperren. SQLite im Speicher vermeidet dieses Problem grundsätzlich, weil jede Verbindung ihre eigene, unsichtbare Instanz bekommt.
9. SQLite im Speicher im Vergleich zu Alternativen
Neben SQLite im Speicher gibt es weitere verbreitete Strategien für Repository-Tests. Die folgende Tabelle vergleicht sie nach Geschwindigkeit, Realitätsnähe und Setup-Aufwand.
| Strategie | Geschwindigkeit | Produktionsnähe | Setup-Aufwand |
|---|---|---|---|
| EntityManager Mock | Sehr hoch | Sehr niedrig | Niedrig, aber testet keine echte Query |
| SQLite im Speicher | Sehr hoch | Mittel | SchemaTool statt Migrationen |
| MySQL in Docker | Mittel | Sehr hoch | Container, Migrationen, Netzwerk-Setup |
| Geteilte Test-Datenbank | Niedrig | Hoch | Parallelisierung schwierig, Datenkollisionen |
Die pragmatische Kombination für die meisten Symfony Projekte: SQLite im Speicher für die breite Masse der schnellen Repository-Tests in der lokalen Entwicklung und in CI, ergänzt um eine kleinere Suite echter MySQL-Integrationstests für datenbankspezifische Funktionen, die vor jedem Release laufen.
Mironsoft
Doctrine Testing, Symfony Architektur und CI-Pipelines
Repository-Tests, die echte Queries prüfen und trotzdem schnell laufen?
Wir richten SQLite-basierte Testkonfigurationen ein, ergänzen gezielte MySQL-Integrationstests für datenbankspezifische Funktionen und bringen Testlaufzeiten in bestehenden Symfony Projekten deutlich herunter.
Test-Setup
SQLite-Testkonfiguration, SchemaTool-Integration und Foundry-Factories
Repository-Audit
Ungetestete Query-Builder-Methoden identifizieren und absichern
CI-Beschleunigung
Parallele Testausführung mit isolierten In-Memory-Datenbanken
10. Zusammenfassung
Doctrine Repository Tests mit SQLite im Speicher lösen ein zentrales Problem: Query-Builder-Methoden und DQL-Aufrufe werden gegen ein echtes relationales Schema geprüft, ohne den Overhead eines externen Datenbankservers. Die Konfiguration in config/packages/test/doctrine.yaml mit der DSN sqlite:///:memory: ist in wenigen Zeilen erledigt, das Schema kommt aus Doctrines SchemaTool statt aus Migrationen, und Foundry-Factories erzeugen realistische Testdaten mit minimalem Boilerplate.
Wichtig bleibt die klare Abgrenzung: SQLite im Speicher ist hervorragend für allgemeine Query-Logik, Joins und Aggregationen geeignet, aber kein vollständiger Ersatz für MySQL-spezifische Features. Wer diese Grenze kennt und für datenbankspezifische Funktionen eine kleinere, gezielte MySQL-Suite ergänzt, bekommt eine Testpyramide, die sowohl schnell als auch verlässlich ist.
Doctrine Repository Tests mit SQLite im Speicher: Das Wichtigste auf einen Blick
DSN-Konfiguration
sqlite:///:memory: nur unter config/packages/test/doctrine.yaml, Produktionsumgebung bleibt unangetastet.
Schema statt Migration
Doctrines SchemaTool generiert das Schema direkt aus den Entity-Mappings, keine Migrationsdateien nötig.
Isolation
Transaktion pro Test mit Rollback am Ende, oder Schema-Neuaufbau in setUp() für vollständige Isolation.
Grenzen kennen
Datenbankspezifische Funktionen wie Full-Text-Search separat gegen echtes MySQL testen.