Software testen: Architektur
Software testen mit Unit-Tests, Teil 2
Setzen Sie moderne Programmier-Paradigmen um und benutzen Sie eine saubere Architektur, so bekommen Sie eine testbare Anwendung geschenkt. Schreiben Sie eine testbare Anwendung, bekommen Sie die saubere Architektur geschenkt. Im Gegensatz zu einem Teufelskreis befinden Sie sich damit also in einem Engelskreis. Zumindest ergab eine oberflächliche Recherche zum Thema diesen Begriff als Gegensatz zum vicious circle.
Die Quelltexte finden Sie im GitHub-Repository zu diesem Artikel im Branch „Architecture“.
Worum geht es?
Es soll ein GreetingService gebaut werden, der abhängig von der Tageszeit und dem aktuellen Benutzer eine Begrüßung ausgibt. Die Schnittstelle ist trivial, aber es gibt wenigstens eine Schnittstelle (Listing 1), was aus architektonischer Sicht definitiv ein Punkt auf der Haben-Seite ist.
Listing 1: Die Schnittstelle
public interface IGreetingService
{
string SayHello();
}
The Ugly
In Anspielung auf den oben zitierten Film soll es mit etwas Hässlichem losgehen, nämlich einer naiven Implementation der IGreetingService-Schnittstelle, an dem Sie als jemand, der dafür Unit-Tests schreiben soll, kaum ihre helle Freude haben werden. In Listing 2 sehen Sie den Quelltext (Quältext wäre hier wahrscheinlich zutreffender):
Listing 2: Der Hässliche
public class UglyGreetingService : IGreetingService
{
public string SayHello()
{
var greeting = GetGreeting();
var user = GetUser();
return $"{greeting} {user.Name}";
}
private static User GetUser()
{
return new UserService().GetCurrentUser();
}
private static string GetGreeting()
{
var t = DateTime.UtcNow.TimeOfDay;
if (t.TotalHours > 5 && t.TotalHours < 22)
{
return "Moin";
}
return "Slaap di wat";
}
}
Hässliche Probleme
Ohne Refaktorisierung können Sie diesen Dienst nicht sinnvoll testen:
- GetCurrentUser gibt bestenfalls einen Leerstring oder null zurück, wenn es keinen angemeldeten Benutzer gibt.
- GetGreeting ist direkt abhängig von der Tageszeit, also ist das Testergebnis ebenfalls abhängig von der Tageszeit.
Sie können maximal prüfen, ob SayHello eine Zeichenkette zurückgibt, aber das wäre reichlich überflüssig, denn über den Rückgabetyp gibt die Signatur von SayHello sowieso schon Auskunft. Sie haben weder Kontrolle über die aktuelle Tageszeit noch über den aktuellen Benutzer, der wahrscheinlich im Fall einer Webanwendung irgendwie aus irgendwelchen Authorisierungs-Headern des HttpContext des gerade ausgeführten HttpRequests geholt wird. Bevor Sie einen ziemlich sinnlosen Test wie den in Listing 3 schreiben, gehen Sie besser Kaffee holen.
Listing 3: Hässlicher Test (MSTest)
[TestClass]
public class UglyGreetingServiceTests
{
private IGreetingService greetingService = null!;
[TestInitialize]
public void Setup()
{
greetingService = new UglyGreetingService();
}
[TestMethod]
public void ReturnsGreeting()
{
var result = greetingService.SayHello();
Assert.IsInstanceOfType<string>(result);
}
[TestCleanup]
public void TearDown()
{
// Hier nur der Vollständigkeit halber.
// Gegebenenfalls erfolgt hier die Freigabe von Ressourcen oder das Aufräumen von Testdaten
// Wird nach jedem Test aufgerufen
}
}
The Bad
Nachdem Sie Ihre Kollegen mit etlichen Fluchsalven und unzähligen WTFs amüsiert und vor allem Ihren Kaffee getrunken haben, machen Sie sich an die Arbeit. Hier tut eine beherzte Refaktorisierung Not (Listing 4). Sie beginnen mit dem UserService. Der bekommt eine Schnittstelle verpasst, sodass Sie mit einem Mock im Test bestimmen können, was GetCurrentUser zurückgibt. Die IUserService-Schnittstelle wird sodann als Konstruktor-Parameter dem GreetingService übergeben.
Listing 4: Der Böse
public class BadGreetingService(IUserService userService) : IGreetingService
{
public string SayHello()
{
var greeting = GetGreeting();
var user = GetUser();
return $"{greeting} {user.Name}";
}
private User GetUser()
{
return userService.GetCurrentUser();
}
private static string GetGreeting()
{
var t = DateTime.UtcNow.TimeOfDay;
if (t.TotalHours > 5 && t.TotalHours < 22)
{
return "Moin";
}
return "Slaap di wat";
}
}
Böse Probleme
Immerhin können Sie jetzt wenigstens etwas testen (Listing 5). Noch nicht schön, aber nicht mehr ganz so sinnbefreit wie vorher. Nach einem weiteren Kaffee und einigen eher verhaltenen Verwünschungen können Sie nun erste belastbare Ergebnisse Ihrer Arbeit einchecken. Nebenbei werden Sie bemerkt haben, das xUnit anstelle einer Setup-Methode den Konstruktor benutzt und dass zum Aufräumen die IDisposable-Schnittstelle verwendet wird.
Listing 5: Böser Test (xUnit)
public class BadGreetingServiceTests : IDisposable
{
private IGreetingService greetingService = null!;
public BadGreetingServiceTests()
{
// https://xunit.net/docs/shared-context
var userService = new Mock<IUserService>();
userService.Setup(u => u.GetCurrentUser()).Returns(new User(Guid.NewGuid(), "Christian"));
greetingService = new BadGreetingService(userService.Object);
}
[Fact]
public void ReturnsGreetingWithName()
{
var result = greetingService.SayHello();
Assert.Contains("Christian", result);
}
public void Dispose()
{
// Hier nur der Vollständigkeit halber.
// Gegebenenfalls erfolgt hier die Freigabe von Ressourcen oder das Aufräumen von Testdaten
// Wird nach jedem Test aufgerufen
}
}
The Good
Nach einem weiteren Kaffee beschließen Sie, dass Sie noch Potenzial nach oben sehen. Das Testergebnis muss nun noch unabhängig von der Tageszeit gemacht werden (Listing 6). Sie stoßen auf die abstrakte Klasse TimeProvider. Da geht doch noch was!
Listing 6: Der Gute
public class GreetingService(IUserService userService, TimeProvider timeProvider) : IGreetingService
{
public string SayHello()
{
var greeting = GetGreeting();
var user = GetUser();
return $"{greeting} {user.Name}";
}
private User GetUser()
{
return userService.GetCurrentUser();
}
private string GetGreeting()
{
var t = timeProvider.GetUtcNow().UtcDateTime.TimeOfDay;
if (t.TotalHours > 5 && t.TotalHours < 22)
{
return "Moin";
}
return "Slaap di wat";
}
}
Gut
Sie sehen, dass Sie Tests parametrisieren können ([TestCase]-Attribute, Listing 7). Dazu aber in einem der nächsten Beiträge mehr. Sie sehen auch, dass xUnit im Gegensatz zu MSTest und NUnit kein Attribut benötigt, um eine Testklasse zu dekorieren. Aber am meisten freuen Sie sich, dass Ihre Tests nun auch unabhängig von der Tageszeit ein deterministisches Ergebnis zurückliefern! Wenn das keinen weiteren Kaffee wert ist…
Fragen Sie sich jetzt noch, wie Sie nun im wirklichen Leben beim Anwendungsstart eine konkrete Implementation des abstrakten TimeProviders bereitstellen, dann beachten Sie bitte folgenden Ausschnitt:
// services ist vom Typ IServiceCollection aus dem Namensraum Microsoft.Extensions.DependencyInjection services.AddSingleton(TimeProvider.System);
Listing 7: Guter Test (NUnit)
[TestFixture]
public class GreetingServiceTests
{
private IGreetingService greetingService = null!;
private readonly Mock<TimeProvider> timeProvider = new();
[SetUp]
public void Setup()
{
var userService = new Mock<IUserService>();
userService.Setup(u => u.GetCurrentUser()).Returns(new User(Guid.NewGuid(), "Christian"));
greetingService = new GreetingService(userService.Object, timeProvider.Object);
}
[Test]
public void ReturnsGreetingWithName()
{
var result = greetingService.SayHello();
Assert.That(result, Does.Contain("Christian"));
}
[TestCase(8, "Moin")]
[TestCase(23, "Slaap di wat")]
public void ReturnsGreetingWithTime(int hour, string expectedStart)
{
// arrange
timeProvider.Setup(p => p.GetUtcNow()).Returns(new DateTime(2025, 6, 6, hour, 0, 0, DateTimeKind.Utc));
// act
var result = greetingService.SayHello();
//assert
Assert.That(result, Does.StartWith(expectedStart));
}
}
Fazit
Noch einen Kaffee? Leider ermöglicht der Alltag nicht immer so relativ einfache Möglichkeiten wie hier exemplarisch dargestellt. Aber um es mit einem Titel einer lesenswerten und unterhaltsamen Publikation von Eckard von Hirschhausen zu sagen: Die Leber wächst mit ihren Aufgaben. In diesem Sinne:
Bis neulich!