Testen von Datenbanken – 1 Einführung

24. Februar 2015

Meiner Meinung nach macht es definitiv Sinn, bestimmten Code einer Datenbank zu testen. Ich konzentriere mich dabei eher auf komplexe Verarbeitungen. Einfache CRUD Methoden mit Tests abzudecken schlägt sich meist in steigendem Aufwand nieder. In nachfolgenden Kapiteln möchte ich nun einige Möglichkeiten vorstellen, wie man einen Datenbanktest erstellen könnte.

Ich werde Begriffe wie Systemtest oder Integrationstest vermeiden und von Testfällen und Testszenarien sprechen. Ein Testfall ist ein atomarer Test mit Eingangsdaten, Verarbeitung und einem erwarteten und zu prüfenden Ergebnis. Als Testszenario bezeichne ich mehre Testfälle, die logisch zusammen gehören. Also beispielsweise die gleichen Eingangsdaten verwenden.

Meine Testfälle sind

  • technisch voneinander unabhängig,
  • nutzen eine Verbindung zu einer Datenbank,
  • lesen und schreiben Dateien

und sind sehr wahrscheinlich nicht nach 200ms fertig. Ausführbar sind sie über die Visual Studio Testtools oder auch NUnit und zum Schluss ist jeder einzelne rot oder grün.

Warum existiert komplexer Code in der Datenbank?

Ich verwende gern einfache CRUD Methoden, aber hin und wieder müssen Daten nur von einer Tabelle in eine andere Tabelle kopiert, gruppiert, erweitert (Bsp.: mit technischen IDs), etc. werden. Hier würde ich immer eine etwas komplexere Verarbeitung in der Datenbank als in einem darüber liegendem Layer bevorzugen.

Aber auch aus der Historie der Anwendung heraus können komplexe Datenbankverarbeitungen existieren. Hier lohnen sich Datenbanktest ganz besonders. Man kann so nicht nur seine Weiterentwicklung absichern, sondern auch eher ein Migration und Überarbeitung angehen. Wer weiß schon ohne Tests ob der neue Code zum gleichen Ergebnis führt?

Migration

Referenzanwendung

Als Basis für meine Erläuterungen habe ich mich für eine relativ einfache Verarbeitung entschieden. Mein .NET DataAcessLayer ruft eine Stored Procedure im MS SQL Server auf. Dabei sollen Daten aus der Tabelle [dbo].[Source] in die Tabellen [dbo].[Target_Code] und [dbo].[Target_Sum] überführt werden. Sie werden nach [Code] gruppiert und mit ein technischer Schlüssel über [dbo].[Target_Code] versehen, der ggf. auch neu erzeugt werden muss. Die Summen pro Code Schlüssel und Datum werden in [dbo].[Target_Sum] gespeichert.

Die später zu testende Datenbank:

DB Diagramm

   1: CREATE PROCEDURE [dbo].[Delete_Target_Sum_ByCalcDate] 

   2:     @CalcDate AS DATETIME

   3: AS

   4: BEGIN

   5:     SET NOCOUNT ON;

   6:  

   7:     DELETE FROM dbo.Target_Sum WHERE CalcDate = @CalcDate;

   8: END

   1: CREATE PROCEDURE [dbo].[Insert_Target_ByCalcDate] 

   2:     @CalcDate AS DATETIME

   3: AS

   4: BEGIN

   5:     SET NOCOUNT ON;

   6:  

   7:     WITH Source_Code AS

   8:     (

   9:         SELECT 

  10:             DISTINCT Code

  11:         FROM dbo.Source

  12:         WHERE CalcDate = @CalcDate

  13:     )

  14:     INSERT INTO dbo.Target_Code

  15:     (

  16:         Code

  17:     )

  18:     SELECT 

  19:         Code

  20:     FROM Source_Code

  21:     WHERE Code not in (SELECT Code FROM dbo.Target_Code);

  22:  

  23:     WITH Source_Sum AS

  24:     (

  25:         SELECT 

  26:             Code,

  27:             SUM(ISNULL(Value,0.0)) as Value_Sum

  28:         FROM dbo.Source

  29:         WHERE CalcDate = @CalcDate

  30:         GROUP BY Code

  31:     )

  32:     INSERT INTO dbo.Target_Sum 

  33:     ( 

  34:         CalcDate,

  35:         ID,

  36:         Value

  37:     )

  38:     SELECT 

  39:         @CalcDate, 

  40:         C.ID, 

  41:         S.Value_Sum

  42:     FROM Source_Sum AS S

  43:     INNER JOIN dbo.Target_Code C

  44:     ON S.Code = C.Code;

  45:  

  46:  

  47: END

