Auswahl einer Teststrategie

Wie bereits in der Übersicht beschrieben, müssen Sie eine grundlegende Entscheidung treffen, ob Ihre Tests das Produktionsdatenbanksystem einbeziehen (so wie Ihre Anwendung) oder ob die Tests mit einem Testdouble ausgeführt werden, wodurch Ihr Produktionsdatenbanksystem ersetzt wird.

Tests mit einer echten externen Ressource – anstatt sie durch einen Testdouble zu ersetzen – können die folgenden Schwierigkeiten umfassen:

  1. In vielen Fällen ist es einfach nicht möglich oder praktisch, mit der tatsächlichen externen Ressource zu testen. Ihre Anwendung kann z. B. mit einem Dienst interagieren, auf den nicht einfach getestet werden kann (aufgrund von Ratenbeschränkungen oder fehlender Testumgebung).
  2. Selbst wenn es möglich ist, die tatsächliche externe Ressource einzubeziehen, kann dies äußerst langsam sein: Die Durchführung einer großen Anzahl von Tests gegen einen Cloud-Service kann dazu führen, dass die Tests zu lange dauern. Tests sollten Teil des täglichen Workflows des Entwicklers sein, daher ist es wichtig, dass Tests schnell ausgeführt werden.
  3. Die Ausführung von Tests für eine externe Ressource kann zu Isolationsproblemen führen, bei denen sich die Tests gegenseitig beeinträchtigen. Beispielsweise können mehrere Paralleltests für eine Datenbank Daten ändern und dazu führen, dass sie auf unterschiedliche Weise fehlschlagen. Durch die Verwendung eines Tests wird dies vermieden, da jeder Test an seiner eigenen Speicherressource ausgeführt wird und daher natürlich von anderen Tests isoliert ist.

Tests, die mit einem Testdouble erfolgreich sind, garantieren jedoch nicht, dass Ihr Programm auch mit der echten externen Ressource funktioniert. Ein Testdouble der Datenbank kann beispielsweise String-Vergleiche unter Berücksichtigung der Groß- und Kleinschreibung durchführen, während das Produktionsdatenbanksystem Vergleiche ohne Berücksichtigung der Groß- und Kleinschreibung vornimmt. Solche Probleme werden nur aufgedeckt, wenn die Tests gegen Ihre echte Produktionsdatenbank ausgeführt werden. Daher sind diese Tests ein wichtiger Bestandteil jeder Teststrategie.

Das Testen der Datenbank kann einfacher sein, als es scheint.

Aufgrund der oben genannten Schwierigkeiten beim Testen einer echten Datenbank werden Entwickler häufig dazu angehalten, zuerst Testdoubles zu verwenden und eine robuste Testsuite zu haben, die sie häufig auf ihren Rechnern ausführen können. Tests, die die Datenbank einbeziehen, sollen dagegen viel seltener ausgeführt werden und bieten in vielen Fällen auch eine viel geringere Abdeckung. Wir empfehlen, über Letzteres nachzudenken, und schlagen vor, dass Datenbanken von den oben genannten Problemen weit weniger betroffen sein könnten, als die meisten Menschen glauben:

  1. Die meisten Datenbanken können heutzutage problemlos auf dem Computer des Entwicklers installiert werden. Containerbasierte Technologien wie Docker können dies sehr einfach machen, und Technologien wie Github Workspaces und Dev Container richten Ihre gesamte Entwicklungsumgebung für Sie ein (einschließlich der Datenbanken). Bei Verwendung von SQL Server ist es auch möglich, LocalDB unter Windows zu testen oder ein Docker-Image unter Linux einfach einzurichten.
  2. Das Testen einer lokalen Datenbank mit einem angemessenen Testdatensatz ist in der Regel extrem schnell: Die Kommunikation ist vollständig lokal, und Testdaten werden in der Regel im Arbeitsspeicher auf der Datenbankseite gepuffert. EF Core selbst enthält über 30.000 Tests allein gegen SQL Server, die zuverlässig in wenigen Minuten abgeschlossen sind, bei jedem einzelnen Commit in CI ausgeführt werden und sehr häufig von Entwicklern lokal ausgeführt werden. Einige Entwickler verwenden eine In-Memory-Datenbank (ein „Fake“) in dem Glauben, dass dies für die Geschwindigkeit erforderlich ist, was ist in Wirklichkeit fast nie der Fall ist.
  3. Die Isolierung ist in der Tat eine Hürde, wenn Tests gegen eine echte Datenbank laufen, da die Tests Daten verändern und sich gegenseitig stören können. Es gibt jedoch verschiedene Techniken zur Isolierung in Datenbanktestszenarien; wir konzentrieren uns auf diese in Testen Ihres Produktionsdatenbanksystems.

