Testing 

Länder setzen die ersten vier Stufen der Testpyramide um, um die Software zu testen und die notwendige Qualität sicherzustellen. Im Folgenden wird hier die Umsetzung der verschiedenen Tests und der dabei verwendeten Konstrukte beschrieben.

Die Struktur und das Codieren von Tests hat dieselben Qualitätsansprüche wie der Produktiv-Code!

UnitTests

UnitTests können für alle Bausteine der Mikroarchitektur eines Landes geschrieben werden, da insbesondere das Strategiemuster angebunden ist und somit für den Test die Klassen isoliert werden können. Zur Isolation können dann Test Double in Form von Fakes, Spys, Mocks, Stubs, etc. verwendet werden.

In Schleupen.CS wird hierfür die Nutzung von Test Double insbesondere die Bibliothek Moq verwendet.

Unittests testen Klassen isoliert

Die Implementierung eines UnitTests unter Verwendung von Mocks zeigt exemplarisch folgender Code:

[Test]
public async Task LeiheAusAsync_WhereBuchIstNichtAusgeliehen_ShouldCreateAusleihe()
{
  var benutzerkontoId = BenutzerkontoId.New();
  fixture.Mocks.BuchRepositoryStub
       .Setup(x => x.QueryByIdAsync(fixture.Data.BuchVerweise))
       .Returns(Task.FromResult<IBuecher>(new Buecher(fixture.Data.Buecher)));
  ...
  BuecherAusleihenController testObject = fixture.CreateTestObject();
  await testObject.LeiheAusAsync(fixture.Data.BenutzerSid, fixture.Data.BuchVerweise);
  fixture.Mocks.AusleiheRepositoryMock.Verify(
       x => x.Add(It.Is<IAusleihen>(ausleihen => ausleihen.Count == 1)));
}

In diesem Beispiel hat der BuchRepositoryStub die Rolle eines Stubs, da er lediglich Werte zurückgibt. Der AusleiheRepositoryMock indes ist ein Mock mit dem überprüft wird, ob Ausleihen erstellt worden sind.

In Schleupen.CS verwenden wir den Begriff Testobject anstelle von SUT da kein System, sondern eine Klasse - genauer ein Objekt - getestet wird.

Wie man ebenfalls anhand des Beispiels erkennen kann, wird der Test anhand des Patterns Arrange-Act-Assert strukturiert, wobei im Assert genau ein Sachverhalt geprüft wird (er also nicht notwendigerweise genau ein Assert enthält).

Zudem haben die UnitTests in Schleupen.CS 3.0 folgende Namenskonvention:

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

Diese Namenskonvention ist rein der Lesbarkeit geschuldet, da die Methodennamen (= Testnamen) durchaus länger werden können. In den entsprechenden Tools sind die Tests dann zum einen bzgl. der zu testenden Methode gruppiert und zum anderen sind die Randbedingung und das erwartete Resultat gut erkennbar.  

LeiheBuchAus_BenutzerHatKeineAusleihen_ShouldReturnAusleihe

LeiheBuchAus_BenutzerIstNichtAusleiheberechtigt_ShouldThrowException

Die Tests werden im Allgemeinen nach der zu testenden Klasse organisiert. D.h. es wird eine Klasse mit dem Postfix Tests und ein Fixture mit folgender Namenskonvention erstellt:

<Zu testende Klasse>TestFixture

Getestet werden soll die Klasse BuecherAusleihenController. Dann wird eine Klasse BuecherAusleihenControllerTest mit folgenden Grundgerüst erstellt:

[TestFixture]
internal sealed class BuecherAusleihenControllerTest : IDisposable
{
  private Fixture fixture;

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

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

In Schleupen.CS 3.0 wird NUnit als Unit-Testing-Framework verwendet.

Erweiterte Strukturierung

Für reine UnitTests können Hilfs-Fixtures wiederverwendet werden.

Allgemeine Grundstruktur von Tests in Schleupen.CS 3.0

Dabei wird insbesondere ein sogenanntes AggregateFixture pro Aggregat definiert, in der die Testdatenerzeugung per Fluent Interface angeboten wird.

public sealed class BuchFixture
{
  ...
  public BuchId[] BuchIds => fixture.CreateMany<BuchId>().ToArray();

