UnitTests 6: Konfigurationen

29. Juni 2012

Abhängigkeiten von Konfigurationseinstellungen sind ein leidiges Thema in UnitTests. Will man unterschiedliche Einstellungen testen, muss man am Configuration Framework (d.h. der System.Configuration namespace) vorbei gehen und eine manuelle Konfiguration vornehmen. Das muss nicht nur in der zu konfigurierenden Komponente so vorgesehen werden, das führt auch dazu, dass man die Konfigurationen selbst schlecht bis gar nicht testen kann.

Die Ausgangslage…

Machen wir es zunächst etwas konkreter an einem typischen Beispiel: Der Zugriff auf die Datenbank…

Und wieder der Hinweis: … für diese Beiträge …  “UnitTest” … lax … lediglich Tests … die MSTest als UnitTest-Framework nutzen. (Nein lieber ungenannt bleibender Kollege, auch diesmal keine Grundsatzdiskussion Smile with tongue out…)

… benötigt einen Connection-String. Der ist üblicherweise in der .config-Datei, etwa der web.config hinterlegt. Auch ein UnitTest-Projekt hat eine .config-Datei, nämlich die app.config im Projekt-Verzeichnis, die automatisch beim Build umbenannt und als Assembly-Konfiguration verwendet wird:

image

In diesen kann (und muss!) man den Connection-String natürlich problemlos mit aufnehmen:

   1: <?xml version="1.0" encoding="utf-8" ?>

   2: <configuration>

   3:     <connectionStrings>

   4:         <add name="ConsoleApplication1.Properties.Settings.PrivatbilanzConnectionString"

   5:             connectionString="Data Source=.SQL2008R2;Initial Catalog=Privatbilanz;Integrated Security=True;Pooling=False"

   6:             providerName="System.Data.SqlClient" />

   7:     </connectionStrings>

   8: </configuration>

Soweit, so gut.  

 

Die Tests…

“Normale” Tests laufen damit natürlich – sie erreichen die Datenbank wie gewünscht.

Aber was ist mit Tests von Fehlverhalten? Wenn die Datenbank nicht verfügbar ist, oder die Berechtigungen nicht stimmen? Anwendungscode sollte auf so eine Situation vorbereitet sein und ein adäquates Fehlerverhalten an den Tag legen, etwa eine ConfigurationErrorsException werfen.

Lösung 1 wäre das ändern der Konfiguration auf einen ungültigen Wert. Problem dabei: Als Kollateralschaden werden alle anderen Tests im Testprojekt rot (oder mindestens inconclusive). Nicht gerade das, was man sich im Sinne einer Testautomation wünscht. Ausweg wäre ein eigenes Testprojekt mit eigener .config-Datei; das wäre dann aber für jeden denkbarer Fehlerfall in der Konfiguration notwendig – auch nicht das Gelbe vom Ei.

Die typische Lösung ist dann i.d.R., dass man der Komponente die konfigurierten Werte in anderer Form übergibt. Beispiel:

   1: [TestMethod]

   2: [ExpectedException(typeof(SqlException))]

   3: public void TestInvalidConnection()

   4: {

   5:     var connectionString = ConfigurationManager.ConnectionStrings["ConsoleApplication1.Properties.Settings.PrivatbilanzConnectionString.Invalid"].ConnectionString;

   6:  

   7:     var data = new DataAccess(connectionString);

   8:  

   9:     // this should fail!

  10:     var actual = data.GetUserInformation("TestUser1");

  11: }

Statt den Connection-String von der Datenzugriffskomponente auslesen zu lassen, gibt man ihn ihr von außen mit.

Und bis zu einem gewissen Grad funktioniert das auch durchaus…

… aber dieses Verfahren gerät leider ganz schnell an seine Grenzen. Mögliche Fallstricke sind u.a.:

  • Die Komponente muss darauf vorbereitet sein entsprechend konfiguriert zu werden. Nicht immer ist das so einfach wie hier dargestellt.
  • Komplexere Konfiguration mit eigenen Config-Sections sind sehr aufwendig zu erzeugen.
  • Einsatz von Fremdsoftware die eigene Konfiguration benötigt, aber keine entsprechenden Eingriffspunkte bereitstellt.
  • Komplexeres Zusammenspiel von Objekten, bei denen nicht immer sofort klar ist, welche Dinge wie konfiguriert werden müssen.
  • Code bzgl. der Konfiguration selbst bleibt ungetestet, etwas Schema-Validierungen, fehlerhafte Typisierung, Bereitstellung von Standardwerten, etc..

