Work in Progress
Diese Seite ist aktuell im Review! Die Seite wurde noch nicht qualitätsgesichert und kann Fehler enthalten.
Die verlinkten Seiten sind ggf. nur für Schleupen-Mitarbeiter sichtbar.

Testklassen für Unittests erstellen

Dieses Muster soll den Entwurf von Unittest-Klassen dokumentieren. Die resultierenden Klassen sollen einfach wartbar, der Quellcode wiederverwendbar sein. Die Implementierung soll sich einfach auf verschiedene Test-Frameworks portieren lassen (NUnit, Testdriven.NET, xUnit). Außerdem wird beschrieben, wie die Struktur der Test-Assemblys auszusehen hat.

Design

Jede Testklasse besitzt eine untergeordnete Klasse, die die Testumgebung darstellt. Diese wird als Fixture bezeichnet. Das Fixture selber kann weitere durch andere bereitgestellte Fixture-Klassen verwenden. Das Fixture stellt den Zugriff auf die Mocks und die Testdaten bereit.4

Implementierung

Gliederung

Das Resharper-Template Testclass kann zur Erzeugung der Grundstruktur genutzt werden. Soll eine weitere Testmethode erstellt werden, so kann das Resharper Live-Template testclass.and.fixture.2.3 verwendet werden. Die Installation der Templates erfolgt mit dem Import der Resharper-Settings aus dem Framework.

Assembly-Struktur

Die Unittest werden in einer separaten Assembly definiert. Diese heißt so wie die Assembly, aus die die zu testende Klasse stammt zzgl. des Suffixes .Tests. So würden beispielsweise die Unittests einer Assembly mit dem Namen Schleupen.CS.MT.BIB.BusinessCases.dll in einer Assembly mit dem Namen Schleupen.CS.MT.BIB.BusinessCases.UnitTests.dll. Genauso wird mit dem Standardnamensraum der Test-Assembly verfahren. Ist beispielsweise der Standardnamensraum Schleupen.CS.MT.BIB, so muss dieser in der Test-Assembly ebenfalls Schleupen.CS.MT.BIB lauten.

Die Abhängigkeiten der Test-Assemblys entsprechen denen der Produktiv-Assemblys. Die Codeanalyse sollte auch auf der Test-Assembly aktiviert sein, hier sollte aber ggf. ein seperates, reduziertes Regelset zum Einsatz kommen.

Aufbau der Unittests

Die Implementierung erfolgt über eine Testklasse und der Fixture-Klasse. Jede Testklasse besitzt eine Fixture-Klasse, die für die Testklasse die Testumgebung darstellt. In dieser Klasse wird Hilfsfunktionialität bereitgestellt, die für den eigentlichen Test eher ablenkend wären. Der Aufbau des Fixtures ist in Muster Fixture für Testklasse erstellen beschrieben.

Testklasse

Die Testklasse beinhaltet die Testmethoden. Außerdem hält sie eine Referenz auf das Fixture. Damit die Tests voneinander unabhängig sind, sollte die Testumgebung stets neu aufgebaut werden (neues Fixture-Objekt). Dies sollte dann in der Setup()-Methode geschehen (NUnit bietet hierzu das SetUpAttribute an). Für die Fixture-Klasse empfiehlt es sich, diese als nested class in einer partial class in einer anderen Datei zu definieren. Siehe C#-Richtlinien.

namespace Schleupen.CS.MT.BIB;

internal sealed partial class SomeClassTest : IDisposable
{
   private Fixture fixture;

   [SetUp]
   public void Setup()
   {
      fixture = new Fixture(); 
   }
   
   [TearDown]
   public void Dispose()
   {
      fixture?.Dispose();
      fixture = null;
   }

   private sealed class Mocks
   { /* ... */ }

   private sealed class TestData
   { /* ... */ }

   private sealed class Fixture : IDisposable
   {
      public Mocks Mocks { get; private set; }   

      public TestData TestData { get; private set; }   

      public Fixture()
      {
            TestData = new TestData();
            Mocks = new Mocks();
      }
      
      public void Dispose()
      { }
   }
}
Testmethode

Es sollte für jede öffentliche Methode einer Klasse mindestens ein Test erstellt werden, der das Verhalten dieser Methode prüft. Die Testmethode erhält einen aussagekräftigen Namen, anhand dessen man den Testfall erkennen kann. Der Name sollte nach folgendem Schema erstellt werden:

<Name der zu testenden Methode>_<Rahmenbedingungen des Tests>_<Erwartetes Resultat>

