Neben den funktionalen Anforderungen an ein Software-Produkt, welche sich mittels Unit- und Integrationstests absichern lassen, existieren auch eine Reihe von nicht-funktionalen Anforderungen. Aspekte der Sicherheit oder der Performance einer Anwendung lassen sich durch End-to-End Tests abdecken. Um die Wartbarkeit und Erweiterbarkeit einer Software sicherzustellen, kann man neben statischer Code-Analyse auch automatisierte Architektur-Tests einsetzen. Damit lassen sich Abhängigkeiten zwischen Komponenten oder die Einhaltung von Namenskonventionen überprüfen.

Auch beim Refactoring von bestehendem Code können sich solche Architektur-Tests als sinnvoll erweisen.

Mit ArchUnitNET lassen sich Architektur-Regeln als Code definieren und automatisiert testen.

ArchUnitNET

ArchUnitNET ist ein .NET Port der in der Java-Welt etablierten Bibliothek ArchUnit. Mit ArchUnitNET ist es möglich, Architektur-Regeln als Unit-Test zu hinterlegen und kontinuierlich und automatisiert zu überprüfen. Die Regeln werden in C# Code geschrieben und können in einem beliebigen Test-Framework ausgeführt werden. Um ArchUnitNET zu nutzen, genügt es, seinem Projekt das entsprechende Nuget-Paket hinzuzufügen. Für eine einfache Integration in die unterschiedlichen Testframeworks stehen Pakete für xUnit, NUnit und MS-Test bereit.

Architektur-Regeln

ArchUnitNET-Test sind wie andere Unit-Tests auch aufgebaut. Es existiert eine Test-Klasse mit einer Reihe von Test-Methoden. Der besseren Performance wegen legt man eine statische Klassen-Property mit der Definition der Architektur an.

Hierzu macht man dem ArchLoader eine Reihe von Assemblies bekannt, welche den Code enthalten, der zu unserer Architektur gehören soll. Dazu kann man z.B. Marker-Klassen nutzen. ArchLoader kennt aber auch eine Reihe weiterer Methoden, etwa um Assemblies aus einem Verzeichnis zu laden.

private static readonly Assembly DataModuleAssembly = 
    typeof(DataModuleMarker).Assembly;
private static readonly Assembly BusinessModuleAssembly = 
    typeof(BusinessModuleMarker).Assembly;
private static readonly Assembly DesktopModuleAssembly = 
    typeof(DesktopModuleMarker).Assembly;

private static readonly Architecture Architecture = new ArchLoader()
    .LoadAssemblies(DataModuleAssembly, BusinessModuleAssembly, DesktopModuleAssembly)
    .Build();

Innerhalb der Architektur lassen sich nun Mengen von Klassen, Interfaces oder Methoden bilden, z.B. um Klassen in Schichten zu gruppieren. Nutzt man ein konsistentes Namensschema, kann man auch nach Namensmustern filtern und etwa alle Repository-Klassen zusammenfassen.

Bei der Definition hilft die eingängige Fluent-API von ArchUnitNET.

private readonly IObjectProvider<IType> DataLayer =
    Types().That().ResideInAssembly(DataModuleAssembly).As("Data layer");

private readonly IObjectProvider<IType> BusinessLayer =
    Types().That().ResideInAssembly(BusinessModuleAssembly).As("Business layer");

private readonly IObjectProvider<Class> RepositoryClasses =
    Classes().That().HaveNameEndingWith("Repository").As("Repository classes");

Um nun zu testen, ob alle Repository-Klassen auch wirklich im Data-Layer angesiedelt sind, schreiben wir einen Test, der eine entsprechende Regel definiert und unsere Architektur gegen diese prüft. Zur einfachen Verwendung kann man ArchRuleDefinition als static using einbinden.

using static ArchUnitNET.Fluent.ArchRuleDefinition;

[Fact]
public void RepositoryClassesShouldBeInDataLayer()
{
    var rule = Classes().That().Are(RepositoryClasses).Should().Be(DataLayer);
    rule.Check(Architecture);
}

Zum Überprüfen von gewollten oder ungewollten Abhängigkeiten zwischen Modulen genügen ebenfalls wenige Zeilen Code. Der nachfolgende Test prüft, ob irgendein Typ aus dem Desktop-Layer auf einen Typ aus dem Data-Layer zugreift.

[Fact]
public void DesktopLayerShouldNotReferenceDataLayer()
{
    var rule = Types().That().Are(DesktopLayer).Should().NotDependOnAny(DataLayer);
    rule.Check(Architecture);
}

Stellt ArchUnitNET eine Verletzung unserer Regeln fest, schlägt der Test fehl und liefert eine entsprechende Fehlermeldung.

FailedArchRuleException
"Types that are Desktop layer should not depend on Data layer" failed:
    DesktopModule.ViewModels.ProductListViewModel does depend on
    DataModule.ProductRepository

