Integrationstests vs. Stored Procedures – Teil 3

22. April 2014

Am Anfang dieser Artikelserie wurde zunächst die Herausforderung aufgezeigt, die sich aus der durchgängigen Verwendung von Stored Procedures in Kombination mit umfangreichen Integrationstests ergibt. Im zweiten Teil der Artikelserie wurde dann gezeigt, wie sich der Zugriff auf Stored Procedures für CRUD-Operationen soweit abstrahieren lässt, dass man generische Methoden für eine beliebige Anzahl von Datenentitäten verwenden kann. Dadurch wird es auch möglich, für alle auf diese Weise aufrufbaren Stored Procedures die gleichen Integrationstests zu verwenden.

Doppelte Sicherheit

Für das Testen der Datenzugriffsmethoden bzw. der dahinterliegenden Stored Procedures sollten einige Voraussetzungen erfüllt sein:

  • Es wird eine Testdatenbank mit allen relevanten Tabellen und Stored Procedures benötigt. Empfehlenswert ist z. B. die Verwendung eines Datenbankprojektes, mit dem sich die Testdatenbank bequem aus Visual Studio heraus generieren lässt.
  • Man sollte für Testzwecke (und nur für diese) eine alternative Implementierung der Datenzugriffsmethoden haben, sodass man die tatsächlichen Datenzugriffsmethoden unabhängig voneinander testen kann.
  • Für jede Entität müssen bei dem im Folgenden vorgestellten Verfahren mindestens zwei Testdatensätze bereitgestellt werden. Eine Alternative hierzu wäre eine automatisierte Testdatengenerierung.

Des Weiteren wird ein geeignetes Test-Framework (z. B. das Visual Studio Testing Framework oder NUnit) benötigt. Zwar handelt es sich bei allen Tests nicht um autarke Unit-Tests, da sich die Stored Procedures nicht losgelöst von den Datenzugriffsmethoden testen lassen. Das Bestreben sollte aber dahin gehen, die Abhängigkeiten von weiteren Komponenten so gering wie möglich zu halten. Aus diesem Grund ist auch die angesprochene alternative Implementierung wichtig. Die im zweiten Teil der Artikelserie vorgestellten Hilfsmethoden für die Auswertung der Attribute können erneut zum Einsatz kommen, sollten dann aber idealerweise mit Unit Tests auf ihre Funktionstüchtigkeit getestet worden sein.

Es bietet sich an, für die alternativen Datenzugriffsmethoden konventionelle SQL-Statements zu verwenden. Da diese Methoden nur im Testprojekt bzw. auf der Testdatenbank verwendet werden, ist der Grundsatz, dass alle Datenbankzugriffe in der eigentlichen Anwendung per Stored Procedure zu erfolgen haben, davon nicht berührt.

