Thomas Bandt

Über mich | Kontakt | Archiv

Repositories testen

Neulich bin ich nach langem Grübeln und einigem Hin- und Her für mich zu dem Schluss gekommen, dass Repositories für mich in den allermeisten Fällen nicht testwürdig sind. Ganz glücklich war ich mit diesem Ergebnis nicht, aber irgendwann muss man ja auch einmal einen Platz im Dorf für die Kirche finden, um sie dann da stehen zu lassen – zumindest für das aktuelle Projekt. In den Kommentaren regte sich Widerspruch, insbesondere Stefan brachte mich dann auf die „richtige Spur“.

Das Grundproblem war meine Annahme, dass Unittests für den von mir verwendeten OR-Mapper Linq to Sql zu kompliziert sind (weil zu viel weggemockt werden muss, was insbesondere bei aufwendigeren Statements fast unmöglich wird – d.h. diese Annahme stimmt) und dass Integrationstests viel Mühe machen und Zeit kosten, da eine Test-Datenbank parallel zur „Live-Datenbank“ mitgepflegt und mit Testdatensätzen gefüllt werden will.

Das ist aber gar nicht notwendig. Denn auch wenn Linq to Sql häufig als der kümmerliche kleine Stiefbruder des Entity Frameworks daherkommt, eines geht damit problemlos: eine Datenbank aus dem vorhandenen Schema erstellen und löschen.

Damit ist das Grundproblem schon einmal weggefallen: die Pflege einer Testdatenbank ist nicht notwendig. Diese wird einfach vor dem Test dynamisch erzeugt und nachher wieder gelöscht.

Das verursacht keinen Mehraufwand auf der Administrationsseite. Denn fügt man ein Feld, eine Tabelle oder ein sonstiges Objekt in der Datenbank hinzu, löscht oder ändert es, muss man das Schema nur einmalig aktualisieren, in Form der *.dbml-Datei, die man ja sowieso pflegen muss.

Ändern sich dabei auch testrelevante Objekte, muss man diese für die Tests nicht etwa mit T-SQL-ALTER-Scripts nachziehen – Resharper, der Compiler oder spätestens der Testrunner melden schon, wo es zwickt.

Das Interface

Das öffentliche Interface, gegen das die Nutzer des Repositories arbeiten, bleibt unverändert:

   1:  public interface ICompanyRepository
   2:  {
   3:      void CreateCompany(Company source);
   4:      Company GetCompanyByName(Guid clientID, string name);
   5:  }

Die Implementierung

Intern ist eine kleine Erweiterung in Form einer Basisklasse nötig, die die zum Testen benötigte Infrastruktur bereitstellt:

   1:  public abstract class RepositoryBase<TDomainModel, TLinqToSqlModel>
   2:  {
   3:      protected RepositoryBase()
   4:      {
   5:          var config = ObjectFactory.GetInstance<IConfigurationService>();
   6:          Database = new DatabaseDataContext(config[ConfigKey.DatabaseConnectionString]);
   7:      }
   8:      public DatabaseDataContext Database { get; private set; }
   9:      public abstract TDomainModel Map(TLinqToSqlModel source);
  10:  }

Den Connectionstring stelle ich in diesem Fall über einen Konfigurations-Dienst bereit, das ist aber für das eigentliche Thema unerheblich und kann daher ignoriert und anders gelöst werden. Relevant ist die Property Database und die Methode Map();

Database ist nötig um nach außen hin den aktuellen Linq-to-Sql-DataContext bereitstellen zu können, später sieht man dann wozu. Map() dient dazu später das Mapping der Linq-to-Sql-Objekte auf die eigenen Objekte leichter testen zu können.