Im Fazit: Eine einzige Konfiguration für das UnitTest-Projekt zu haben ist chronisch unzureichend.

Und leider nimmt sich das Testframework dieses Themas nicht an.

 

Eine mögliche Lösung…

Das folgende ist ausdrücklich eine mögliche – nicht DIE – Lösung, denn sie ist an Voraussetzungen gebunden:

  • Jede Funktionalität die durch Konfiguration beeinflussbar ist, muss sich so zurücksetzen lassen, dass sie die Konfiguration erneut auswertet. Andernfalls kann man die Konfiguration nicht in von einem Test zum nächsten ändern.
  • Die Lösung basiert auf nicht dokumentierten, internen Arbeitsweisen des Configuration Framework. Das birgt  das Risiko, dass sie mit der nächsten Version des .NET Framework (oder einem einfachen Patch) nicht mehr funktioniert. Das Riskio ist vermutlich gering, aber definitiv vorhanden; das muß akzeptabel sein.

Dreh- und Angelpunkt ist System.Configuration.ConfigurationMananger. Diese Klasse stellt über statische Methoden die Möglichkeiten bereit, .config-Dateien zu Öffnen und Sections auszulesen. Über sie laufen alle Zugriffe auf die Konfiguration einer (Web-)Anwendung.

Schaut man sich diese Klasse per MSDN-Dokumentation Reflector genauer an, stellt man fest, dass sie gegen eine per Interface adressierte Klasse arbeitet, die je nach Laufzeitumgebung (ausführbare Datei, Web-Anwendungen) instanziiert wird. Und hier genau kann man ansetzen…

Das Interface heißt IInternalConfigSystem und liegt im Namespace System.Configuration.Internal. Zwei Mal “internal” sollte ein deutlicher Hinweis sein, dass Microsoft diesen Teil nicht zur Nutzung für Anwendungsentwickler vorgesehen hat. Andererseits ist dieser Trick nicht gerade unbekannt