Die alternative Implementierung der Datenzugriffsmethoden könnte so aussehen:

   1: public IDataReader DirectSelect(Type type, int primaryKey)

   2: {

   3:     var tableName = GetTableName(type);

   4:     var primaryKeyName = GetPrimaryKeyName(tableName);

   5:  

   6:     using (var connection = new SqlConnection("ConnectionString"))

   7:     {

   8:         connection.Open();

   9:         var cmd = new SqlCommand("SELECT * FROM " + tableName + " WHERE " + primaryKeyName + " = " + primaryKey, connection);

  10:         return cmd.ExecuteReader();

  11:     }

  12: }

   1: public void DirectDelete(Type type, int primaryKey)

   2: {

   3:     var tableName = GetTableName(type);

   4:     var primaryKeyName = GetPrimaryKeyName(tableName);

   5:  

   6:     using (var connection = new SqlConnection("ConnectionString"))

   7:     {

   8:         connection.Open();

   9:         var cmd = new SqlCommand("DELETE FROM " + tableName + " WHERE " + primaryKeyName + " = " + primaryKey, connection);

  10:         cmd.ExecuteNonQuery(); 

  11:     }

  12: }

   1: public int DirectInsert(object item)

   2: {

   3:     var type = item.GetType();

   4:     var tableName = GetTableName(type);

   5:     var columnMappings = GetColumnMappings(type, false);

   6:  

   7:     var columnDict = new Dictionary<string, object>();

   8:  

   9:     foreach (var currentColumnMapping in columnMappings)

  10:     {

  11:         var property = currentColumnMapping.Key;

  12:         var columnName = currentColumnMapping.Value;

  13:         var rawValue = property.GetValue(item, null);

  14:         var valueType = property.PropertyType;

  15:         object finalValue;

  16:  

  17:         if (valueType == typeof(string))

  18:         {

  19:             finalValue = "'" + rawValue + "'";

  20:         }

  21:         else if (valueType == typeof(DateTime))

  22:         {

  23:             finalValue = string.Format("'{0:yyyy-MM-dd}'", rawValue);

  24:         }

  25:         else if ((valueType == typeof(int)) || (valueType == typeof(int?)))

  26:         {

  27:             if (rawValue == null) { continue; }

  28:             finalValue = rawValue;

  29:         }

  30:         else if ((valueType == typeof(decimal)) || (valueType == typeof(decimal?)))

  31:         {

  32:             if (rawValue == null) { continue; }

  33:             finalValue = ((decimal)rawValue).ToString(CultureInfo.InvariantCulture);

  34:         }

  35:         else if (valueType == typeof(bool))

  36:         {

  37:             finalValue = (bool)rawValue ? 1 : 0;

  38:         }

  39:         else

  40:         {

  41:             throw new InvalidOperationException("Unerwarteter Typ '" + valueType.Name + "' in Spalte '" + columnName + "'");   

  42:         }

  43:  

  44:         columnDict.Add(columnName, finalValue);

  45:     }

  46:  

  47:     var sql = string.Format("INSERT INTO {0} ({1}) VALUES ({2}); SELECT CAST(SCOPE_IDENTITY() AS INT)", tableName, string.Join(",", columnDict.Keys), string.Join(",", columnDict.Values));

  48:  

  49:     using (var connection = new SqlConnection("ConnectionString"))

  50:     {

  51:         connection.Open();

  52:         var cmd = new SqlCommand(sql, connection);

  53:         var identity = (int)cmd.ExecuteScalar();

  54:         return identity > 0 ? identity : -1;   

  55:     }            

  56: }

Eine Doppelimplementierung der Datenzugriffsmethode für UPDATE-Operationen ist nicht zwingend erforderlich.

Blaupausen für die Integrationstests

Der Aufbau der einzelnen Integrationstests ist ähnlich, unterscheidet sich aber von CRUD-Operation zu CRUD-Operation, da jeweils etwas anderes getestet wird:

INSERT:

  1. Datensatz in die Datenbank einfügen mit der zu testenden Methode
  2. Datensatz aus der Datenbank auslesen mit der direkten Methode
  3. Prüfen, ob die gelesenen Werte den geschriebenen Werten entsprechen
  4. Datensatz aus der Datenbank löschen mit der direkten Methode

SELECT:

  1. Datensatz mit der direkten Methode in die Datenbank einfügen
  2. Datensatz aus der Datenbank auslesen mit der zu testenden Methode
  3. Datensatz aus der Datenbank auslesen mit der direkten Methode
  4. Prüfen, ob die gelesenen Werte in beiden Fällen identisch sind
  5. Datensatz aus der Datenbank löschen mit der direkten Methode

UPDATE:

  1. Datensatz mit der direkten Methode in die Datenbank einfügen
  2. Den Datensatz in der Datenbank aktualisieren mit der zu testenden Methode
  3. Datensatz aus der Datenbank auslesen mit der direkten Methode
  4. Prüfen, ob die gelesenen Werte in beiden Fällen unterschiedlich sind
  5. Datensatz aus der Datenbank löschen mit der direkten Methode

DELETE:

  1. Datensatz in die Datenbank einfügen mit der direkten Methode
  2. Datensatz mit der zu testenden Methode aus der Datenbank löschen
  3. Prüfen, ob kein Datensatz mehr in der Datenbank ist mit der direkten Methode

Konkretes Beispiel