und ihre Zugriffsschicht

   1: public class DBManagerTarget

   2: {

   3: public void DeleteTargetSum(DateTime calcDate)

   4: {

   5:     ExecSql((SqlCommand cmd) => FillCommand(cmd, "dbo.Delete_Target_Sum_ByCalcDate", calcDate));

   6: }

   7:  

   8: public void FillTarget(DateTime calcDate)

   9: {

  10:     ExecSql((SqlCommand cmd) => FillCommand(cmd, "dbo.Insert_Target_ByCalcDate", calcDate));

  11: }

  12:  

  13: private void FillCommand(SqlCommand cmd, string spName, DateTime calcDate)

  14: {

  15:     cmd.CommandType = System.Data.CommandType.StoredProcedure;

  16:     cmd.CommandText = spName;

  17:  

  18:     SqlParameter parCalcDate = cmd.CreateParameter();

  19:     parCalcDate.ParameterName = "@CalcDate";

  20:     parCalcDate.DbType = System.Data.DbType.DateTime;

  21:     parCalcDate.SqlValue = calcDate.Date.ToString("yyyy-MM-dd HH:mm:ss");

  22:     cmd.Parameters.Add(parCalcDate);

  23: }

  24:  

  25: public void ExecSql(Action<SqlCommand> fillCommand)

  26: {

  27:     try

  28:     {

  29:         string connectionString = ConfigurationManager.ConnectionStrings["DB"].ConnectionString;

  30:         using (SqlConnection con = new SqlConnection(connectionString))

  31:         {

  32:             con.Open();

  33:             using (SqlCommand cmd = con.CreateCommand())

  34:             {

  35:                 fillCommand(cmd);

  36:                 cmd.ExecuteNonQuery();

  37:             }

  38:         }

  39:     }

  40:     catch (Exception ex)

  41:     {

  42:         //TODO

  43:         throw;

  44:     }

  45: }

  46: }

Testhilfen