public void Attach_WhereNothingIsAttached_ShouldReturnOneItem() { ... }
public void LeiheBuchAus_WhereUserHasNoAushleihen_ShouldReturnOneAusleihe() { ... }

Die Länge der Methodennamen dienen dem Verständnis und der Anzeige im NUnit und sind keine OO-Konstrukt! Daher sind NUR FÜR DIESEN Fall Unterstriche in Methodennamen erlaubt!!

Die Implementierung der Testmethode selber besteht aus drei Teilen. Oft wird dies auch als Triple-A bezeichnet:

  • Testvorbereitung (Arrange)
  • Testdurchführung (Act)
  • Testauswertung (Assert)
private Fixture fixture;

[SetUp]
public void Setup()
{
   fixture = new Fixture();
}

[TearDown]
public void Dispose()
{
   if (fixture != null)
   {
      fixture.Dispose();
      fixture = null;
   }
}

[Test]
[Category(TestCategory.ContinuousIntegration)]
public void LeiheBuchAus_WithTwoBuecher_CallsQueryBySidForUpdate()
{ 
  /* ... */
}
Testvorbereitung

Hier wird der Test vorbereitet. Sollte die Erzeugung des zu testenden Objektes zu kompliziert sein, (kann ein Smell sein) so sollte dies im Fixture geschehen. Wichtig ist hierbei, dass die für den Test relevanten Daten mit übergeben werden sollen.

Testdurchführung

Für die Durchführung des Tests ist der Aufruf der zu testenden Methode nötig.

Testauswertung

Die Testauswertung prüft die Umgebung und die Rückgabe der zu testenden Methode. Unter die Testauswertung der Umgebung fällt das Verifizieren von Aufrufen an Mocks.

Hilfsklassen
Transaktionen

Teardown durch Rollback der Transaktion.
Aus: xUnit Patterns (siehe
Literatur/Links)

Um durch oder für einen Test vorgenommene Datenbankänderungen nach Ablauf des Tests bequem zurückrollen zu können, wird die Klasse Schleupen.CS.SY.TestFixtures.TransactionScopeFixture verwendet. Eine Instanz dieser Klasse wird in einem den Test umschließenden using-Block erzeugt. Durch das Verlassen des using-Block wird die Transaktion, in der der Testcode ausgeführt wurde zurückgerollt.

private sealed class Fixture : IDisposable
{
    private TransactionScopeFixture transactionScopeFixture = new TransactionScopeFixture();

    public void Dispose()
    {
        transactionScopeFixture.Dispose();
    }
    // ...
}

Hierbei handelt es sich nicht mehr um einen Unittest, sondern um einen sogenannten ConnectionTest oder mehr! Vgl. Teststrategie.

SessionTokenFixture

Um für integrative Tests ein SessionToken zu erhalten, existiert in der PI.SB-API eine Klasse Schleupen.CS.PI.SB.Sessions.Testing.SessionTokenFixture. Diese verwendet die Werte aus der app.config, um ein entsprechendes SessionToken zu erzeugen und im Test zur Verfügung zu stellen.

app.config

<appSettings>
 <add key="ViewName" value="Standard" />
 <add key="ElementName" value="System" />
 <add key="ElementTypeName" value="System" />
</appSettings>
SessionTokenFixture sessionTokenFixture = new SessionTokenFixture();
// ...
sessionTokenFixture.Token;

Bei der Verwendung handelt es sich dann nicht mehr um einen Unittest, sondern um einen sogenannten ConnectionTest oder mehr, da hier Infrastruktur benötigt wird! Vgl. Teststrategie.

Test von Workflows

Siehe NUnit-Tests für Workflows.

ConfigurationConnectionInfoProvider

Für Unittests steht im Framework eine Implementierung der Schnittstelle Schleupen.CS.PI.Framework.Persistence.IConnectionInfoProvider bereit: Die Klasse Schleupen.CS.PI.Framework.Persistence.ConfigurationConnectionInfoProvider verwendet die in der app.config angegebene Verbindungszeichenfolge für Repositories.

app.config

 <connectionStrings>
    <add name="MT.BIB" providerName="System.Data.SqlClient" connectionString="Data Source=localhost\SQLEXPRESS;Initial Catalog=bib_test;User ID=csap_sag;Password=appl%0815" />
 </connectionStrings>

