Unit-Tests für IoC Container

Inhaltsverzeichnis

Das Problem

Wenn für eine Anwendung Dependency Injection (DI) genutzt wird und ein Inversion of Control (IoC) Container wie Autofac zum Einsatz kommt, kann es schnell passieren, dass nicht alle benötigten Komponenten registriert werden. Die Abhängigkeiten einer Klasse werden z.B. als Interface in den Constructor injiziert und fortan genutzt. Während der Entwicklung fällt dann nicht auf, dass die Abhängigkeit nicht aufgelöst werden kann, aber sobald die Anwendung gestartet und genutzt wird, kommt es zu Fehlermeldungen, die oft zum Absturz der Anwendung führen.

Unhandled exception. Autofac.Core.Registration.ComponentNotRegisteredException: 
 The requested service 'ConsoleApp.Interfaces.ITextWriter' has not been registered.
 To avoid this exception, either register a component to provide the service, check 
 for service registration using IsRegistered(), or use the ResolveOptional() method 
 to resolve an optional dependency.
   at Autofac.ResolutionExtensions.ResolveService(IComponentContext context, Service service, IEnumerable`1 parameters)
   at Autofac.ResolutionExtensions.Resolve(IComponentContext context, Type serviceType, IEnumerable`1 parameters)
   at Autofac.ResolutionExtensions.Resolve[TService](IComponentContext context, IEnumerable`1 parameters)
   at Autofac.ResolutionExtensions.Resolve[TService](IComponentContext context)
   at Program.<Main>$(String[] args) in /Users/gerrit/Code/IocTests/src/ConsoleApp/Program.cs:line 7

Um möglichst schnell und automatisch (z.B. durch die CI/CD Pipelines) auf das Problem aufmerksam zu werden, sollte die IoC Konfiguration unter Test gestellt werden. In diesem Blogpost beziehe ich mich auf die Möglichkeiten von Autofac, aber das Vorgehen ist auch auf andere Packages wie Microsoft.Extensions.DependencyInjection übertragbar.

Eine Beispielanwendung inkl. Tests gibt es im GitHub Repo gerrited/IocTests. Neben Autofac werden auch die Pakete NUnit, Moq und FluentAssertions genutzt.

IoC Konfiguration testen

Angenommen beim Programmstart soll das Interface ITextWriter aufgelöst werden und die registrierte Implementierung ConsoleTextWriter hat weitere Abhängigkeiten (in diesem Fall zu ITextGenerator), die wiederum auch am IoC Container registriert sein müssen.

In der Program.cs sieht es dann beispielsweise wie folgt aus.

var iocConfig = new IocConfig();
var builder = iocConfig.GetBuilder();
var container = builder.Build();

var writer = container.Resolve<ITextWriter>();

writer.Write();

Ein einfacher Test könnte genau diese Schritte durchführen und sicherstellen, dass es nicht zur Exception ComponentNotRegisteredException kommt.

public void Resolve_ITextWriter_NotThrowComponentNotRegisteredException()
{
    var sut = CreateContainer();

    var act = () => sut.Resolve<ITextWriter>();

    act.Should().NotThrow<ComponentNotRegisteredException>();
}

Die Erzeugung des Containers im “Arrange”-Teil des Tests (siehe AAA Pattern) findet in einer Factory Method statt, weil wir diesen Teil noch bei weiteren Tests brauchen werden. Anschließend wird im “Act”, vergleichbar mit der Stelle beim Programmstart, das Interface ITextWriter aufgelöst. Im “Assert” stellen wir sicher, dass die Exception ComponentNotRegisteredException nicht geworfen wird. Mit diesem Test können wir bereits recht sicher sein, dass die Anwendung lauffähig ist.

Unit Testing ist oft doppelte Büchführung und um sowohl die erfolgreiche Auflösung, als auch die Registrierung ansich zu prüfen, kann alternativ oder zusätzlich der folgende Test genutzt werden.

[Test]
public void Resolve_ITextWriter_ReturnConsoleTextWriter()
{
    var sut = CreateContainer();

    var result = sut.Resolve<ITextWriter>();

    result.Should().BeOfType<ConsoleTextWriter>();
}

Statt auf die Exception, welche trotzdem geworfen wird, wenn ITextWriter oder eine Abhängigkeit nicht aufgelöst werden kann, zu prüfen, testen wir, ob es sich bei der aufgelösten Klasse, um den richtigen Typ handelt. Dieser Test schlägt also auch fehl, wenn z.B. der Typ FileTextWriter statt ConsoleTextWriter registriert und dadurch aufgelöst wurde.

Umgang mit externen Ressourcen

Wenn externe Ressourcen wie Netzwerk, Datenbankverbindungen oder das Dateisystem genutzt werden, kann ein Test der IoC Konfiguration etwas schwieriger werden, weil während des Tests die Datenbank nicht vorhanden oder der benötigte Server nicht erreichbar ist. Dann können simple Test Doubles die eigentlichen Implementierungen ersetzen. Dafür werden zusätzlich zu den tatsächlichen Registrierungen, weitere für die jeweiligen Interfaces (z.B. IDatabaseConnection oder IFileSystem) durchgeführt. Damit sichergestellt werden kann, dass es die Typen auch ohne die Test Doubles aufgelöst werden können, sollten weitere Tests verwendet werden.

[Test]
public void IsRegistered_IDatabaseConnection()
{
    var sut = CreateContainer();

    var result = sut.IsRegistered<IDatabaseConnection>();

    result.Should().BeTrue();
}

Durch IsRegistered wird der Typ nicht aufgelöst und es werden keine externen Ressourcen wie Datenbankverbindungen erstellt, was zu Fehlern wie “Anmeldung fehlgeschlagen” oder “Server nicht gefunden” führen könnte. Solche Tests sollten trotzdem vermieden werden, weil jetzt nicht sichergestellt werden kann, ob während der Laufzeit auch alle Anhängigkeiten der registrierten Implementierung von IDatabaseConnection aufgelöst werden können.

Sind das Unit-Tests?

Ob es sich hierbei um Unit- oder Integrationstests handelt, ist (wie so häufig) abhängig von der individuellen Perspektive und Motivation. Getestet wird eine Komponente “IoC Konfiguration”. Außerdem können die Tests schnell und ohne weitere Konfiguration ausgeführt werden. Andererseits werden Abhängigkeiten der Implementierungen aufgelöst, wodurch ganz eindeutig mehrere Klassen und Projekte am Test beteiligt sind. Ich persönlich bevorzuge grundsätzlich eine Aufteilung der Tests in Unit (eher schnell und ohne Konfiguration) und Integration (vermutlich eher langsam, evtl. ist eine Konfiguration nötig oder es wird auf externe Ressourcen wie Dateien, Server und Datenbanken zugegriffen), wobei die Grenze bewusst unscharf bleibt.

Herausforderungen

Die Tests werden deutlich schwieriger, wenn es keinen eindeutigen Einstiegspunkt, individuelle Lifetimescopes (z.B. pro API Request) oder weitere Möglichkeiten zum Auflösen von Abhängigkeiten (z.B. einen Service Locater mit einer anderen IoC Konfiguration) gibt. Auf solche fiesen Komplexitätsverstärker versuche ich aber sowieso möglichst zu verzichten.

Außerdem wird die Aussagekraft von Tests verringert, wenn viele Registrierungen aufgrund von externen Ressourcen durch Test Doubles ersetzt werden müssen, weil die wirklichen Abhängigkeiten dann erst zur Laufzeit aufgelöst werden. Deshalb sollte sich die Anzahl der zusätzlich registrierten Test Doubles auf ein Minimum beschränken.

Fazit

Anders als Mark Seemann, dessen neues Buch “Code That Fits in Your Head” ich aktuell jedem empfehle kann, in seinem Blogpost “Testing Container Configurations”, bin ich der Meinung, dass die aufgeführten Tests, vor allem bei größeren Anwendungen, bei denen Abhängigkeiten oft undurchsichtig werden, die Entwicklung erleichtern können, weil der Feedback Loop deutlich kürzer wird, denn die Tests sollten spätestens nach jedem Commit von der CI/CD Pipeline ausgeführt werden. Meistens reichen bereits wenige Testfälle aus, um recht zuverlässig testen zu können, dass es während der Laufzeit keine Probleme beim Auflösen der Abhängigkeiten geben wird. Auf System- oder Smoke-Tests sollte trotzdem nicht verzichtet werden. Ein Nachteil ist, dass die Tests für jede Anwendung erneut geschrieben werden müssen. Das sollte dann aber bestenfalls einer der ersten Schritte sein, wenn nach Test Driven Development gearbeitet wird.

Auf das Testen einzelner Module, welche beispielsweise für Zusammenstellungen der einzelnen Komponenten eines Pakets genutzt werden, sollte verzichtet werden, weil die Komposition der Abhängigkeiten erst in der IoC Konfiguration der Anwendung festgelegt wird und es dadurch sehr wahrscheinlich vorkommen kann, dass nicht alle Abhängigkeiten eines einzelnen Moduls aufgelöst werden können.