  public BuchFixture NewBuch()
  {
    buch = fixture.Create<Buch>();
    ...
    return this;
  }

  public BuchFixture WithSeite()
  {
    ...
  }

  public BuchFixture WithSeite(Seite seite)
  {
    if (seite == null) { return this; }
    ISeiteData seiteData = seite;
    ((IAggregateChild<Inhaltsangabe>)seiteData).ParentId = inhaltsangabe.Id;
    seiteList.Add(seite);
    return this;
  }

  public BuchFixture WithGenre()
  {
    return WithGenre(Genre.Krimi);
  }

  public BuchFixture WithGenre(Genre aGenre)
  {
    if (aGenre == null) { return this; }
    genre = aGenre;
    return this;
  }

  public Buch Build()
  {
      ...
    return result;
  }
}

Zur Implementierung eines AggregateFixtures wird u.a. AutoFixure verwendet.

Unittests werden in einer Assembly mit dem Postfix *.UnitTests organisiert, so dass diese Tests in der CI/CD-Pipeline einfach angeschlossen werden können. Dieses Verfahren sorgt zudem für ein bewussteres Schreiben von Tests gemäß der Teststufen der Testpyramide. Zudem kann die Code-Coverage spezifischer ausgewertet werden.

Besonderheit Domänenmodell

Im Domänenmodell werden die Aggregate nicht per Dependency Injection verkabelt und auch nicht per se ein Strategiemuster verwendet. Dies Ist auch nicht notwendig, da die Aggregate eine Konsistenzgrenze darstellen und die Kopplung über EntityIds minimal ist. Daher wird in diesem Fall im Allgemeinen nicht jede Entity und jedes ValueObject per se isoliert getestet. Dies ist natürlich möglich und anzuraten, aber das Testen des Clusters von Objekten - das Aggregat - ist hier erlaubt.

[TestFixture]
internal sealed class BenutzerkontoTest
{
  private BenutzerkontoFixture benutzerkontoFixture;
  private BuchFixture buchFixture;

  [SetUp]
  public void Setup()
  {
    buchFixture = new BuchFixture();
    benutzerkontoFixture = new BenutzerkontoFixture();
  }

  [Test]
  public void LeiheAus_WhereBenutzerkontoIsEmpty_ShouldAddOneBuch()
  {
    Benutzerkonto testObject = benutzerkontoFixture.NewBenutzerkonto().Build();
    IAusleihen testResult = testObject.LeiheAus(new Buecher(buchFixture.NewBuch().Build()));
    Assert.That(testResult.Count, Is.EqualTo(1));
  }
  ...
}

Wie man hier sieht, werden die AggregateFixtures verwendet, um einfach Testdaten zu erstellen. Diese sind auch in anderen UnitTests und höheren Teststufen sinnvoll einsetzbar!

Besonderheit bei Repositorys

Das Testen von Repositorys per UnitTest ist möglich, aber der Erfahrung nach wenig zielführend. Hierbei wäre die Verwendung von Fakes, Stubs und/oder Mocks sinnvoll. Eine geeignete Implementierung ist allerdings schwierig. Daher wurde die nachfolgend beschriebene Teststufe definiert.

Auch die Verwendung einer In-Memory-Datenbank hat sich als unpraktisch herausgestellt. Die Abweichungen von der "echten" Datenbank waren zu groß, so dass wir die folgenden ConnectionTests definiert haben, die eine sehr gute Aussagekraft über die Repository-Implementierung liefern. Auch die Unterschiede bzgl. der Laufzeit waren geringer als vermutet.

ConnectionTests

Repositorys werden also durch sogenannte Repository.ConnectionTests gegen eine echte "leere" Datenbank (bis auf das zugehörige Schema) ausgeführt. Hierbei wird folgende Grundstruktur verwendet:

Grundstruktur von RepositoryTests

In der Verwendung wird dabei durch das sogenannte TransactionScopeFixture vor der Durchführung eines Tests eine Transaktion geöffnet, die nach der Durchführung automatisch zurückgerollt wird. So wird dem Prinzip genüge getan, dass die Umgebung nach dem Test identisch mit der Umgebung vor dem Test ist.

internal sealed class BuchRepositoryTest : IDisposable
{
	private Fixture fixture;

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

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

