April 2017

Band 32, Nummer 4

Data Points: Tipps zum Erstellen von Tests mit EF Core und dem InMemory-Anbieter

Von Julie Lerman

Julie LermanBeim Erstellen automatisierter Tests für Methoden, die eine Datenbankinteraktion auslösen, möchten Sie manchmal wirklich sehen, was in der Datenbank geschieht. Es kann jedoch auch vorkommen, dass die Datenbankinteraktion überhaupt nicht relevant für die Aussage Ihres Tests ist. Der neue InMemory-Anbieter von EF Core kann im letztgenannten Fall hilfreich sein. In diesem Artikel stelle ich Ihnen dieses nützliche Tool vor und nenne einige Tipps und Tricks für das Erstellen automatisierter Tests mit EF Core, die ich selbst beim Herumprobieren herausgefunden habe.

Wenn die Datenbankinteraktion für das Testergebnis nicht wichtig ist, können unnötige Aufrufe der Datenbank die Leistung negativ beeinträchtigen oder sogar zu ungenauen Testergebnissen führen. Die Zeitspanne, die für die Kommunikation mit der Datenbank (oder das Löschen und anschließende Neuerstellen einer Testdatenbank) erforderlich ist, kann zu Verzögerungen bei Ihren Tests führen. Außerdem kann ggf. auch ein Problem mit der Datenbank selbst vorliegen. Möglicherweise bewirkt eine Netzwerklatenz oder ein kurzzeitiges Problem, dass nur deswegen ein Fehler bei einem Test auftritt, weil die Datenbank nicht verfügbar ist. Dieses Ergebnis beruht nicht auf einem Fehler in der Programmlogik, die der Test bestätigen soll.

Wir suchen schon lange nach Möglichkeiten, diese Nebenwirkungen zu minimieren. Fakes und Mocking Frameworks sind Lösungen, die häufig verwendet werden. Diese Muster ermöglichen das Erstellen von In-Memory-Darstellungen des Datenspeichers. Das Einrichten ihrer In-Memory-Daten und ihres Verhaltens ist jedoch mit viel Aufwand verbunden. Ein anderer Ansatz besteht im Verwenden einer einfacheren Datenbank als in der Produktion für das Testen, z. B. einer PostgreSQL- oder SQLite-Datenbank anstelle einer SQL Server-Datenbank, die Sie für Ihren Produktionsdatenspeicher einsetzen. In Entity Framework (EF) war es dank der diversen verfügbaren Anbieter immer möglich, verschiedene Datenbanken mit einem Modell als Ziel zu verwenden. Nuancierte Unterschiede bei der Datenbankfunktionalität können jedoch bewirken, dass Probleme auftreten, in denen dieser Ansatz nicht immer funktioniert (auch wenn es sich um eine gute Option handelt, die in der Toolbox verbleiben sollte). Alternativ könnten Sie auch ein externes Tool einsetzen, z. B. die Open Source EFFORT-Erweiterung (github.com/tamasflamich/effort), die auf magische Weise eine In-Memory-Darstellung des Datenspeichers bereitstellt, ohne dass eine aufwändige Einrichtung von Fakes oder Mocking Frameworks erforderlich ist. EFFORT funktioniert mit EF 4.1 über EF6, nicht jedoch über EF Core.

Für EF Core sind bereits mehrere Datenbankanbieter vorhanden. Microsoft stellt die SQL Server- und SQLite-Anbieter als Teil der Produktfamilie der EntityFrameworkCore-APIs zur Verfügung. Auch für SQLCE bzw. PostgreSQL sind Anbieter verfügbar, die von den MVPs Erik Eilskov Jensen und Shay Rojansky verwaltet werden. Außerdem stehen Anbieter von Drittanbietern kommerziell zur Verfügung. Microsoft hat jedoch einen weiteren Anbieter erstellt – nicht für das persistente Speichern in einer Datenbank, sondern für das vorübergehende persistente Speichern im Arbeitsspeicher. Dies ist der InMemory-Anbieter: „Microsoft. EntityFrameworkCore.InMemory“. Sie können diesen Anbieter als eine schnelle Möglichkeit verwenden, um einen Ersatz für eine tatsächliche Datenbank in vielen Testszenarien bereitzustellen.

