Doctrine Repository Tests mit SQLite im Speicher: Schnelle Integrationstests in Symfony
AI generated
SF
{ }
Symfony · Doctrine · SQLite · Testing
Doctrine Repository Tests mit SQLite im Speicher
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.

19 Min. Lesezeit SQLite :memory: · SchemaTool · Doctrine ORM 3 Symfony 7 · PHPUnit 11

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.

11. FAQ: Doctrine Repository Tests mit SQLite im Speicher

1Warum SQLite statt echtem MySQL?
Kein Verbindungsaufbau zu einem externen Server nötig, dadurch Tests oft unter zehn Millisekunden statt mehreren hundert.
2Wie konfiguriere ich nur die Testumgebung?
config/packages/test/doctrine.yaml mit pdo_sqlite und sqlite:///:memory: anlegen, überschreibt nur die test-Umgebung.
3Woher kommt das Schema ohne Migrationen?
Doctrines SchemaTool generiert es direkt aus den Entity-Mappings, unabhängig von SQL-Dialekt-spezifischen Migrationsdateien.
4Wie erzeuge ich Testdaten?
Mit zenstruck/foundry als Entity-Factory-Bibliothek für sinnvolle Standardwerte mit minimalem Boilerplate.
5Wie bleiben Tests isoliert?
Schema-Neuaufbau in setUp() oder Transaktion pro Test mit Rollback am Ende.
6Was kann SQLite gegenueber MySQL nicht?
Full-Text-Search, bestimmte JSON-Operatoren und Datumsfunktionen unterscheiden sich teils erheblich.
7Werden Fremdschluessel durchgesetzt?
Nur mit PRAGMA foreign_keys = ON explizit gesetzt, sonst nicht standardmäßig.
8Funktioniert das mit Parallelisierung?
Ja, jede Verbindung zu :memory: ist isoliert, paratest-Worker blockieren sich nicht gegenseitig.
9Haeufigster Konfigurationsfehler?
Fehlende config/packages/test/doctrine.yaml, wodurch Tests unbemerkt gegen die echte Datenbank laufen.
10Komplett auf MySQL-Tests verzichten?
Nein, eine kleinere gezielte Suite gegen echtes MySQL für datenbankspezifische Funktionen ergänzend vorhalten.