	private sealed class Fixture : IDisposable
	{
		private readonly IConnectionInfoProvider connectionInfoProvider = new TestConfigurationConnectionInfoProvider();
		private readonly TransactionScopeFixture transactionScopeFixture = new();
		private readonly PersistenceContextScope persistenceContextScope;
		private readonly BuchPersistenceFixture buchPersistenceFixture;

		public Fixture()
		{
			persistenceContextScope = new PersistenceContextScope(connectionInfoProvider);
			buchPersistenceFixture = new BuchPersistenceFixture(persistenceContextScope.PersistenceContext);
		}
		...
	}
...
}

Des Weiteren wird pro Aggregat neben dem AggregateFixture zur Erzeugung von Testobjekten ein sogenanntes PersistenceFixture erstellt, um beispielsweise für Abfragemethoden im Vorhinein Testdaten persistieren zu können. Dabei wird ein PersistenceFixture pro Aggregat erstellt.

Idealerweise wird das PersistenceFixture in einer anderen Technologie als die Repository-Implementierung codiert!

Das folgende Beispiel zeigt einen Test, der sicherstellt, dass kein SELECT N+1-Problem vorhanden ist:

[Test]
public void WithMaxResults_WhereTitelExists_ShouldExecuteOneStament()
{
	Buch buch = fixture.CreatePersistedBuchAggregates(10, 20).First();
	using BuchRepository testObject = fixture.CreateBuchRepository();
	using StatementExecutionScope statementExecutionScope = new();

	BuchAggregateRestriction buchRestriction = new(SearchMode.Wildcard);
	buchRestriction.By(new BuchCriteria().WhereTitelIs(buch.Titel));
	buchRestriction.MaxResults = 1;

	testObject.QueryBy(buchRestriction);

	Assert.That(statementExecutionScope.StatementCount, Is.EqualTo(2));
	Assert.That(statementExecutionScope.SqlTraces[0].Sql, Does.Contain("TOP")); // nur exemplarisch
	Assert.That(statementExecutionScope.SqlTraces[1].Sql, Does.Contain("join")); // nur exemplarisch
}

ConnectionTests werden in einer Assembly mit dem Postfix *.ConnectionTests organisiert, so dass diese Tests in der CI/CD-Pipeline einfach angeschlossen werden können.

ComponentTests

ComponentTests werden über einen im Code gehosteten Service getestet. Der Host beinhaltet dabei die komplette WCF-Infrastruktur. Diesen Host stellt das ServiceTestFixture<TServiceInterface,  TServiceImplementation> bereit. Da das Hosting in einer eigenen AppDomain (aber nicht in einem eigenen Prozess) erfolgt, können die Daten, die der Service erzeugt nicht über eine lokale Transaktion abgeräumt werden. Der Service "sieht" diese schlichtweg nicht. Als Konsequenz müssen die Daten vor der eigentlichen Testdurchführung committed und im TearDown wieder gelöscht werden, um den Originalzustand des Systems wieder zu erhalten. 

Die Klassen für einen Test des BuecherAusleihenActivityService sind die Folgenden:

Grundstruktur von ComponentTests

An den Nahtstellen zur externen Infrastruktur werden Test-Double (Fake, Mock, Stub) verwendet - beispielsweise für die Diagnoseprotokollierung.

Um Aggregate für die Testvorbereitung (-> Arrange) zu persistieren, wird pro Aggregate ein sogenanntes AggregatePersistenceFixture implementiert.

Das folgende Beispiel zeigt eine Implementierung eines solchen Tests:

[TestFixture]
internal sealed partial class BuecherAusleihenActivityServiceTest
{
	[Test]
	public void LeiheAus_WhereBuchExists_ShouldReturnAusleiheWithThatBuch()
	{
		Buch buch;
		Benutzerkonto benutzerkonto;
		using (ITransactionScope transactionScope = fixture.CreateTransactionScope())
		{
			buch = fixture.CreatePersistedBuch();
			benutzerkonto = fixture.CreatePersistedBenutzerkonto();
			transactionScope.Complete();
		}

		fixture.MockIdentityProvider(benutzerkonto, fixture.CreateSessionToken());
		fixture.WithOpenedServiceHost(
			client =>
			{
				LeiheAusRequest leiheAusRequest = fixture.CreateLeiheAusRequest(buch);

				LeiheAusResponse leiheAusResponse = client.LeiheAus(leiheAusRequest);

				Assert.That(leiheAusResponse, Is.Not.Null);
				Assert.That(leiheAusResponse.AusleiheListe, Is.Not.Empty);
				Assert.That(leiheAusResponse.AusleiheListe.Count, Is.EqualTo(1));
			});
	}
   ...
}
internal sealed partial class BuecherAusleihenActivityServiceTest : IDisposable
{
	private Fixture fixture;

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

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