Im Folgenden soll das in diesem Abschnitt beschriebene Vorgehen exemplarisch anhand eines Tests für die im zweiten Teil der Artikelserie vorgestellte UPDATE-Datenzugriffsmethode gezeigt werden. In der Praxis braucht man natürlich auch für diese Methode mehr als einen Test. Denkbare wären z. B. Tests, die das Verhalten beim Hinzufügen doppelter Primary Keys prüfen usw.

Es wird davon ausgegangen, dass die Testklasse von einer Basis-Testklasse abgeleitet ist. In dieser Test-Basisklasse würden die Methoden DirectSelect, DirectDelete und DirectInsert implementiert sein. Weiterhin wird davon ausgegangen, dass sich die zu testenden Methoden sich in einer Klasse mit dem Namen DataAccess befinden.

Für diesen Test wird mit zwei Entitäten ein Vorher-/Nachher-Vergleich durchgeführt. Deswegen ist es notwendig, dass sich beide Entitäten in allen schreibbaren Werten mit Ausnahme des Primärschlüssels unterscheiden. In realen Projekten ist es sinnvoll, die Testdatengenerierung auszulagern oder zu automatisieren. Für das Beispiel ist es aber hinreichend, diese direkt zu instanziieren.

   1: [Test]

   2: public void AllProductPropertiesCanBeUpdated()

   3: {

   4:     var firstProduct = new Product { Name = "Some Product", InventoryNumber = 1 };

   5:     var secondProduct = new Product { Name = "Another Product", InventoryNumber = 2 };

   6:     

   7:     DoUpdateTest(firstProduct, secondProduct);

   8: }

In der Hilfsmethode DoUpdateTest() liegt die im Abschnitt “Blaupausen für die Integrationstests” beschriebene Prüflogik:

   1: private void DoUpdateTest(object firstItem, object secondItem)

   2: {

   3:     var type = firstItem.GetType();

   4:     var tableName = GetTableName(type); 

   5:  

   6:     // Sicherstellen, dass beide Entitäten unterschiedliche Werte haben

   7:     var columnMappings = GetColumnMappings(type, false);

   8:  

   9:     var primaryKeyName = GetPrimaryKeyName(tableName);

  10:     PropertyInfo primaryKeyProperty = null;

  11:  

  12:     foreach (var currentColumnMapping in columnMappings)

  13:     {

  14:         var property = currentColumnMapping.Key;

  15:         var columnName = currentColumnMapping.Value;

  16:  

  17:         var firstValue = property.GetValue(firstItem, null);

  18:         var secondValue = property.GetValue(secondItem, null);

  19:  

  20:         if (columnName == primaryKeyName)

  21:         {

  22:             // Primärschlüssel ermitteln

  23:             primaryKeyProperty = property;

  24:         }

  25:         else if (firstValue == secondValue)

  26:         {

  27:             // Alle Properties bis auf den Primärschlüssel müssen unterschiedlich sein

  28:             throw new InvalidOperationException("Die Eingangsdaten müssen unterschiedlich sein");

  29:         }

  30:     }

  31:  

  32:     if (primaryKeyProperty == null)

  33:     {

  34:         throw new InvalidOperationException("Der Primärschlüssel konnte nicht ermittelt werden");

  35:     }

  36:  

  37:     var primaryKeyValue = 0;

  38:  

  39:     try

  40:     {

  41:         // Die erste Entität wird in die Datenbank geschrieben.

  42:         primaryKeyValue = DirectInsert(firstItem);

  43:  

  44:         if (primaryKeyValue <= 0)

  45:         {

  46:             throw new InvalidOperationException("Der Primärschlüssel des erzeugten Datensatzes muss größer als 0 sein");

  47:         }

  48:  

  49:         // Beide Objekte erhalten den Primärschlüssel des eingefügten Datensatzes.

  50:         primaryKeyProperty.SetValue(firstItem, primaryKeyValue, null);

  51:         primaryKeyProperty.SetValue(secondItem, primaryKeyValue, null);

  52:  

  53:         // Erzeugen der Testkomponente

  54:         var dataAccess = new DataAccess();

  55:  

  56:         // Aufrufen der zu testenden Methode

  57:         dataAccess.Update(secondItem);

  58:  

  59:         // Es gilt zu testen, ob alle schreibbaren Werte der ersten Entität durch

  60:         // die Werte der zweiten Entität ersetzt wurden.

  61:         using (var reader = DirectSelect(type, primaryKeyValue))

  62:         {

  63:             Assert.IsTrue(reader.Read());

  64:  

  65:             foreach (var currentColumnMapping in columnMappings)

  66:             {

  67:                 var property = currentColumnMapping.Key;

  68:                 var columnName = currentColumnMapping.Value;

  69:  

  70:                 var expectedValue = property.GetValue(secondItem, null);

  71:                 var actualValue = reader[columnName] != DBNull.Value ? reader[columnName] : null;

  72:  

  73:                 // Alle ausgelesenen Werte müssen mit den Werten der zweiten Entität identisch

  74:                 // sein, die sich allesamt von den Werten der ersten Entität unterscheiden, was

  75:                 // zuvor geprüft wurde. Wenn das der Fall ist, wurden über die Stored Procedure

  76:                 // alle schreibbaren Werte ersetzt und der Test wird erfolgreich durchlaufen.

  77:                 Assert.AreEqual(expectedValue, actualValue);

  78:             }

  79:         }

  80:     }

  81:     finally

  82:     {

  83:         // Löschen, was sich löschen lässt.

  84:         if (primaryKeyValue > 0)

  85:         {
  87:         }

  86:             DirectDelete(type, primaryKeyValue);

  88:     }

  89: }