Erweiterungen

  • Strukturierung von Testklassen mit sehr vielen Tests

    Für den Fall, dass sehr viele Unittests zu erstellen sind und die entsprechende Testklasse zu groß wird, können die Tests wie folgt unterteilt werden:
    • Die eigentliche Testklasse wird abstrakt deklariert und in einen gleichnamigen Namespace gelegt (d.h., heißt die Klasse BuchRepositoryTest, so wird diese in einem (Sub-)Namensraum BuchRepositoryTest gelegt). In dieser Klasse können z.B. SetUp() und TearDown() implementiert werden, um diese nicht in jeder ableitenden Testklasse implementieren zu müssen.
    • Für jede zu testende Methode wird eine entsprechende Testklasse erstellt. Diese Testklasse erbt von der abstrakten Testklasse.
    • Die Tests werden in die Testklassen aufgeteilt. Dabei entfällt der Name der zu testenden Methode und es ergibt sich folgendes Muster für den Namen der Testmethode: <Rahmenbedingungen des Tests>_<Erwartetes Resultat>.
    • Optional kann wie im Standardmuster ein Fixture innerhalb der jetzt abstrakten Basisklasse implementiert werden.

Das Verschieben in einen gleichnamigen Namespace wird aus zwei Gründen gemacht:

  1. Es könnte ohne Namespace schnell unübersichtlich werden, falls mehrere Tests in einem Namespace diesem Muster folgen.
  2. Es wird vermieden, dass es Konflikte gibt, wenn zwei zu testende Klassen im gleichen Namespace die gleiche Methode haben.
  • Integrative Unittests

    Unittests, die integrativen Testcharakter haben (, weil sie beispielsweise das eine Datenbank verwenden), müssen ggf. anders aufgebaut werden. Ist die Performance zu schlecht, weil das Fixture und damit beispielsweise die Datenbank-Strukturen für jeden Test neu erstellt werden, sollte die Fixture-Instanz wiederverwendet werden. Die Testklasse hat dann folgende Struktur: 
namespace Schleupen.CS.MY.Tests;

public sealed partial class SomeClassTest : IDisposable
{
    private Fixture fixture;

    [SetUp]
    public void Setup()
    {
        fixture = new Fixture();
    }

    [TearDown]
    public void Dispose()
    {
        if (fixture != null)
        {
            fixture.Reset(); // Optional, falls bestimmte Testdaten oder ein bestimmte Teile der Testumgebung manuell zurückgesetzt werden müssen
            fixture.Dispose();
            fixture = null;
        }
    }
    // ...
}
Implementierungshinweise

Die Tests werden in einer speziellen Test-Assembly definiert. Die Mocks werden in eine Klasse Mocks und die Testdaten in einer Klasse TestData zusammengefasst und durch das Fixture über zwei Properties bereitgestellt. Beide Properties werden in der CreateTestObject()-Methode neu initialisiert.

Implementierung eines Fixtures:

internal sealed partial class BuchAusleihenControllerTest
{
    private Fixture fixture;
      
    [SetUp]
    public void Setup()
    {
       fixture = new Fixture();
    }
  
    [TearDown]
    public void Dispose()
    {
        if (fixture != null)
        {
           fixture.Reset(); // Optional, falls bestimmte Testdaten oder ein bestimmte Teile der Testumgebung zurückgesetzt werden müssen
           fixture.Dispose();
           fixture = null;
       }
    }
  
    private sealed class TestData
    {
        private readonly BuchFixture buchFixture = new BuchFixture(); // für dieses Muster irrelevant! Hier wird ein für mehrere Test-Klassen gemeinsames Fixture verwendet

        public TestData()
        {
            SomeBuecher = new[] { buchFixture.NewBuch().Build(), buchFixture.NewBuch().Build() };
            SomeBuecherIds = SomeBuecher.Select(x => x.Id);
            SomeUserSid = UniqueNameGenerator.Generate("Sid", 20);
            SomeBenutzerkonto = new Benutzerkonto(Guid.NewGuid(), SomeUserSid, "Hans Wurst");
            DeaktiviertesBenutzerkonto = new Benutzerkonto(Guid.NewGuid(), userSid, "Hans Durst");
            DeaktiviertesBenutzerkonto.Deaktiviere();
        }

        public IEnumerable<Guid> SomeBuecherIds { get; private set; }

        public IEnumerable<Buch> SomeBuecher { get; private set; }

        public string SomeUserSid { get; private set; }

        public Benutzerkonto SomeBenutzerkonto { get; private set; }
      
        public Benutzerkonto DeaktiviertesBenutzerkonto { get; private set; }
    }