Natürlich ist die statische Variable mit besagter Instanz nicht public verfügbar, man muss sie also per Reflection auslesen:

   1: public static class UnitTestConfiguration

   2: {

   3:     public static void UseConfigFile(string configFile)

   4:     {

   5:         // access some configuration in order to initialize config system

   6:         var test = ConfigurationManager.AppSettings["dummy"];

   7:         // get config system from private static member

   8:         var field = GetInternalField();

   9:         var configSystem = (IInternalConfigSystem)field.GetValue(null);

  10:   

  11:         // ...

  12:     }

  13:     

  14:     static FieldInfo GetInternalField()

  15:     {

  16:         var field = typeof(ConfigurationManager).GetField("s_configSystem", BindingFlags.Static | BindingFlags.NonPublic);

  17:         return field;

  18:     }

Wichtig ist der initiale Zugriff auf die Konfiguration, sonst ist die interne Instanz womöglich noch nicht initialisiert. (Von den drei oben verlinkten Beispielen machen das zwei verkehrt!)

Hat man diesen Stand, geht es nur noch darum eine eigene Klasse unterzuschieben, die die Rolle des Configuration System übernimmt. Hier die vollständige Implementierung von UseConfigFile:

   1: public static void UseConfigFile(string configFile)

   2: {

   3:     // access some configuration in order to initialize config system

   4:     var test = ConfigurationManager.AppSettings["dummy"];

   5:     // get config system from private static member

   6:     var field = GetInternalField();

   7:     var configSystem = (IInternalConfigSystem)field.GetValue(null);

   8:     // install the wrapper, if not done yet

   9:     var wrapper = configSystem as ConfigurationWrapper;

  10:     if (wrapper == null)

  11:     {

  12:         wrapper = new ConfigurationWrapper(configSystem);

  13:         field.SetValue(null, wrapper);

  14:     }

  15:     wrapper.ConfigFile = configFile;

  16: }

Der Wrapper wird nur einmal installiert. Außerdem erhält er das ursprüngliche Configuration System als Verweis, damit es ggf. wieder zurückgesetzt werden kann. Entsprechend läßt sich der Ursprungszustand wieder herstellen:

   1: public static void Reset()

   2: {

   3:     var field = GetInternalField();

   4:     var wrapper = field.GetValue(null) as ConfigurationWrapper;

   5:     if (wrapper != null)

   6:         field.SetValue(null, wrapper.Wrapped);

   7: }

Fehlt noch die Implementierung des Wrappers selbst. Der allgemeine Teil – Properties, Konstruktor, simple Methoden – ist einfach genug:

   1: public class ConfigurationWrapper : IInternalConfigSystem

   2: {

   3:     public IInternalConfigSystem Wrapped { get; private set; }

   4:     public string ConfigFile { get; set; }

   5:  

   6:     public ConfigurationWrapper(IInternalConfigSystem wrapped)

   7:     {

   8:         this.Wrapped = wrapped;

   9:     }

  10:  

  11:     public void RefreshConfig(string sectionName)

  12:     {

  13:         this.Wrapped.RefreshConfig(sectionName);

  14:     }

  15:  

  16:     public bool SupportsUserConfig

  17:     {

  18:         get { return this.Wrapped.SupportsUserConfig; }

  19:     }

  20: }

Der interessante Teil verbirgt sich hinter der Methode GetSection(). Anstatt das ursprüngliche Configuration System aufzurufen, das seine Informationen aus der app.config ausliest, kann man hier seine eigene .config-Datei öffnen:

   1: public object GetSection(string configKey)

   2: {

   3:     ExeConfigurationFileMap map = new ExeConfigurationFileMap();

   4:     map.ExeConfigFilename = this.ConfigFile;

   5:     Configuration config = ConfigurationManager.OpenMappedExeConfiguration(map, ConfigurationUserLevel.None);

   6:     return config.GetSection(configKey);

   7: }

Der hier gezeigte Code zeigt den Grundansatz, er ist aber nicht umfassend auf alles vorbereitet. Zum Beispiel werden die appSettings im Web-Context als NameValueCollection erwartet, in ausführbaren Dateien und UnitTest-Projekten aber als AppSettingsSection geliefert.

Und nun…?

 

Die Verwendung

Nun haben wir, was wir brauchen. Die entsprechende Verwendung in einem Test:

   1: [TestMethod]

   2: [DeploymentItem("ConfigurationTests.TestInvalidConnection.config")]

   3: [ExpectedException(typeof(SqlException))]

   4: public void TestInvalidConnection_Better()

   5: {

   6:     // redirect to other config file...

   7:     UnitTestConfiguration.UseConfigFile("ConfigurationTests.TestInvalidConnection.config");

   8:  

   9:     var data = new DataAccess();

  10:     // this should fail!

  11:     var actual = data.GetUserInformation("TestUser1");

  12:  

  13:     // reset config system to standard!

  14:     UnitTestConfiguration.Reset();

  15: }

Wichtig: Das funktioniert nur so lange, wie DataAccess im Beispiel den Connection-String aus der Konfiguration entsprechend zu Rate zieht – und zwar bei jedem Aufruf, selbst wenn man vorher schon mal auf die Datenbank zugegriffen hatte. Das Zurücksetzen der Konfiguration, samt aller Daten die davon abhängen (das könnte der verwendete DI-Container sein, Caches die im Test gebildet wurden, Singleton-Implementierungen, etc.) sollte also zum guten Ton im Rahmen der Testbereinigung gehören.

 

Fazit

Mit der hier gezeigten Variante kann man auch konfigurationslastigen Code ausreichend testen. Nicht nur verschiedene Konfigurationsvarianten, sondern insbesondere auch das Fehlerverhalten. Ich habe das erfolgreich in einem Projekt umgesetzt, wobei dort zusätzlich noch Namenskonventionen für die Konfigurationsdateien zum Einsatz kamen (Testklasse.Testmethode.config), so dass die .config-Datei automatisch verwendet wird, sobald sie vorhanden ist.