Wie man sieht, gibt es innerhalb der Methode DoUpdateTest() keinen direkten Bezug zu der Klasse Product. Alle benötigten Informationen lassen sich aus den zuvor festgelegten Namenskonventionen und den Metadaten, mit denen die Klasse angereichert wurde, ermitteln. Für weitere, nach dem gleichen Prinzip aufgebaute Klassen, kann daher die gesamte Testlogik einfach übernommen werden.

Normalerweise ist es nicht üblich, in einer Testmethode auch die Gültigkeit der Testdaten zu prüfen, wie es im obigen Beispiel gemacht wurde. In der Praxis haben sich solche Selbsttests aber als sehr nützlich erwiesen, da einer der häufigsten Fehler in der Bereitstellung unvollständiger Testdaten bestand. Dies passiert erfahrungsgemäß besonders oft beim Hinzufügen neuer Properties mit einem Default-Wert, also bei fast allen Properties, die hinzugefügt werden. In einer realen Anwendung kann es aber sinnvoll sein, die Selbsttests von den eigentlichen Testmethoden zu trennen.

Fazit

Der Vorgabe, Datenbankzugriffe grundsätzlich nur über Stored Procedures abzuwickeln, muss nicht im Widerspruch zu dem Anspruch, eine möglichst große Testabdeckung zu erzielen, stehen. Mit dem in dieser Artikelserie vorgestellten Weg lässt sich der Datenzugriff über die Stored Procedures so vereinheitlichen, dass man nicht mehr mit unzähligen, fragmentierten Tests zu kämpfen hat. Am besten ist, wenn man überhaupt nicht in die Verlegenheit kommt, in großem Stil Copy-and-Paste zu betreiben.

Da sich die Testlogik beliebig oft wiederverwenden lässt, lohnt es sich, diese zu verbessern. Die Erfahrung in der Praxis zeigt, dass durch Erweiterungen und Korrekturen der gemeinsam genutzten Testlogik nicht selten Fehler in bereits ausreichend getestet geglaubten Programmteilen finden.

Natürlich lässt sich auf diese Weise nicht jedes erdenkliche Szenario bewältigen. Insbesondere bei komplexen Abhängigkeiten zwischen den Entitäten und bei einer Verlagerung von erheblichen Teilen der Business-Logik in die Datenbank bzw. die Stored Procedures, stößt das Prinzip an seine Grenzen. Doch auch in solchen Anwendungen gibt es fast immer auch noch eine Vielzahl von klassischen Datenentitäten, für die sich die vorgestellte Methodik wiederum hervorragend eignet.