Continuous Delivery mit Azure DevOps, Teil 2: Bauen auf dem Server

Seite 3: Unit-Tests mit In-Memory-Datenbank

Inhaltsverzeichnis

Wenn man den Quellcode des "Miracle List"-Backend verwendet, dann sollte der Build erst mal nicht gelingen. Das liegt daran, dass es in der Quellcodeverwaltung auch ein Projekt VSTools.csproj gibt, das kein .NET-Core-, sondern ein .NET-"Full"-Framework-Projekt ist. Es dient nur dazu, ein Klassendiagramm (.cd-Datei) mit Visual Studio zu erstellen, was in einem .NET-Core-Projekt lange Zeit nicht möglich war. Das Projekt lässt sich nicht mit den .NET-Core-Werkzeugen übersetzen.

Hier gilt es, im Task-Build, der im Standard alle .csproj-Dateien kompiliert, eine Ausnahme festzulegen. Dafür müssen Nutzer eine Zeile einfügen, die mit einem Ausrufezeichen beginnt:

**/*.csproj
!Tools/*

Das Texteingabefeld für den Build-Pfad besitzt zwar eine Sprechblase, die leitet den Benutzer aber nicht zu einer Dokumentation der Syntax. Allerdings lässt sich feststellen, dass die Eigenschaft "Path to project(s)" beim Task "Build .NET Core" ausgegraut ist (s. Abb. 3). Man sieht eine Klammer; ein Klick zeigt eine Sprechblase, die erklärt, dass diese Eigenschaft mit einem Pipeline-Parameter verknüpft ist. Durch Klick auf "Pipeline" (s. Abb. 1) ändern Anwender diesen. Nach der Änderung können sie den Build-Prozess durch Klick auf Save & queue erneut anstoßen.

Ein verknüpfter Parameter in dem "Build .NET Core"-Task (Abb. 3)

Allerdings wird auch mit dieser Anpassung der Build-Prozess nicht erfolgreich sein. Dieses Mal zeigt die Ablaufverfolgung, dass die Unit-Tests fehlschlagen, weil sie eine lokale SQL-Server-Datenbank ansprechen, die natürlich während des Pipeline-Ablaufs gar nicht auf dem Build-System vorhanden ist. Die erste naheliegende Vermutung ist, dass die falschen Tests ausgeführt wurden. Es waren aber nicht die falschen Tests, sie waren nur falsch konfiguriert.

Entity Framework Core, der objektrelationale Mapper für .NET Core, der im "Miracle List"-Backend für den Datenbankzugriff zum Einsatz kommt, besitzt einen In-Memory-Treiber, mit dem man auf elegante Weise Datenbankzugriffe ohne eine echte Datenbank und auch ohne das Implementieren von Mock-Objekten testen kann. Die implementierten Unit-Tests können wahlweise gegen eine echte Datenbank laufen (als Integrationstests) oder gegen die In-Memory-Datenbank (als echte Unit-Tests). Das Umschalten erfolgt durch eine Codezeile, die sich nach der Konfigurationseinstellung ConnectionStrings:MiracleListDB richtet. Diese Einstellung steckt in der appsettings.json-Datei, lässt sich aber durch eine Umgebungsvariable mit genau diesem Namen überschreiben. Umgebungsvariablen können Bediener im Azure-DevOps-Build-Prozess leicht durch die Registerkarte "Variables" (s. Abb. 1) setzen. Hier gibt es eine Tabelle, in der der Eintrag mit Name = "ConnectionStrings:MiracleListDB" und Value = "InMemoryDB" zu ergänzen ist.

Im nächsten Versuch wird der Build-Prozess erstmals bis zum Ende durchlaufen. Allerdings zeigt ein Blick auf die Registerkarten "Tests" oder "Summary" (s. Abb. 4) des Build-Durchlaufs, dass von den 24 Unit-Tests drei kein Ergebnis lieferten.

Drei von 24 Tests sind weder erfolgreich noch gescheitert (Abb. 4).

Das liegt daran, dass der In-Memory-Treiber leider doch nicht alle Fähigkeiten einer relationalen Datenbank besitzt. Drei der implementierten Tests können daher nicht als Unit-Tests, sondern nur als Integrationstests laufen. Dass ein Test eine echte Datenbank erfordert, müssen die Testautoren definieren. Das folgende Listing zeigt einen dieser drei Tests, der mithilfe des XUnit-Frameworks und der Erweiterung SkippableFact realisiert ist. Hier wird eine Computed Column getestet, die die In-Memory-Datenbank im Gegensatz zu einem SQL Server nicht berechnet.

namespace UnitTests
{
 publicclassTaskManagerTest
 {
  [SkippableFact] // NUGET: Xunit.SkippableFact
  [Trait("Category", "Integration")]
  publicvoid CreateTaskDueInDaysTest()
  {
   Skip.If(DAL.Context.ConnectionString == "", "Only runs as integration test as the InMem-DB does not support Default Values and Computed Columns!");

   var um = new UserManager("CreateTaskTestUser", true);
   um.InitDefaultTasks();
   var tm = new TaskManager(um.CurrentUser.UserID);
   var cm = new CategoryManager(um.CurrentUser.UserID);
   var t = new BO.Task();
   t.CategoryID = cm.GetCategorySet().ElementAt(0).CategoryID;
   t.Due = DateTime.Now.AddDays(3);
   tm.CreateTask(t);
   Assert.True(t.TaskID > 0);

   Assert.Equal(BO.Task.DefaultTitle, t.Title); // Default Value Test: not supported in InMem-DB
   Assert.Equal(3, t.DueInDays);// Computed Column Test: not supported in InMem-DB
  }
...
 }
}

Da alle diese nicht ohne Datenbank ausführbaren Unit-Tests mit [Trait("Category", "Integration")] annotiert sind, können Build-Prozess-Autoren sie einfach ausschließen. Dafür tragen sie im Task "Unit Tests .NET Core" in der Eigenschaft "Arguments" den folgenden zusätzlichen Parameter ein.

--filter Category!=Integration

DieseTest-Case-Filter-Syntax ist auf GitHub dokumentiert. Damit werden dann die drei Integrationstests aus dem Unit-Test-Lauf ausgeschlossen, sodass schließlich 100 Prozent der verbliebenen 21 Tests erfolgreich sein werden. Auf der Summary-Seite eines Build-Durchlaufs finden Anwender oben in der Liste unter "Build artifacts published" einen Hyperlink zu der erstellten Zip-Datei (s. Abb. 4). Diese können sie aus der Cloud herunterladen und prüfen.

Spannend ist es, dass die Build-Agents von Microsoft das .NET Core SDK schon vorinstalliert haben, andernfalls würde bereits der dotnet restore-Befehl am Anfang nicht laufen. Eine Herausforderung kann aber die Versionsnummer sein. Wenn man das "Miracle List"-Backend nicht auf einem Windows-System, sondern unter dem vordefinierten Agent "Ubuntu 16.04" übersetzen lässt, bricht der Package Restore mit der Fehlermeldung ab, dass das .NET SDK 2.141.0 installiert sei, aber 2.1.300 benötigt werde. Um das zu beheben, könnte man den Code auf das neuste SDK aktualisieren oder aber man installiert vor der ersten Ausführung eines .NET-Core-Kommandozeilenbefehls das passende .NET Core SDK auf das Build-System. Dazu gibt es den Task ".NET Core Tool Installer", den man durch Klick auf das Pluszeichen neben "Agent job 1" (s. Abb. 1) hinzufügt und an die erste Stelle verschiebt. In der Eigenschaft "Version" ist dann "2.1.300" einzutragen.

Abbildung 1 zeigt, dass die Build-Pipeline nicht direkt aus den Task-Schritten besteht, sondern es noch die Hierarchieebene "Agent Job 1" gibt. Eine Pipeline kann mehrere Jobs enthalten. Neben Jobs, die auf Agents laufen, gibt es auch sogenannte "Agentless Jobs" alias "Server Jobs", die auf dem DevOps-Server selbst laufen; diese bieten aber nur wenige Funktionen. In den Eigenschaften eines Jobs kann man festlegen, dass ein Job parallel auf mehreren Agents laufen soll. Eine Pipeline lässt sich als JSON-Datei ex- und importieren. Die in diesem Beitrag dargestellten Pipelines sind herunterladbar.

Die Build-Pipelines bieten unter "Library" die Möglichkeit, auch Pipeline-übergreifende Variablen zu speichern. Bei Task Groups können Anwender Folgen von Tasks zur Wiederverwendung ablegen. Zu einer Task Group kommen sie, indem sie in einer Pipeline mehrere Schritte markieren und dann im Kontextmenü Create Task Group wählen. Beauftrager eines Builds erhalten automatisch per E-Mail eine Erfolgsnachricht.

Andere Benutzer können sich über die "Notification Settings" entsprechende Abonnements einrichten. Es gibt zahlreiche Ereignistypen für alle Azure-DevOps-Dienste, die sich mit Filtern eingrenzen lassen. In den Eigenschaften einer Build-Pipeline legt man unter "Retention" fest, wie lange die Build-Ergebnisse aufbewahrt werden. Unter Option findet man einen Hyperlink zu einer dynamisch erzeugten Grafikdatei, die anzeigt, ob der letzte Build erfolgreich war. Zudem kann man bei jedem fehlgeschlagenen Build automatisch ein Work Item eines beliebigen Typs erzeugen.