Vorbereiten von „DbContext“ für den InMemory-Anbieter

Da Ihr „DbContext“ manchmal zum Herstellen einer Verbindung mit einem echten Datenspeicher und manchmal zum Herstellen einer Verbindung mit dem InMemory-Anbieter verwendet wird, möchten Sie ihn möglichst flexibel im Hinblick auf Anbieter und ohne Abhängigkeit von einem bestimmten Anbieter einrichten.

Beim Instanziieren eines „DbContext“ in EF Core müssen Sie „DbContextOptions“ einschließen, die angeben, welcher Anbieter verwendet werden soll. Außerdem muss eine Verbindungszeichenfolge angegeben werden (wenn erforderlich). „UseSqlServer“ und „UseSqlite“ erfordern z. B. das Übergeben einer Verbindungszeichenfolge, und jeder Anbieter bietet Ihnen Zugriff auf die relevante Erweiterungsmethode. Das folgende Beispiel zeigt, wie dies aussieht, wenn die Implementierung direkt in der OnConfiguring-Methode der DbContext-Klasse erfolgt, in der ich eine Verbindungszeichenfolge aus einer Anwendungskonfigurationsdatei lese:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
  var settings = ConfigurationManager.ConnectionStrings;
  var connectionString = settings["productionDb"].ConnectionString;
  optionsBuilder.UseSqlServer(connectionString);
 }

Ein flexibleres Muster besteht jedoch im Übergeben eines vorkonfigurierten DbContextOptions-Objekts an den Konstruktor von „DbContext“:

public SamuraiContext(DbContextOptions<SamuraiContext> options)
    :base(options) { }

EF Core übergibt diese vorkonfigurierten Optionen an den zugrunde liegenden „DbContext“ und wendet sie dann für Sie an.

Wenn dieser Konstruktor vorhanden ist, besteht nun die Möglichkeit, schnell andere Anbieter (und andere Optionen, z. B. eine Verbindungszeichenfolge) aus der Programmlogik anzugeben, die den Kontext verwendet.

Wenn Sie einen IoC-Container (Inversion of Control) in Ihrer Anwendung (z. B. StructureMap structuremap.github.io) oder die in ASP.NET Core integrierten Dienste verwenden, können Sie den Anbieter für den Kontext in dem Code konfigurieren, in dem Sie andere anwendungsweite IoC-Dienste konfigurieren. Das folgende Beispiel verwendet ASP.NET Core-Dienste in einer typischen Datei „startup.cs“:

public void ConfigureServices(IServiceCollection services) {
  services.AddDbContext<SamuraiContext>(
    options => options.UseSqlServer(
      Configuration.GetConnectionString("productionDb")));
  services.AddMvc();
}

In diesem Fall ist „SamuraiContext“ der Name meiner Klasse, die von „DbContext“ erbt. Ich verwende erneut SQL Server und habe die Verbindungszeichenfolge in der Datei „appsettings.json“ von ASP.NET Core unter dem Namen „productionDb“ gespeichert. Der Dienst wurde so konfiguriert, dass er immer dann, wenn ein Klassenkonstruktor eine Instanz von „Samurai­Context“ erfordert, die Laufzeit nicht nur anweist, „Samurai­Context“ zu instanziieren, sondern auch die in dieser Methode angegebenen Optionen mit dem Anbieter und der Verbindungszeichenfolge zu übergeben.

Wenn diese ASP.NET Core-App meinen „SamuraiContext“ verwendet, erfolgt dies nun standardmäßig mit SQL Server und meiner Verbindungszeichenfolge. Dank der Flexibilität, die ich in die SamuraiContext-Klasse integriert habe, kann ich jedoch auch Tests erstellen, die den gleichen „SamuraiContext“ verwenden, aber ein DbContextOptions-Objekt übergeben, das stattdessen die Verwendung des InMemory-Anbieters (oder beliebiger anderer Optionen, die für einen bestimmten Test relevant sind) angibt.