Was wäre ein Test ohne eine entsprechende Prüfung? Um mir hier das Leben zu vereinfachen habe ich diese Prüfung in ein eigenes Assembly ausgelagert. Dazu gehören:

  • Dateien mit SQL Befehlen im Test gegen die Datenbank ausführen
  • Speichern von Ergebnissen eines SQL Befehls in einer Datei
  • Einfacher Dateienvergleich.
   1: public class DBManager

   2: {

   3:     public string ConnectionStringKey { get; private set; }

   4:     public bool AssertTransactionContext { get; private set; }

   5:  

   6:     public DBManager(string connectionStringKey, bool assertTransactionContext)

   7:     {

   8:         ConnectionStringKey = connectionStringKey;

   9:         AssertTransactionContext = assertTransactionContext;

  10:     }

  11:  

  12:     public void ExecFile(string filePath)

  13:     {

  14:         string sql = File.ReadAllText(filePath);

  15:         ExecCommand(sql);

  16:     }

  17:  

  18:     public void ExecCommand(string cmdText)

  19:     {

  20:         try

  21:         {

  22:             string connectionString = ConfigurationManager.ConnectionStrings[ConnectionStringKey].ConnectionString;

  23:             using (SqlConnection con = new SqlConnection(connectionString))

  24:             {

  25:                 if (AssertTransactionContext && Transaction.Current == null)

  26:                     throw new ArgumentException("nicht ohne Transaction!");

  27:  

  28:                 con.Open();

  29:                 //con.EnlistTransaction(transaction);

  30:                 /* alternative für GO

  31:                         Server db = new Server(new ServerConnection(conn));

  32:                         string script = File.ReadAllText(scriptPath);

  33:                         db.ConnectionContext.ExecuteNonQuery(script); 

  34:                 */

  35:  

  36:                 foreach (string cmd in cmdText.Split(new string[] { "GO" }, StringSplitOptions.RemoveEmptyEntries))

  37:                 {

  38:                     using (SqlCommand sqlCmd = new SqlCommand())

  39:                     {

  40:                         sqlCmd.Connection = con;

  41:  

  42:  

  43:                         sqlCmd.CommandType = System.Data.CommandType.Text;

  44:                         sqlCmd.CommandText = cmd;

  45:  

  46:                         sqlCmd.ExecuteNonQuery();

  47:                     }

  48:                 }

  49:  

  50:  

  51:                 con.Close();

  52:             }

  53:         }

  54:         catch (Exception ex)

  55:         {

  56:             //TODO

  57:             throw;

  58:         }

  59:     }

  60:         

  61:     public void ReadToFile(string filePath, string cmdText)

  62:     {

  63:         try

  64:         {

  65:             string connectionString = ConfigurationManager.ConnectionStrings[ConnectionStringKey].ConnectionString;

  66:             using (SqlConnection con = new SqlConnection(connectionString))

  67:             {

  68:                 if (AssertTransactionContext && Transaction.Current == null)

  69:                     throw new ArgumentException("nicht ohne Transaction!");

  70:  

  71:                 con.Open();

  72:                 using (SqlCommand sqlCmd = new SqlCommand())

  73:                 {

  74:                     sqlCmd.Connection = con;

  75:  

  76:  

  77:                     sqlCmd.CommandType = System.Data.CommandType.Text;

  78:                     sqlCmd.CommandText = cmdText;

  79:  

  80:                     using (SqlDataReader reader = sqlCmd.ExecuteReader())

  81:                     {

  82:                         File.Delete(filePath); //könnte auch Folder mit Timestamp erzeugen!

  83:                         using (FileStream stream = new FileStream(filePath, FileMode.CreateNew))

  84:                         {

  85:                             using (StreamWriter writer = new StreamWriter(stream, Encoding.UTF8))

  86:                             {

  87:                                 writer.WriteLine(cmdText);

  88:                                 writer.WriteLine("");

  89:                                 writer.WriteLine(GetColumnInfo(reader));

  90:                                 writer.WriteLine("");

  91:  

  92:                                 while (reader.Read())

  93:                                 {

  94:                                     writer.WriteLine(GetData(reader));

  95:                                 }

  96:                             }

  97:                         }

  98:                     }

  99:                 }

 100:  

 101:                 con.Close();

 102:             }

 103:         }

 104:         catch (Exception ex)

 105:         {

 106:             //TODO

 107:             throw;

 108:         }

 109:     }

 110:  

 111:     private string GetColumnInfo(SqlDataReader reader)

 112:     {

 113:         StringBuilder sb = new StringBuilder();

 114:         for (int i = 0; i < reader.FieldCount; i++)

 115:         {                

 116:             if(i > 0)

 117:                 sb.Append(",");

 118:  

 119:             sb.AppendFormat("{0}({1})", reader.GetName(i), reader.GetDataTypeName(i));

 120:         }

 121:  

 122:         return sb.ToString();

 123:     }

 124:  

 125:     private string GetData(SqlDataReader reader)

 126:     {

 127:         StringBuilder sb = new StringBuilder();

 128:         for (int i = 0; i < reader.FieldCount; i++)

 129:         {

 130:             if (i > 0)

 131:                 sb.Append(",");

 132:  

 133:             sb.AppendFormat("{0}", reader.GetValue(i));

 134:         }

 135:  

 136:         return sb.ToString();

 137:     }

 138: }

   1: public static class CompareHelper

   2: {

   3:     public static void AssertAreEual(string expectedFilePath, string actualFilePath)

   4:     {

   5:         string expectedContent = File.ReadAllText(expectedFilePath,Encoding.UTF8);

   6:         string actualContent = File.ReadAllText(actualFilePath, Encoding.UTF8);

   7:         Assert.AreEqual(expectedContent, actualContent);

   8:     }

   9: }

Testfall

Die Tabelle [dbo].[Source] enthält folgende Daten:

1 - 7 Input

Nach dem Test sollen die Tabelle [dbo].[Target_Code] wie folgt befüllt sein:

1 - 9 output

und die Tabelle [dbo].[Target_Sum] so:

1 - 8 output

Im Test werden das SQL-Statement, die Spaltenstruktur und das Ergebnis in eine Textdatei geschrieben und verglichen. Der erwartete Inhalt der Datei sieht wie folgt aus:

SELECT * FROM dbo.Target_Sum WHERE CalcDate='2014-01-01';

 

CalcDate(datetime),ID(int),Value(float)

 

01.01.2014 00:00:00,1,10

01.01.2014 00:00:00,2,20

01.01.2014 00:00:00,3,30

01.01.2014 00:00:00,4,40

Diese Umgebung wird meine Grundlage sein, um die verschiedenen Vorgehen zu vergleichen. Während Daten, Anwendung und sogar Prüfung der Test gleich bleiben, wird sich lediglich die Methodik  und das Konzept der Test ändern.

zur Übersicht