	private sealed class TestData
	{
		private readonly AusleiheFixture ausleiheFixture = new();
		private readonly BuchFixture buchFixture = new();
		private readonly BenutzerkontoFixture benutzerkontoFixture = new();

		public Benutzerkonto CreateBenutzerkonto()
		{
			return benutzerkontoFixture
					.NewBenutzerkonto()
					.Build();
		}

		public Buch CreateBuch()
		{
			...
		}

		public Ausleihe CreateAusleihe(BenutzerkontoId benutzerkonto, BuchId buch)
		{
			return ausleiheFixture
					.NewAusleihe()
					.For(benutzerkonto)
					.Of(buch)
					.Build();
		}
	}

	private sealed class Mocks
	{
		public Mock<IIdentityProvider> IdentityProviderStub { get; } = new();

		public Mock<IBusinessEventPublisher> BusinessEventPublisherMock { get; } = new();

		public Mock<IServiceDiagnosticsWriter> DiagnosticsWriterMock { get; } = new();

		public Mock<User> UserStub { get; } = new();
	}

	private sealed class Fixture : ServiceTestFixture<IBuecherAusleihenActivityService, BuecherAusleihenActivityService>
	{
		private readonly TransactionScopeFactory transactionScopeFactory = new();
		private readonly PersistenceContextScope persistenceContextScope = new(new TestConfigurationConnectionInfoProvider());
		private readonly BuchPersistenceFixture buchPersistenceFixture;
		private readonly BenutzerkontoPersistenceFixture benutzerkontoPersistenceFixture;
		private readonly AusleihePersistenceFixture ausleihePersistenceFixture;

		public Fixture()
			: base(new TestServiceHostFactoryConfigurator())
		{
			RegisterInstance(Mocks.IdentityProviderStub.Object);
			RegisterInstance(Mocks.BusinessEventPublisherMock.Object);
			RegisterInstance(Mocks.DiagnosticsWriterMock.Object);

			buchPersistenceFixture = new BuchPersistenceFixture(persistenceContextScope.PersistenceContext);
			benutzerkontoPersistenceFixture = new BenutzerkontoPersistenceFixture(persistenceContextScope.PersistenceContext);
			ausleihePersistenceFixture = new AusleihePersistenceFixture(persistenceContextScope.PersistenceContext);
		}