Im nächsten Abschnitt zeige ich zwei verschiedene Tests, die EF Core nutzen. Der erste Test (Abbildung 1) soll ermitteln, ob die richtige Datenbankinteraktion aufgetreten ist. Dies bedeutet, dass der Test die Datenbank wirklich verwenden soll. Ich erstelle „DbContextOptions“ also so, dass der SQL Server-Anbieter verwendet wird – jedoch mit einer Verbindungszeichenfolge für eine Testversion meiner Datenbank, die ich schnell erstellen und anschließend löschen kann.

Abbildung 1: Testen, ob eine Datenbankeinfügung wie erwartet funktioniert

[TestMethod]
  public void CanInsertSamuraiIntoDatabase() {
    var optionsBuilder = new DbContextOptionsBuilder();
    optionsBuilder.UseSqlServer
      ("Server = (localdb)\\mssqllocaldb; Database =
        TestDb; Trusted_Connection = True; ");
    using (var context = new SamuraiContext(optionsBuilder.Options)) {
      context.Database.EnsureDeleted();
      context.Database.EnsureCreated();
      var samurai = new Samurai();
      context.Samurais.Add(samurai);
      var efDefaultId = samurai.Id;
      context.SaveChanges();
      Assert.AreNotEqual(efDefaultId, samurai.Id);
    }
  }

Ich verwende die EnsureDeleted- und EnsureCreated-Methoden, um eine ganz neue Version der Datenbank für den Test zu erhalten. Diese Methoden funktionieren sogar dann, wenn keine Migrationen vorhanden sind. Alternativ könnten Sie auch „EnsureDeleted“ und „Migrate“ zum erneuten Erstellen der Datenbank verwenden, wenn Migrationsdateien vorhanden sind.

Im nächsten Schritt erstelle ich eine neue Entität („Samurai“), weise EF an, mit deren Nachverfolgung zu beginnen, und notiere mir dann den temporären Schlüsselwert, den der SQL Server-Anbieter bereitstellt. Nachdem ich „SaveChanges“ aufgerufen habe, bestätige ich, dass SQL Server den eigenen, von der Datenbank generierten Wert für den Schlüssel angewendet hat, und vergewissere mich, dass dieses Objekt tatsächlich richtig in die Datenbank eingefügt wurde.

Das Löschen und erneute Erstellen einer SQL Server-Datenbank kann sich ggf. auf die Ausführungsdauer des Tests auswirken. Sie können in diesem Fall SQLite verwenden und die gleichen Ergebnisse schneller erzielen. Dabei wird sichergestellt, dass der Test noch immer eine tatsächliche Datenbank betrifft. Beachten Sie außerdem, dass SQLite genau wie der SQL Server-Anbieter einen temporären Schlüsselwert festlegt, wenn Sie dem Kontext eine Entität hinzufügen.

Wenn Sie Methoden nutzen, die EF Core verwenden, der Test jedoch ohne Verwendung der Datenbank erfolgen soll, ist der InMemory-Anbieter außerordentlich praktisch. Denken Sie jedoch daran, dass InMemory keine Datenbank ist und nicht alle Varianten des Verhaltens einer relationalen Datenbank (z. B. referentielle Integrität) emuliert. Wenn eine solche Funktionalität für Ihren Test wichtig ist, sollten Sie ggf. die SQLite-Option oder (wie in den EF Core-Dokumenten vorgeschlagen) den In-Memory-Modus von SQLite bevorzugen, der unter bit.ly/2l7M71p beschrieben wird.

Die folgende Methode habe ich in einer App geschrieben, die eine Abfrage mit EF Core ausführt und eine Liste von KeyValuePair-Objekten zurückgibt:

public List<KeyValuePair<int, string>> GetSamuraiReferenceList() {
  var samurais = _context.Samurais.OrderBy(s => s.Name)
    .Select(s => new {s.Id, s.Name})
    .ToDictionary(t => t.Id, t => t.Name).ToList();
  return samurais;
}

Ich möchte testen, ob die Methode wirklich eine KeyValuePair-Liste zurückgibt. Ich muss nicht die Datenbank abfragen, um dies zu beweisen.

Der folgende Test zeigt die Verwendung des InMemory-Anbieters (den ich bereits im Testprojekt installiert habe):

[TestMethod]
  public void CanRetrieveListOfSamuraiValues() {
    _options = new DbContextOptionsBuilder<SamuraiContext>()
               .UseInMemoryDatabase().Options;
    var context = new SamuraiContext(_options);
    var repo = new DisconnectedData(context);
    Assert.IsInstanceOfType(repo.GetSamuraiReferenceList(),
                            typeof(List<KeyValuePair<int, string>>));
  }

Dieser Test erfordert nicht einmal, dass Beispieldaten für die In-Memory-Darstellung der Datenbank verfügbar sind, weil es ausreicht, eine leere Liste von „KeyValuePairs“ zurückzugeben. Wenn ich den Test ausführe, kann EF Core sicher sein, dass der Anbieter bei der Ausführung der Abfrage von „GetSamuraiReferenceList“ Ressourcen im Arbeitsspeicher zuweist, die EF bei der Ausführung verwenden kann. Die Abfrage ist erfolgreich. Der Test ist es ebenfalls.

Wie muss ich vorgehen, wenn ich testen möchte, ob die richtige Anzahl von Ergebnissen zurückgegeben wird? In diesem Fall muss ich Daten bereitstellen, um ein Seeding des InMemory-Anbieters auszuführen. Ähnlich wie bei einem Fake oder Mock müssen die Daten erstellt und in den Datenspeicher des Anbieters geladen werden. Wenn Fakes und Mocks verwendet werden, können Sie ein List-Objekt erstellen, dieses mit Daten auffüllen und dann eine Abfrage anhand der Liste ausführen. Der InMemory-Anbieter übernimmt die Verwaltung des Containers. Sie verwenden nur EF-Befehle, um ihn vorab mit Daten aufzufüllen. Der InMemory-Anbieter übernimmt außerdem einen Großteil des bei der Verwendung von Fakes oder Mocks erforderlichen Mehraufwands und der zusätzlichen Codierung.

Abbildung 2 zeigt als Beispiel eine Methode, die ich zum Ausführen des Seedings des InMemory-Anbieters verwende, bevor meine Tests mit diesem interagieren:

Abbildung 2: Seeding eines InMemory-Anbieters von EF Core

private void SeedInMemoryStore() {
    using (var context = new SamuraiContext(_options)) {
      if (!context.Samurais.Any()) {
        context.Samurais.AddRange(
          new Samurai {
            Id = 1,
            Name = "Julie",
          },
          new Samurai {
            Id = 2,
            Name = "Giantpuppy",
        );
        context.SaveChanges();
      }
    }
  }

Wenn meine In-Memory-Daten leer sind, fügt diese Methode zwei neue „Samurais“ ein und ruft dann „SaveChanges“ auf. Nun ist alles für die Verwendung durch einen Test bereit.

Aber wie kann mein InMemory-Datenspeicher Daten aufweisen, wenn ich nur den Kontext instanziiert habe? Der Kontext ist nicht der InMemory-Datenspeicher. Stellen Sie sich den Datenspeicher als ein List-Objekt vor: Der Kontext erstellt ihn schnell, wenn erforderlich. Nachdem er jedoch erstellt wurde, verbleibt er für die Lebensdauer der Anwendung im Arbeitsspeicher. Wenn ich eine einzelne Testmethode ausführe, kommt es zu keinen Überraschungen. Wenn ich hingegen mehrere Testmethoden ausführe, verwendet jede Testmethode die gleiche Datenmenge, und Sie möchten das Auffüllen möglicherweise nicht ein zweites Mal ausführen. Zum Verständnis dieses Sachverhalts gibt es noch mehr zu sagen. Ich werde darauf eingehen, nachdem Sie etwas mehr Code gesehen haben.

Dieser nächste Test ist etwas an den Haaren herbeigezogen. Er soll jedoch die Verwendung eines mit Daten aufgefüllten InMemory-Speichers zeigen. Da bekannt ist, dass ich soeben ein Seeding des Arbeitsspeichers mit zwei „Samurais“ ausgeführt habe, ruft der Test diese gleiche GetSamuraiReferenceList-Methode auf und stellt fest, dass die sich ergebende Liste zwei Elemente enthält:

[TestMethod]
  public void CanRetrieveAllSamuraiValuePairs() {
    var context = new SamuraiContext(_options);
    var repo = new DisconnectedData(context);
    Assert.AreEqual(2, repo.GetSamuraiReferenceList().Count);
  }

Möglicherweise ist Ihnen aufgefallen, dass ich die Seed-Methode nicht aufgerufen und keine Optionen erstellt habe. Ich habe diese Programmlogik in den Testklassenkonstruktor verschoben, damit ich sie in meinen Tests nicht wiederholen muss. Die _options-Variable wird für den vollständigen Bereich der Klasse deklariert:

private DbContextOptions<SamuraiContext> _options;
  public TestDisconnectedData() {
    _options =
      new DbContextOptionsBuilder<SamuraiContext>().UseInMemoryDatabase().Options;
    SeedInMemoryStore();
  }

Da ich nun die Seed-Methode in den Konstruktor verschoben habe, nehmen Sie wahrscheinlich an (wie es bei mir der Fall war), dass diese nur ein Mal aufgerufen wird. Das stimmt jedoch nicht. Wussten Sie, dass ein Testklassenkonstruktor von jeder ausgeführten Testmethode aufgerufen wird? Ganz ehrlich: Ich hatte das vergessen, bis mir auffiel, dass Tests bei alleiniger Ausführung bestanden wurden, bei der gemeinsamen Ausführung jedoch ein Fehler auftrat. Das war der Fall, bevor ich die Überprüfung eingefügt habe, ob die Samurai-Daten bereits im Arbeitsspeicher vorhanden sind. Jede Methode, die den Aufruf der Seed-Methode auslöst, würde ein Seeding der gleichen Sammlung ausführen. Dies würde unabhängig davon geschehen, ob ich die Seed-Methode in jeder Testmethode oder nur ein Mal im Konstruktor aufrufe. Die Überprüfung auf bereits vorhandene Daten schützt mich in beiden Fällen.

Es gibt jedoch ein eleganteres Verfahren zum Vermeiden des Problems von einen Konflikt verursachenden In-Memory-Datenspeichern. InMemory ermöglicht das Bereitstellen eines Namens für den Datenspeicher.

Wenn Sie die Erstellung der „DbContextOptions“ zurück in die Testmethode verschieben möchten (und dies für jede Methode erfolgen soll), stellt das Angeben eines eindeutigen Namens als Parameter von „UseInMemory“ sicher, dass jede Methode ihren eigenen Datenspeicher verwendet.

Ich habe ein Refactoring meiner Testklasse ausgeführt, indem ich die klassenweite _options-Variable und den Klassenkonstruktor entfernt habe. Stattdessen verwende ich eine Methode zum Erstellen der Optionen für einen benannten Datenspeicher und das Seeding des entsprechenden Datenspeichers, der den gewünschten Namen als einen Parameter annimmt:

private DbContextOptions<SamuraiContext> SetUpInMemory(string uniqueName) {
  var options = new DbContextOptionsBuilder<SamuraiContext>()
                    .UseInMemoryDatabase(uniqueName).Options;
  SeedInMemoryStore(options);
  return options;
}

Ich habe die Signatur und die erste Zeile von „SeedInMemoryStore“ so geändert, dass die konfigurierten Optionen für den eindeutigen Datenspeicher verwendet werden:

private void SeedInMemoryStore(DbContextOptions<SamuraiContext> options) {
  using (var context = new SamuraiContext(options)) {

Und jede Testmethode verwendet nun diese Methode zusammen mit einem eindeutigen Namen zum Instanziieren von „DbContext“. Hier sind die überarbeiteten „CanRetrieve­AllSamuraiValuePairs“. Die einzige Änderung besteht darin, dass ich nun die neue SetUpInMemory-Methode zusammen mit dem eindeutigen Namen des Datenspeichers übergebe. Ein praktisches Muster, das vom EF-Team empfohlen wird, ist die Verwendung des Testnamens als Name der InMemory-Ressource:

[TestMethod]
  public void CanRetrieveListOfSamuraiValues() {
  using (var context = 
      new SamuraiContext(SetUpInMemory("CanRetrieveListOfSamuraiValues"))) {
    var repo = new DisconnectedData(context);
    Assert.IsInstanceOfType(repo.GetSamuraiReferenceList(),
                            typeof(List<KeyValuePair<int, string>>));
   }
 }

Andere Testmethoden in meiner Testklasse weisen ihre eigenen eindeutigen Namen für den Datenspeicher auf. Sie sehen also, dass Muster für die Verwendung einer eindeutigen Datenmenge oder das Freigeben einer gemeinsamen Datenmenge für Testmethoden verfügbar sind. Wenn Ihre Tests Daten in den In-Memory-Datenspeicher schreiben, ermöglichen die eindeutigen Namen das Vermeiden von Nebenwirkungen auf andere Tests. Denken Sie daran, dass EF Core 2.0 immer die Angabe eines Namens erfordert (im Gegensatz zum optionalen Parameter in EF Core 1.1).

Hier noch ein letzter Tipp, den ich Ihnen zum InMemory-Datenspeicher geben möchte. Als ich den ersten Test beschrieben habe, habe ich darauf hingewiesen, dass die SQL Server- und SQLite-Anbieter einen temporären Wert in die Schlüsseleigenschaft des Samurais einfügen, wenn das Objekt dem Kontext hinzugefügt wird. Ich habe jedoch nicht erwähnt, dass der Anbieter den Wert nicht überschreibt, wenn Sie den Wert selbst angeben. Da ich das Standardverhalten der Datenbank verwende, überschreibt die Datenbank aber in allen Fällen den Wert mit ihrem eigenen generierten Wert für den Primärschlüssel. Wenn Sie beim InMemory-Anbieter jedoch einen Schlüsseleigenschaftenwert angeben, wird dieser Wert vom Datenspeicher verwendet. Wenn Sie keinen Wert angeben, verwendet der InMemory-Anbieter einen clientseitigen Schlüsselgenerator, dessen Wert als der dem Datenspeicher zugewiesene Wert fungiert.

Die verwendeten Beispiele stammen aus meinem Kurs „EF Core: erste Schritte mit Pluralsight“ (bit.ly/PS_EFCoreStart). In diesem Kurs erfahren Sie mehr über EF Core sowie das Testen mit EF Core. Der Beispielcode steht auch als Download zusammen mit diesem Artikel zur Verfügung.


Julie Lermanist Microsoft Regional Director, Microsoft MVP, Mentorin für das Softwareteam und Unternehmensberaterin. Sie lebt in den Bergen von Vermont. Sie hält bei User Groups und Konferenzen in der ganzen Welt Vorträge zum Thema Datenzugriff und zu anderen Themen. Julie Lerman führt unter thedatafarm.com/blog einen Blog. Sie ist die Autorin von „Programming Entity Framework“ sowie der Ausgaben „Code First“ und „DbContext“ (alle bei O’Reilly Media erschienen). Folgen Sie ihr auf Twitter: @julielerman, und sehen Sie sich ihre Pluralsight-Kurse unter juliel.me/PS-Videos an.

Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Rowan Miller