Die konkrete Implementierung sieht aus wie folgt:

   1:  public class CompanyRepository : RepositoryBase<Model.Company, Company>, 
   2:      ICompanyRepository
   3:   
   4:  {
   5:   
   6:      public void CreateCompany(Model.Company source)
   7:      {
   8:          var company = new Company();
   9:          company.ID = source.ID;
  10:          company.Created = source.Created;
  11:          company.FKClientID = source.ClientID;
  12:          company.Language = (int)source.Language;
  13:          company.Name = source.Name;
  14:          company.TimeZone = source.TimeZoneID;
  15:          Database.Companies.InsertOnSubmit(company);
  16:          Database.SubmitChanges();
  17:      }
  18:   
  19:      public Model.Company GetCompanyByName(Guid clientID, string name)
  20:      {
  21:          return Map(Database.Companies.SingleOrDefault(c => c.FKClientID == clientID 
  22:              && c.Name.Trim().ToLower() == name.Trim().ToLower()));
  23:   
  24:      }
  25:   
  26:      public override Model.Company Map(Company source)
  27:   
  28:      {
  29:   
  30:          if (source == null)
  31:   
  32:              return null;
  33:   
  34:          var company = new Model.Company();
  35:          company.ClientID = source.FKClientID;
  36:          company.Created = source.Created;
  37:          company.ID = source.ID;
  38:          company.Language = (Model.Language)source.Language;
  39:          company.Name = source.Name;
  40:          company.TimeZoneID = source.TimeZone;
  41:   
  42:          return company;
  43:   
  44:      }
  45:   
  46:  }

Im Prinzip also keine Änderung bis auf die veränderte Basisklasse. Nur testbar ist das ganze jetzt geworden.

Die Tests

Nun zum eigentlichen Punkt – den Tests. Um diese durchführen zu können muss, wie eingangs geschrieben, im Prinzip nur vor jedem Test oder Testdurchlauf die Datenbank erzeugt werden, was mit einem Einzeiler getan wäre.

Ich habe nach einigen Versuchen auch hier für die Testklassen eine Basisklasse geschrieben, die die notwendige Arbeit übernimmt, ohne dass ich sie für jede Testklasse wiederholen müsste:

   1:  public abstract class ConcernOfSqlServerRepository<TSut, TDomainModel, TLinqToSqlModel>
   2:  {
   3:   
   4:      protected ConcernOfSqlServerRepository()
   5:      {
   6:          var config = MockRepository.DynamicMock<IConfigurationService>();
   7:          using (MockRepository.Record())
   8:          {
   9:              config.Expect(c => c[ConfigKey.DatabaseConnectionString]).
  10:                  Return(ConfigurationManager.ConnectionStrings["TestConnection"].
  11:                  ConnectionString).Repeat.Any();
  12:          }
  13:          ObjectFactory.Configure(c => c.For<IConfigurationService>().Use(config));
  14:      }
  15:   
  16:      public TSut Sut { get; set; }
  17:   
  18:      private MockRepository mockRepository;
  19:      public MockRepository MockRepository
  20:      {
  21:          get { return mockRepository ?? (mockRepository = new MockRepository()); }
  22:      }
  23:          
  24:      public void InitializeDatabase()
  25:      {
  26:          var sut = Sut as RepositoryBase<TDomainModel, TLinqToSqlModel>;
  27:   
  28:          if (sut.Database.DatabaseExists())
  29:              sut.Database.DeleteDatabase();
  30:   
  31:          if (sut.Database.DatabaseExists())
  32:              throw new Exception("Die Testdatenbank konnte zur Initialisierung nicht gelöscht werden.");
  33:   
  34:          sut.Database.CreateDatabase();
  35:   
  36:          if (!sut.Database.DatabaseExists())
  37:              throw new Exception("Die Testdatenbank konnte nicht erzeugt werden.");
  38:      }
  39:   
  40:      [TestFixtureTearDown]
  41:      public void CleanUpDatabase()
  42:      {
  43:              
  44:          var sut = Sut as RepositoryBase<TDomainModel, TLinqToSqlModel>;
  45:   
  46:          if (!sut.Database.DatabaseExists())
  47:              return;
  48:   
  49:          sut.Database.DeleteDatabase();
  50:   
  51:          if (sut.Database.DatabaseExists())
  52:              throw new Exception("Die Testdatenbank konnte nicht gelöscht werden.");
  53:   
  54:      }
  55:   
  56:      [TestFixtureSetUp]
  57:      public abstract void FixtureSetUp();
  58:   
  59:  }

Interessant sind die beiden Methoden InitializeDatabase() und CleanUpDatabase(). Nur die letztere wird von nunit automatisch nach jedem Testlauf aufgerufen, was Gründe hat.

Zum einen habe ich mich dafür entschieden die Datenbank jeweils für eine komplette Testklasse nur einmal zu erzeugen und anschließend wieder zu löschen, da das Erzeugen und Löschen für jeden einzelnen Test einfach zu viel Zeit in Anspruch nehmen würde. Dies muss man beim Erstellen der Tests natürlich beachten.