    private sealed class Mocks
    {
        public Mocks()
        {
            var mockRepository = new MockRepository(MockBehavior.Loose);

            BenutzerkontoRepository = mockRepository.Create<IBenutzerkontoRepository>();
            BuchRepository = mockRepository.Create<IBuchRepository>();
            BuecherAusgeliehenEventServiceGateway = mockRepository.Create<IBuecherAusgeliehenEventServiceGateway>();
        }

        public Mock<IBenutzerkontoRepository> BenutzerkontoRepository { get; private set; }

        public Mock<IBuchRepository> BuchRepository { get; private set; }

        public Mock<IBuecherAusgeliehenEventServiceGateway> BuecherAusgeliehenEventServiceGateway { get; private set; }
    }

    private sealed class Fixture
    {
        public Mocks Mocks { get; private set; }

        public TestData TestData { get; private set; }

        public BuecherAusleihenController CreateTestObject()
        {
            TestData = new TestData();
            Mocks = new Mocks();
  
            Mocks.BenutzerkontoRepository.Setup(x => x.QueryBySidForUpdate(Parameter.IsAny<string>().Object))
                .Returns(Data.SomeBenutzerkonto);

            Mocks.BuchRepository.Setup(x => x.QueryById(Parameter.IsAny<IEnumerable<Guid>>().Object))
                .Returns(Data.SomeBuecher);
          
            return new BuecherAusleihenController(Mocks.BenutzerkontoRepository.Object,
                Mocks.BuchRepository.Object,
                Mocks.BuecherAusgeliehenEventServiceGateway.Object);
        }
      
        public BuecherAusleihenController CreateTestObjectMitDeaktiviertemBenutzerkonto()
        {
            TestData = new TestData();
            Mocks = new Mocks();

            Mocks.BenutzerkontoRepository.Setup(x => x.QueryBySid(Parameter.IsAny<string>().Object))
                    .Returns(TestData.DeaktiviertesBenutzerkonto);
            Mocks.BenutzerkontoRepository.Setup(x => x.QueryBySidForUpdate(Parameter.IsAny<string>().Object))
                    .Returns(TestData.DeaktiviertesBenutzerkonto);

            Mocks.Configuration.Setup(x => x.Read<AusleiheConfigurationValue>()).Returns(new AusleiheConfigurationValue());

            return new BuecherAusleihenController(Mocks.BenutzerkontoRepository.Object,
                    Mocks.BuecherAusgeliehenEventServiceGateway.Object,
                    Mocks.AusleihfristVerlaengertEventServiceGateway.Object,
                    Mocks.Configuration.Object,
                    Mocks.DiagnosticsWriter.Object);
         }
    }
}

Die Testklasse sieht wie folgt aus: 

internal sealed partial class BuchAusleihenControllerTest
{
    [Test]
    [Category(TestCategory.ContinuousIntegration)]
    public void LeiheBuchAus_WithTwoBuecher_CallsQueryBySidForUpdate()
    {
        BuecherAusleihenController testObject = fixture.CreateTestObject();

        testObject.LeiheBuecherAus(fixture.Data.SomeUserSid, fixture.Data.SomeBuecherIds);

        fixture.Mocks.BenutzerkontoRepository.Verify(x => x.QueryBySidForUpdate(Parameter.Is(fixture.Data.SomeUserSid).Object));
    }

    [Test]
    public void LeiheBuecherAus_MitDeaktiviertemBenutzerkonto_SetsAusleiheCountTo1()
    {
       BuecherAusleihenController testObject = fixture.CreateTestObjectMitDeaktiviertemBenutzerkonto();

       Assert.Throws<ValidationException>(() => testObject.LeiheBuecherAus(fixture.TestData.DeaktiviertesBenutzerkonto.BenutzerSid, fixture.TestData.Buecher.ToArray()));
    }
}

Abgrenzung

Das Konzept ist auf automatisierte (Unit)-Tests begrenzt.

Stärken und Schwächen

Stärken
  • Geringe Abhängigkeit zum Test-Framework
  • Trennung von Test- und Produktivcode
  • Einfache Refaktorisierung der Unittests zur Aufteilung auf mehrere Testklassen
  • Einfache Refaktorisierung zum Herauslösen allgemeiner Hilfsklassen zur Einrichtung der Testumgebung
Schwächen
  • Funktioniert nicht applikationsübergreifend

Literatur/Links

Cookie Consent mit Real Cookie Banner