Die obigen Ausführungen sind nicht dazu gedacht, Testdoubles zu verunglimpfen oder gegen ihre Verwendung zu argumentieren. Nicht zuletzt sind Testdoubles für einige Szenarien erforderlich, die sonst nicht getestet werden können, z. B. das Simulieren von Datenbankfehlern. Unserer Erfahrung nach schrecken Benutzer jedoch aus den oben genannten Gründen häufig davor zurück, mit ihrer Datenbank zu testen, weil sie glauben, es sei langsam, schwierig oder unzuverlässig, obwohl das nicht unbedingt der Fall ist. Das Testen Ihres Produktionsdatenbanksystems zielt darauf ab, dieses Problem zu lösen, indem es Richtlinien und Beispiele für das Schreiben schneller, isolierter Tests Ihrer Datenbank bereitstellt.

Verschiedene Typen von Testdoubles

Testdoubles ist ein breiter Begriff, der sehr unterschiedliche Ansätze umfasst. In diesem Abschnitt werden einige gängige Verfahren behandelt, die Testdoubles für das Testen von EF Core-Anwendungen umfassen:

  1. Verwenden Sie SQLite (In-Memory-Modus) als Datenbank-Fake und ersetzen Sie damit Ihr Produktionsdatenbanksystem.
  2. Verwenden Sie den EF Core In-Memory-Provider als Datenbank-Fake und ersetzen Sie damit Ihr Produktionsdatenbanksystem.
  3. Simulieren oder erzeugen Sie DbContext und DbSet.
  4. Stellen Sie eine Repositoryebene zwischen EF Core und Ihrem Anwendungscode vor, und modellieren oder erzeugen Sie diese Ebene.

Im Folgenden untersuchen wir, was jede Methode bedeutet, und vergleichen sie miteinander. Es wird empfohlen, die verschiedenen Methoden durchzulesen, um ein vollständiges Verständnis zu erhalten. Wenn Sie sich entschieden haben, Tests zu schreiben, die Ihr Produktionsdatenbanksystem nicht einbeziehen, dann ist eine Repository-Schicht der einzige Ansatz, der ein umfassendes und zuverlässiges Stubbing/Mocking der Datenschicht ermöglicht. Dieser Ansatz ist jedoch mit erheblichen Kosten in Bezug auf Implementierung und Wartung verbunden.

SQLite als Datenbank-Fake

Ein möglicher Testansatz besteht darin, Ihre Produktionsdatenbank (z. B. SQL Server) mit SQLite auszutauschen und effektiv als „Fake“ zu testen. Abgesehen von der einfachen Einrichtung verfügt SQLite über ein In-Memory-Datenbank-Feature, das besonders zum Testen nützlich ist: Jeder Test ist natürlich in einer eigenen In-Memory-Datenbank isoliert, und es müssen keine tatsächlichen Dateien verwaltet werden.