Zum anderen ist es nicht in jedem Fall notwendig wirklich die Datenbank zu initialisieren, weshalb InitializeDatabase() beim Setup (FixtureSetup()) manuell aufgerufen werden muss – sofern die Datenbank eben benötigt wird.

In beiden Methoden wird zudem deutlich, warum die Basisklasse der Repositories die Property Database enthält – so kann hier leicht auf den aktuellen DataContext zugegriffen werden.

Im Anschluss noch Beispiele für das Testen dreier Standard-Szenarien.

1. Das Mapping

Ich verwende grundsätzlich nicht die vom Designer generierten Linq-to-Sql-Klassen in meiner Anwendung, außer innerhalb der Repositories für den reinen Datenzugriff. Das hat einfach den Grund, dass ich unabhängig bleiben möchte – so kann ich Linq to Sql z.B. jederzeit wegwerfen und mit ADO.NET, nHibernate oder Weißderkuckuck ersetzen.

Weil ich dies so handhabe, muss aber das Mapping der Linq-to-Sql-Objekte auf meine eigenen Objekte erfolgen. Im Moment z.B. aufgrund der geringen Komplexität per Hand, alternativ auch per AutoMapper.

   1:  #region Wenn eine Firma gemappt wird
   2:   
   3:  [TestFixture]
   4:  [Category("Blubr.Domain.Data.SqlServer.CompanyRepository")]
   5:  public class Wenn_eine_Firma_gemappt_wird : ConcernOfSqlServerRepository<CompanyRepository, Model.Company, Company>
   6:  {
   7:          
   8:      public override void FixtureSetUp()
   9:      {
  10:          Sut = new CompanyRepository();
  11:      }
  12:   
  13:      [Test]
  14:      public void Wird_die_Firma_zurückgegeben_sofern_die_Quelle_nicht_null_ist()
  15:      {
  16:          var result = Sut.Map(new Company());
  17:          Assert.IsNotNull(result);
  18:      }
  19:   
  20:      [Test]
  21:      public void Wird_null_zurückgegeben_wenn_die_Quelle_null_ist()
  22:      {
  23:          var result = Sut.Map(null);
  24:          Assert.IsNull(result);
  25:      }
  26:   
  27:      [Test]
  28:      public void Wird_die_ClientID_gemappt()
  29:      {
  30:          var source = new Company { FKClientID = Guid.NewGuid() };
  31:          var result = Sut.Map(source);
  32:          Assert.AreEqual(source.FKClientID, result.ClientID);
  33:      }
  34:   
  35:      [Test]
  36:      public void Wird_das_Erstellungsdatum_gemappt()
  37:      {
  38:          var source = new Company { Created = DateTime.Now };
  39:          var result = Sut.Map(source);
  40:          Assert.AreEqual(source.Created, result.Created);
  41:      }
  42:   
  43:      [Test]
  44:      public void Wird_die_ID_gemappt()
  45:      {
  46:          var source = new Company { ID = Guid.NewGuid() };
  47:          var result = Sut.Map(source);
  48:          Assert.AreEqual(source.ID, result.ID);
  49:      }
  50:   
  51:      [Test]
  52:      public void Wird_die_Sprache_gemappt()
  53:      {
  54:          var source = new Company { Language = 2 }; // 2 = Englisch
  55:          var result = Sut.Map(source);
  56:          Assert.AreEqual(Language.English, result.Language);
  57:      }
  58:   
  59:      [Test]
  60:      public void Wird_der_Name_gemappt()
  61:      {
  62:          var result = Sut.Map(new Company { Name = "Fake Company" });
  63:          Assert.AreEqual("Fake Company", result.Name);
  64:      }
  65:   
  66:      [Test]
  67:      public void Wird_die_Zeitzone_gemappt()
  68:      {
  69:          var result = Sut.Map(new Company { TimeZone = 24 });
  70:          Assert.AreEqual(24, result.TimeZoneID);
  71:      }
  72:   
  73:  }
  74:   
  75:  #endregion

Hier sieht man nun, wozu die Map()-Methode in der Basisklasse der Repositories gut ist. Anhand dieser Methode kann man für jedes Repository das Mapping behandeln und auch ordentlich testen.