Nutzung von Code verhindern

Nicht nur die Organisation von Quellcode lässt sich so erzwingen, auch die Verwendung von bestimmten Klassen oder Methode kann man regulieren. Um sicherzustellen, dass nur aus dem Data-Layer auf eine Datenbank zugegriffen wird, schränken wir den Zugriff auf Klassen aus der Assembly Microsoft.Data.SqlClient ein, in der sich unter anderem die Klasse SqlConnection befindet. Von unseren eigenen Assemblies (ProjectAssemblies) dürfen nur aus DataLayer heraus Zugriffe auf die Datenbank-Klassen erfolgen. Damit ArchUnitNET die Assembly Microsoft.Data.SqlClient kennt, müssen wir diese explizit in unsere Architektur-Definition mit aufnehmen.

private static readonly Assembly[] ProjectAssemblies = {
    DataModuleAssembly,
    BusinessModuleAssembly,
    DesktopModuleAssembly
};

private static readonly Assembly SystemDataAssembly =
    typeof(SqlConnection).Assembly;

private static readonly Architecture Architecture = new ArchLoader()
    .LoadAssemblies(/* ... */ SystemDataAssembly)
    .Build();

[Fact]
public void OnlyDataLayerShouldUseDatabase()
{
    var types = Types()
        .That().ResideInAssembly(ProjectAssemblies[0], ProjectAssemblies[1..])
        .And()
        .AreNot(DataLayer);
    var typesNotToUse = Types().That().ResideInAssembly(SystemDataAssembly);

    var rule = types.Should().NotDependOnAny(typesNotToUse);
    rule.Check(Architecture);
}

Architektur-Refactoring

Hat man es mit einer bestehenden Code-Basis zu tun und möchte Änderungen an dieser vornehmen, kann man mittels ArchUnitNET den gewünschten Zielzustand vor den entsprechenden Maßnahmen definieren. Anschließend hat man die Möglichkeit, die bestehende Architektur Stück für Stück in die angestrebte Richtung zu ändern. Zu Beginn des Refactoring-Prozesses zeigen die jetzt noch roten Tests die Stellen an, an denen gearbeitet werden muss. Regeln, die man noch nicht umgesetzt hat, kann man derweil überspringen. Sobald alle Tests wieder aktiviert und grün sind, ist das Refactoring abgeschlossen.

[Fact(Skip = "Refactoring of DesktopLayer will be done in the next iteration")]
public void DesktopLayerShouldNotReferenceDataLayer()
{
    var rule = Types().That().Are(DesktopLayer).Should().NotDependOnAny(DataLayer);
    rule.Check(Architecture);
}

System-Ressourcen finden

Die Verwendung von nicht-deterministischen System-Ressourcen wie der aktuellen Uhrzeit kann das Schreiben von Tests erschweren. Möchte man nicht direkt in Richtung Pure Functions rafactorn, kann es helfen, eine Abstraktion wie den seit .NET 8 hinzugekommenen TimeProvide zu nutzen.

Auch hier kann ein Test uns helfen, alle Zugriffe auf DateTime.Now oder DateTime.UtcNow zu finden.

[Fact]
public void DateTimeNowShouldNotBeUsedInBusinessLayer()
{
    var types = Types().That().Are(BusinessLayer);

    var methodsNotToCall = MethodMembers()
        .That().AreDeclaredIn(typeof(DateTime))
        .And().AreStatic()
        .And().HaveNameEndingWith("get_UtcNow()")
        .Or().HaveNameEndingWith("get_Now()");

    var rule = types.Should().NotCallAny(methodsNotToCall);
    rule.Check(Architecture);
}

Ein fehlschlagender Test zeigt uns auch hier wieder an, an welcher Stelle wir ansetzen müssen.

"Types that are Business layer should not call Method members that are declared
in "System.DateTime" and are static and have name ending with "get_UtcNow()"" failed:
    BusinessModule.ProductService does call System.DateTime
    System.DateTime::get_UtcNow()

Kudos gehen hier an Andreas Lausen für seinen Beispiel-Code von der SEACON 2023.

Fazit

ArchUnitNET bietet die mächtige Möglichkeit, Architektur- und Code-Verwendungs-Regeln in Form von Unit-Tests zu definieren und automatisiert und kontinuierlich zu überprüfen. Nach kurzer Einarbeitung ist das Schreiben von neuen Regeln sehr einfach. Da sich diese Architektur-Regeln wie alle anderen Unit-Test verhalten, lassen sie sich auch in bestehende Codebasen einfügen. Das beste Vorgehen ist natürlich, sie so früh wie möglich zu definieren und zu nutzen.

Der Beispiel-Code zu diesem Post findet sich auf Github: https://github.com/davull/demo-archunit