Bevor Sie dies tun, sollten Sie jedoch wissen, dass sich die verschiedenen Datenbankanbieter in EF Core unterschiedlich verhalten – EF Core versucht nicht, jeden Aspekt des zugrunde liegenden Datenbanksystems zu abstrahieren. Grundsätzlich bedeutet dies, dass das Testen mit SQLite nicht die gleichen Ergebnisse wie bei SQL Server oder einer anderen Datenbank garantiert. Hier sind einige Beispiele für mögliche Verhaltensunterschiede:

  • Die gleiche LINQ-Abfrage gibt möglicherweise unterschiedliche Ergebnisse für unterschiedliche Anbieter zurück. SQL Server beispielsweise unterscheidet standardmäßig nicht zwischen Groß- und Kleinschreibung beim String-Vergleich, während SQLite die Groß- und Kleinschreibung beachtet. Dies kann dazu führen, dass Ihre Tests mit SQLite erfolgreich sind, während sie mit SQL Server fehlschlagen würden (oder umgekehrt).
  • Einige Abfragen, die auf SQL Server funktionieren, werden auf SQLite einfach nicht unterstützt, da die genaue SQL-Unterstützung in diesen beiden Datenbanken unterschiedlich ist.
  • Wenn Ihre Abfrage eine anbieterspezifische Methode wie die EF.Functions.DateDiffDayvon SQL Server verwendet, schlägt diese Abfrage bei SQLite fehl und kann nicht getestet werden.
  • Unformatiertes SQL kann funktionieren, aber auch fehlschlagen oder andere Ergebnisse liefern, je nachdem, was genau gemacht wird. Die SQL-Dialekte unterscheiden sich in vielerlei Hinsicht von Datenbank zu Datenbank.

Verglichen mit der Durchführung von Tests Ihres Produktionsdatenbanksystems ist es relativ einfach, mit SQLite zu beginnen, und so tun es viele Benutzer. Leider neigen die oben genannten Einschränkungen dazu, beim Testen von EF Core-Anwendungen irgendwann problematisch zu werden, auch wenn es zu Beginn nicht so aussieht. Daher empfehlen wir Ihnen, Ihre Tests entweder gegen Ihre echte Datenbank zu schreiben oder, wenn die Verwendung eines Testdoppels eine absolute Notwendigkeit ist, die Kosten für ein Repository-Muster, wie unten beschrieben, in Kauf zu nehmen.

Informationen zur Verwendung von SQLite für Tests finden Sie in diesem Abschnitt.

In-Memory als Datenbank-Fake

Als Alternative zu SQLite hat EF Core auch einen In-Memory-Anbieter. Obwohl dieser Anbieter ursprünglich zur Unterstützung der internen Tests von EF Core selbst entwickelt wurde, verwenden ihn einige Entwickler als Datenbank-Fake beim Testen von EF Core-Anwendungen. Davon ist dringend abzuraten: In-Memory hat als Datenbank-Fake die gleichen Probleme wie SQLite (siehe oben), hat aber darüber hinaus die folgenden zusätzlichen Einschränkungen:

  • Der In-Memory-Anbieter unterstützt im Allgemeinen weniger Abfragetypen als der SQLite-Anbieter, da es sich nicht um eine relationale Datenbank handelt. Mehr Abfragen schlagen fehl oder verhalten sich im Vergleich zu Ihrer Produktionsdatenbank anders.
  • Transaktionen werden nicht unterstützt.
  • Unformatiertes SQL wird überhaupt nicht unterstützt. Vergleichen Sie dies mit SQLite, wo es möglich ist, unformatiertes SQL zu verwenden, solange dieses SQL auf SQLite und Ihrer Produktionsdatenbank auf die gleiche Weise funktioniert.
  • Der In-Memory-Anbieter wurde nicht für die Leistung optimiert und funktioniert im Allgemeinen langsamer als SQLite im In-Memory-Modus (oder sogar im Produktionsdatenbanksystem).

Zusammenfassend lässt sich sagen, dass In-Memory alle Nachteile von SQLite und noch ein paar mehr hat – und im Gegenzug keine Vorteile bietet. Wenn Sie ein einfaches In-Memory-Datenbank-Fake suchen, verwenden Sie SQLite anstelle des In-Memory-Providers; ziehen Sie aber in Betracht, stattdessen das Repository-Muster zu verwenden, wie unten beschrieben.

Informationen zur Verwendung von In-Memory für Tests finden Sie in diesem Abschnitt.