2. Das Abrufen eines Objektes

   1:  #region Wenn eine Firma anhand ihres Namens abgerufen wird
   2:   
   3:  [TestFixture]
   4:  [Category("Blubr.Domain.Data.SqlServer.CompanyRepository")]
   5:  public class Wenn_eine_Firma_anhand_ihres_Namens_abgerufen_wird : 
   6:      ConcernOfSqlServerRepository<CompanyRepository, Model.Company, Company>
   7:  {
   8:   
   9:      private Guid TestCompanyID;
  10:      private Guid TestCompanyClientID;
  11:   
  12:      public override void FixtureSetUp()
  13:      {
  14:   
  15:          Sut = new CompanyRepository();
  16:          InitializeDatabase();
  17:   
  18:          TestCompanyID = Guid.NewGuid();
  19:          TestCompanyClientID = Guid.NewGuid();
  20:   
  21:          CreateTestData();
  22:   
  23:      }
  24:   
  25:      public void CreateTestData()
  26:      {
  27:              
  28:          var company = new Company();
  29:          company.ID = TestCompanyID;
  30:          company.Name = "Test Company";
  31:          company.Created = DateTime.Now;
  32:          company.FKClientID = TestCompanyClientID;
  33:          company.Language = 1;
  34:          company.TimeZone = 1;
  35:   
  36:          Sut.Database.Companies.InsertOnSubmit(company);
  37:          Sut.Database.SubmitChanges();
  38:   
  39:      }
  40:   
  41:      [Test]
  42:      public void Wird_diese_auch_gefunden_wenn_der_Name_mit_Leerzeichen_beginnt()
  43:      {
  44:          var result = Sut.GetCompanyByName(TestCompanyClientID, " Test Company");
  45:          Assert.AreEqual(TestCompanyID, result.ID);
  46:      }
  47:   
  48:      [Test]
  49:      public void Wird_diese_auch_gefunden_wenn_der_Name_mit_Leerzeichen_endet()
  50:      {
  51:          var result = Sut.GetCompanyByName(TestCompanyClientID, "Test Company ");
  52:          Assert.AreEqual(TestCompanyID, result.ID);
  53:      }
  54:   
  55:      [Test]
  56:      public void Wird_diese_auch_gefunden_wenn_die_Großkleinschreibung_nicht_stimmt()
  57:      {
  58:          var result = Sut.GetCompanyByName(TestCompanyClientID, "Test COMpaNy");
  59:          Assert.AreEqual(TestCompanyID, result.ID);
  60:      }
  61:   
  62:      [Test]
  63:      public void Ist_das_Ergebnis_null_wenn_sie_nicht_gefunden_wird()
  64:      {
  65:          var result = Sut.GetCompanyByName(TestCompanyID, "invalid");
  66:          Assert.IsNull(result);
  67:      }
  68:   
  69:  }
  70:   
  71:  #endregion

Hier wird noch einmal das Prinzip deutlich: vor dem Durchlauf wird die Datenbank erstellt und es werden anschließend Testdaten in die zu testenden Objekte/Tabellen gefüllt (typisiert und ohne T-SQL-Gefrickel ...), anschließend wird gegen diese Daten getestet und zu guter Letzt wird die gesamte Datenbank wieder gelöscht.