		public TestData TestData { get; } = new();

		public Mocks Mocks { get; } = new();

		public void WithOpenedServiceHost(Action<IBuecherAusleihenActivityServiceClient> action)
		{
			Mocks.BusinessEventPublisherMock
				.Setup(x => x.PublishOnTransactionComplete<IBuecherAusgeliehenEventService, RaisedNotification>(
							It.IsAny<RaisedNotification>()));

			base.WithOpenedServiceHost(action);
		}

		public void MockIdentityProvider(Benutzerkonto benutzerkonto, string sessionToken)
		{
			Mocks.UserStub
				.SetupGet(x => x.ExternalId).Returns(((IBenutzerkontoData)benutzerkonto).ExternalId.ToString);
			User user = Mocks.UserStub.Object;
			Mocks.IdentityProviderStub
				.Setup(x => x.TryResolveUser(sessionToken, out user))
				.Returns(true);
		}

		public Benutzerkonto CreateBenutzerkonto()
		{
			return TestData.CreateBenutzerkonto();
		}

		public Benutzerkonto CreatePersistedBenutzerkonto()
		{
			Benutzerkonto benutzerkonto = TestData.CreateBenutzerkonto();
			benutzerkontoPersistenceFixture.Persist(benutzerkonto);
			return benutzerkonto;
		}

		public Buch CreatePersistedBuch()
		{
			...
		}

		public ITransactionScope CreateTransactionScope()
		{
			return transactionScopeFactory.CreateTransactionScope();
		}

		public void CreatePersistedAusleihe(BenutzerkontoId benutzerkonto, BuchId buch)
		{
			Ausleihe ausleiheSample = TestData.CreateAusleihe(benutzerkonto, buch);
			ausleihePersistenceFixture.Persist(ausleiheSample);
		}

		public LeiheAusRequest CreateLeiheAusRequest(Buch buch)
		{
			LeiheAusRequest leiheAusRequest = new();
			leiheAusRequest.IdempotencyKey = Guid.NewGuid().ToString();
			leiheAusRequest.SessionToken = CreateSessionToken();
			leiheAusRequest.BuchListe = new List<BuchContract>();
			leiheAusRequest.BuchListe.Add(new BuchContract { Id = buch.Id.Value });
			return leiheAusRequest;
		}

		public GibZurueckRequest CreateZurueckgebenRequest(Buch buch)
		{
			...
		}

		public string CreateSessionToken()
		{
			return "Wird nicht gebraucht da die Verbindung über die app.config gezogen wird. Dies erfolgt über den " +
					"TestServiceHostFactoryConfigurator.";
		}

		protected override void Dispose(bool disposing)
		{
			if (!disposing)
			{
				return;
			}

			base.Dispose(true);

			using ITransactionScope transactionScope = CreateTransactionScope();
			buchPersistenceFixture.DeletePersistedBuecher();
			ausleihePersistenceFixture.DeleteByBuecher(buchPersistenceFixture.PersistedBuecher);
			benutzerkontoPersistenceFixture.DeletedPersistedBenutzerkonten();
			transactionScope.Complete();
			persistenceContextScope.Dispose();
		}
	}
}

ComponentTests werden in einer Assembly mit dem Postfix *.ComponentTests organisiert, so dass diese Tests in der CI/CD-Pipeline einfach angeschlossen werden können.

IntegrationTests

Integrative Tests eines Landes im Zusammenspiel mit der Infrastruktur und/oder anderen Ländern werden

  • analog zu ComponentTests nur ohne Test-Double implementiert und über einen Testrunner oder
  • per Pester mit Powershell ausgeführt.

Wenn IntegrationTests mit .NET implementiert werden, werden diese analog zu den anderen Teststufen in einer Assembly mit dem Postfix *.IntegrationTests organisiert, so dass diese Tests in der CI/CD-Pipeline einfach angeschlossen werden können.

Cookie Consent mit Real Cookie Banner