Mocking oder Stubbing von dbContext und DbSet

Bei diesem Ansatz wird in der Regel ein Pseudoframework verwendet, um ein Test-Double von DbContext und DbSet zu erstellen und Tests mit diesen Doubles durchzuführen. Das Modellieren von DbContext kann ein guter Ansatz zum Testen verschiedener Nicht-Abfrage-Funktionalitäten sein, z. B. Aufrufe von Add oder SaveChanges(), sodass Sie überprüfen können, ob Ihr Code in Schreibszenarien aufgerufen wurde.

Das ordnungsgemäße Modellieren der DbSetAbfrage-Funktionalität ist jedoch nicht möglich, da Abfragen über LINQ-Operatoren ausgedrückt werden, die statische Erweiterungsmethodeaufrufe über IQueryable sind. Wenn einige Leute von „DbSet-Mocking“ sprechen, meinen sie damit eigentlich, dass sie ein DbSet mit einer In-Memory-Sammlung erstellen und dann Abfrageoperatoren gegen diese Sammlung im Speicher auswerten, genau wie eine einfache IEnumerable. Dabei handelt es sich nicht um ein Mocking, sondern um eine Art Fake, bei dem die In-Memory-Sammlung die echte Datenbank ersetzt.

Da nur das DbSet selbst ein Fake ist und die Abfrage im Arbeitsspeicher ausgewertet wird, ist dieser Ansatz sehr ähnlich wie der EF Core-In-Memory-Anbieter: Beide Techniken führen Abfrageoperatoren in .NET über eine Speicherauflistung aus. Infolgedessen leidet auch diese Technik unter denselben Nachteilen: Abfragen verhalten sich anders (z. B. in Bezug auf die Groß- und Kleinschreibung) oder schlagen einfach fehl (z. B. aufgrund von providerspezifischen Methoden), unformatiertes SQL funktioniert nicht und Transaktionen werden bestenfalls ignoriert. Aus diesem Grund sollte diese Technik beim Testen von Abfragecodes generell vermieden werden.

Repositorymuster

Die oben genannten Ansätze haben versucht, entweder den Produktionsdatenbankanbieter von EF Core mit einem Fake-Testanbieter zu tauschen oder ein DbSet durch eine In-Memory-Sammlung zu erstellen. Diese Techniken ähneln sich insofern, als sie die LINQ-Abfragen des Programms immer noch auswerten – entweder in SQLite oder im Speicher – und das ist letztendlich die Quelle der oben beschriebenen Schwierigkeiten: Eine Abfrage, die für die Ausführung in einer bestimmten Produktionsdatenbank konzipiert wurde, kann nicht zuverlässig und ohne Probleme anderswo ausgeführt werden.

Für einen ordnungsgemäßen, zuverlässigen Test sollten Sie eine Repositoryebene einführen, die zwischen Ihrem Anwendungscode und EF Core vermittelt. Die Produktionsimplementierung des Repositorys enthält die tatsächlichen LINQ-Abfragen und führt sie über EF Core aus. Bei Tests wird die Repositoryabstraktion direkt erzeugt oder simuliert, ohne dass tatsächliche LINQ-Abfragen benötigt werden, wodurch EF Core vollständig aus Ihrem Teststapel entfernt wird und Tests sich allein auf Anwendungscode konzentrieren können.

Das folgende Diagramm vergleicht den Datenbank-Fake-Ansatz (SQLite/In-Memory) mit dem Repositorymuster:

Comparison of fake provider with repository pattern

Da LINQ-Abfragen nicht mehr Teil des Tests sind, können Sie Ihrer Anwendung Abfrageergebnisse direkt bereitstellen. Anders ausgedrückt, ermöglichen die vorherigen Ansätze in etwa das Stubbing von Abfrageeingaben (z. B. das Ersetzen von SQL Server Tabellen durch In-Memory-Tabellen), führen dann aber weiterhin die tatsächlichen Abfrageoperatoren im Arbeitsspeicher aus. Das Repositorymuster ermöglicht es Ihnen dagegen, Abfrageausgabe direkt zu erzeugen, sodass sie wesentlich leistungsstärkere und fokussiertere Komponententests ermöglichen. Damit dies funktioniert, darf Ihr Repository keine IQueryable-Methoden bereitstellen, da diese wiederum nicht erzeugt sein können; stattdessen sollte IEnumerable zurückgegeben werden.