3. Das Erstellen eines Objekts

   1:  #region Wenn eine neue Firma angelegt wird
   2:   
   3:  [TestFixture]
   4:  [Category("Blubr.Domain.Data.SqlServer.CompanyRepository")]
   5:  public class Wenn_eine_neue_Firma_angelegt_wird : 
   6:      ConcernOfSqlServerRepository<CompanyRepository, Model.Company, Company>
   7:  {
   8:   
   9:      private Guid TestCompanyClientID;
  10:   
  11:      public override void FixtureSetUp()
  12:      {
  13:          Sut = new CompanyRepository();
  14:          InitializeDatabase();
  15:          TestCompanyClientID = Guid.NewGuid();
  16:      }
  17:   
  18:      private Model.Company GetValidTestCompany()
  19:      {
  20:          var company = new Model.Company();
  21:          company.Created = new DateTime(2010, 4, 28);
  22:          company.ClientID = TestCompanyClientID;
  23:          company.ID = Guid.NewGuid();
  24:          company.Language = Language.German;
  25:          company.Name = "Fake Company";
  26:          company.TimeZoneID = 24;
  27:          return company;
  28:      }
  29:   
  30:      [Test]
  31:      public void Wird_die_ID_gespeichert()
  32:      {
  33:          var testCompany = GetValidTestCompany();
  34:          Sut.CreateCompany(testCompany);
  35:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  36:          Assert.AreEqual(testCompany.ID, result.ID);
  37:      }
  38:   
  39:      [Test]
  40:      public void Wird_das_Erstellungsdatum_gespeichert()
  41:      {
  42:          var testCompany = GetValidTestCompany();
  43:          Sut.CreateCompany(testCompany);
  44:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  45:          Assert.AreEqual(new DateTime(2010, 4, 28), result.Created);
  46:      }
  47:   
  48:      [Test]
  49:      public void Wird_die_ClientID_gespeichert()
  50:      {
  51:          var testCompany = GetValidTestCompany();
  52:          Sut.CreateCompany(testCompany);
  53:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  54:          Assert.AreEqual(TestCompanyClientID, result.FKClientID);
  55:      }
  56:   
  57:      [Test]
  58:      public void Wird_die_Sprache_gespeichert()
  59:      {
  60:          var testCompany = GetValidTestCompany();
  61:          Sut.CreateCompany(testCompany);
  62:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  63:          Assert.AreEqual(1, result.Language);
  64:      }
  65:   
  66:      [Test]
  67:      public void Wird_der_Name_gespeichert()
  68:      {
  69:          var testCompany = GetValidTestCompany();
  70:          Sut.CreateCompany(testCompany);
  71:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  72:          Assert.AreEqual("Fake Company", result.Name);
  73:      }
  74:   
  75:      [Test]
  76:      public void Wird_die_Zeitzone_gespeichert()
  77:      {
  78:          var testCompany = GetValidTestCompany();
  79:          Sut.CreateCompany(testCompany);
  80:          var result = Sut.Database.Companies.Single(c => c.ID == testCompany.ID);
  81:          Assert.AreEqual(24, result.TimeZone);
  82:      }
  83:   
  84:  }
  85:   
  86:  #endregion

Fazit:

Vor einer Woche bin ich noch zu dem Schluss gekommen, dass das Testen von Repositories verschwendete Lebenszeit sei. Daher möchte ich hier ausdrücklich darauf hinweisen, dass ich dieses Vorgehen aktuell nicht für der Weisheit letzter Schluss, sondern eher als Proof of Concept ansehe.

Ich habe mit dieser Vorgehensweise in den vergangenen Tagen gute Ergebnisse erzielt und konnte so auch meine bestehenden Repositories im Nachhinein relativ effizient mit Tests abdecken sowie Erweiterungen an den Repositories ebenfalls gefühlt sehr effizient test-first entwickeln.

Die Lösung zeigt, dass man auch mit dem „kleinen“ Linq to Sql problemlos Integrationstests für de Datenzugriff schreiben kann, ohne dabei auch nur eine einzige Zeile T-SQL tippen oder das SQL Server Management Studio öffnen zu müssen. Voraussetzung ist lediglich ein laufender SQL Server, auf dem die Datenbank erzeugt werden kann.

HTH

Kommentare

  1. Stefan Lieser schrieb am Montag, 3. Mai 2010 09:05:00 Uhr:

    Hi Thomas,

    schön, dass es mit deinen Tests vorangeht! Noch zwei Tipps: AutoMapper macht das Leben leichter ;-) Selbst wenn es nur wenige Eigenschaften sind, du ersparst dir durch die Konventionen das Tippen.

    Wichtiger finde ich aber die Vorgehensweise von Fluent NHibernate. Da gibt es eine Klasse PersistenceSpecification. Die solltest du dir mal anschauen und "abkupfern". Sollte nicht sooo schwer sein, das auf LIQ to SQL zu übertragen.

    Viele Grüße
    Stefan
  2. Thomas schrieb am Montag, 3. Mai 2010 10:20:00 Uhr:

    Habe mir PersistenceSpecification mal kurz angesehen (http://wiki.fluentnhibernate.org/Persistence_specification_testing) - schick, schick. Das wäre dann wohl eher mal ein Anlass doch über die Nutzung von nHibernate statt L2S nachzudenken.


« Zurück  |  Weiter »