Da das Repository-Muster jedoch erfordert, dass jede einzelne (testbare) LINQ-Abfrage in einer IEnumerable-Methode gekapselt wird, wird Ihrer Anwendung eine zusätzliche architektonische Schicht auferlegt, die erhebliche Kosten für die Implementierung und Wartung verursachen kann. Diese Kosten sollten bei der Entscheidung, wie eine Anwendung getestet werden soll, nicht außer Acht gelassen werden, insbesondere wenn man bedenkt, dass für die Abfragen, die das Repository bereitstellt, wahrscheinlich immer noch Tests gegen die echte Datenbank erforderlich sind.

Es ist erwähnenswert, dass Repositories auch andere Vorteile als nur das Testen haben. Sie stellen sicher, dass der gesamte Code für den Datenzugriff an einem Ort konzentriert und nicht über die gesamte Anwendung verstreut ist. Und wenn Ihre Anwendung mehr als eine Datenbank unterstützen muss, kann die Abstraktion des Repositorys sehr hilfreich sein, um Abfragen für verschiedene Anbieter zu optimieren.

Ein Beispiel mit Tests mit einem Repository finden Sie in diesem Abschnitt.

Gesamtvergleich

Die folgende Tabelle enthält einen schnellen, vergleichenden Überblick über die verschiedenen Testtechniken und zeigt, welche Funktionalität unter welchem Ansatz getestet werden kann:

Feature Im Arbeitsspeicher SQLite In-Memory DbContext-Mocking Repositorymuster Testen der Datenbank
Test-Double-Typ Fake Fake Fake Mocking/Stubbing Real, kein Double
Unformatiertes SQL? Nein Depends (Abhängig) Nein Ja Ja
Transaktionen? Nein (ignoriert) Ja Ja Ja Ja
Anbieterspezifische Übersetzungen? Nein Nr. Nein Ja Ja
Genaues Abfrageverhalten? Depends (Abhängig) Depends (Abhängig) Depends (Abhängig) Ja Ja
Kann LINQ überall in der Anwendung verwendet werden? Ja Ja Ja Nein* Ja

* Alle testbaren LINQ-Datenbankabfragen müssen in IEnumerable-Rückgabe-Repositorymethoden gekapselt werden, um simuliert/erzeugt zu werden.

Zusammenfassung

  • Entwicklern wird empfohlen, eine gute Testabdeckung ihrer Anwendung mit dem tatsächlichen Produktionsdatenbanksystem durchzuführen. Dies sorgt dafür, dass die Anwendung tatsächlich in der Produktion funktioniert, und mit dem richtigen Design können Tests zuverlässig und schnell ausgeführt werden. Da diese Tests in jedem Fall erforderlich sind, empfiehlt es sich, dort zu beginnen und bei Bedarf Tests mithilfe von Test-Doubles nach Bedarf hinzuzufügen.
  • Wenn Sie sich für ein Test-Double entschieden haben, empfehlen wir Ihnen, das Repository-Muster zu implementieren, das es Ihnen ermöglicht, Ihre Datenzugriffsschicht oberhalb von EF Core als Stub oder Mocking zu verwenden, anstatt einen Fake EF Core-Anbieter (SQLite/In-Memory) oder durch Mocking von DbSet zu verwenden.
  • Wenn das Repositorymuster aus irgendeinem Grund keine praktikable Option ist, sollten Sie SQLite-Datenbanken im Arbeitsspeicher verwenden.
  • Vermeiden Sie den In-Memory-Provider für Testzwecke. Davon wird abgeraten und es wird nur für Legacy-Anwendungen unterstützt.
  • Vermeiden Sie simuliertes DbSet für Abfragezwecke.