Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 6 Min.

Bob lernt Fachjargon

Vom Fluent-API zur Geschäftssprache – wie Domain-spezifische Sprachen Tests noch lesbarer und ausdrucksstärker machen.
© EMGenie

Im Grundlagen-Artikel „Bob, der Testdaten-Baumeister“ in der dotnetpro-Ausgabe 10-11/2025 [1] lernten wir, wie man lesbare Tests mithilfe von Fluent-APIs und Testdata-Buildern baut. Bei größeren, komplexeren Testsetups stoßen aber selbst diese Werkzeuge manchmal an ihre Grenzen: Tests mit vielen WithX()-Aufrufen sind zwar wartbar, aber nicht unbedingt auf Anhieb verständlich. Domain-spezifische Sprachen (DSLs) lösen dieses Problem, indem sie die Testdaten-Erstellung in eine natürliche Geschäftssprache übersetzen.

Von technischen Details zur Fachsprache

Betrachten wir einen typischen Test mit Standard-Buildern für ein komplexeres Szenario:

 

var draftPost = new BlogPostBuilder()
     .WithIsPublished(false)
     .WithPublishedDate(null)
     .WithCategory(new CategoryBuilder().WithName("Technology").Build())
     .Build();

 

Der Test ist wartbar und nutzt Builder – aber er spricht die Sprache der Implementierung, nicht die Sprache der Domäne. Ein Fachanwender würde nicht sagen: „Erstelle einen Post, der nicht publiziert ist, kein Publikationsdatum hat und eine Kategorie mit Name ‚Technologie‘“. Er würde sagen: „Erstelle einen Entwurf in der Kategorie Technologie.“ Genau hier setzen Domain-spezifische Sprachen an. Sie übersetzen technische Builder-Aufrufe in fachliche Konzepte und reduzieren dabei die Komplexität auf das Wesentliche:

 

var draftPost = Create.DraftPost().InCategory("Technology");

 

Diese eine Zeile ist nicht nur kürzer – sie ist auch für Nichtentwickler verständlich und kommuniziert die Intention des Tests direkt.

Fachliche Konzepte identifizieren

Mit unserer DSL möchten wir fachliche Konzepte in den Vordergrund stellen und technische Details (wie zum Beispiel die Testdata-Builder) verstecken. Im ersten Schritt sollte man die wichtigsten fachlichen Konzepte der eigenen Domäne identifizieren und benennen. In unserem Blogsystem gibt es beispielsweise folgende Konzepte:

  • Post-Zustände: Ein DraftPost ist ein noch nicht publizierter Beitrag, ein PublishedPost ist live, ein ScheduledPost hat ein Publikationsdatum in der Zukunft.
  • Zeitliche Konzepte: Beiträge FromLastWeek(), FromYesterday() oder FromTheFuture() beschreiben zeitliche Relationen ohne konkrete Datumswerte.
  • Kategorisierung:InCategory("Technology") oder WithoutCategory() beschreiben die Zuordnung zu Kategorien.

Der Einstiegspunkt: Die Create-Klasse

Eine DSL braucht einen klaren Einstiegspunkt. In unserem Fall ist das die statische Klasse Create in Listing 1. Jede dieser Methoden gibt einen konfigurierten BlogPostBuilder zurück. Das bedeutet, dass DSL die Builder nicht ersetzt, sondern sie baut auf ihnen auf. Dadurch bleiben alle With-Methoden verfügbar und man kann bei Bedarf weitere Anpassungen vornehmen:

 

var post = Create.DraftPost().WithTitle("My Special Draft").InCategory("Technology");
Listing 1: Die Klasse Create als Einstiegspunkt für unsere DSL
public static class Create
{
    public static BlogPostBuilder DraftPost()
    {
        return new BlogPostBuilder().WithIsPublished(false).WithPublishedDate(null);
    }

    public static BlogPostBuilder PublishedPost()
    {
        return new BlogPostBuilder()
            .WithIsPublished(true)
            .WithPublishedDate(DateTime.Now.AddDays(-1));
    }

    public static BlogPostBuilder ScheduledPost()
    {
        return new BlogPostBuilder()
            .WithIsPublished(false)
            .WithPublishedDate(DateTime.Now.AddDays(7));
    }
} 

Domänen-spezifische Extension Methods

Für wiederkehrende Konfigurationen erweitern wir die Builder um Extension Methods, die fachliche Konzepte ausdrücken. Extension Methods haben gegenüber einfachen Klassenmethoden den Vorteil, dass sie Builder aus verschiedenen Quellen erweitern können, ohne diese Klassen selbst ändern zu müssen. Wenn der BlogPostBuilder in einer Bibliothek liegt oder von einem Code-Generator erstellt wird, können wir trotzdem projekt- oder domänenspezifische Methoden hinzufügen. Zudem können verschiedene Teams oder Module ihre eigenen DSL-Erweiterungen schaffen, ohne sich gegenseitig zu beeinflussen.

Listing 2 zeigt Extension Methods, um Kategorien sowie zeitliche Aspekte unserer DSL abzubilden. Die Extension Methods lesen sich wie natürliche Sprache und verstecken technische Details. InCategory("Tech") ist aussagekräftiger als WithCategory(new CategoryBuilder().WithName("Tech").Build()) und kapselt zudem die Logik zur Category-Erstellung an zentraler Stelle.

Listing 2: BlogpostBuilderExtensions für Kategorien und zeitliche Aspekte
public static class BlogPostBuilderExtensions
{
    public static BlogPostBuilder InCategory(this BlogPostBuilder builder, string categoryName)
    {
        return builder.WithCategory(new CategoryBuilder().WithName(categoryName).Build());
    }

    public static BlogPostBuilder WithoutCategory(this BlogPostBuilder builder)
    {
        return builder.WithCategory(null);
    }

    public static BlogPostBuilder FromLastWeek(this BlogPostBuilder builder)
    {
        return builder.WithPublishedDate(DateTime.Now.AddDays(-7));
    }

    public static BlogPostBuilder FromYesterday(this BlogPostBuilder builder)
    {
        return builder.WithPublishedDate(DateTime.Now.AddDays(-1));
    }
} 

Collection-Builder in der DSL

In einem vorangegangenen Artikel „Hierarchische Testdata-Builder“ beschäftigten wir uns mit dem Collection-Builder-Pattern. Dieses Pattern entfaltet seine wahre Stärke in Kombination mit der DSL. Hiermit können wir komplexe Szenarien mit mehreren Entitäten elegant ausdrücken. Dazu erweitert man einfach die Create-Klasse aus Listing 1 mit weiteren Convenience-Methoden, wie in Listing 3 gezeigt. Das Beispiel umfasst zwei Methoden, mit denen man einfach drei veröffentlichte oder fünf Blogpost-Drafts erstellen kann. Benötigt man mehr Flexibilität, so kann man auch hier, ähnlich wie beim BlogPostBuilder, auf Extension Methods zurückgreifen.

Listing 3: Convenience-Methoden für Collection-Builder
public static class Create
{
    // ... Methoden aus Listing 1
    public static CollectionBuilder<BlogPost, BlogPostBuilder> ThreePublishedPosts()
    {
        return new CollectionBuilder<BlogPost, BlogPostBuilder>()
            .Add(b => b.WithIsPublished(true))
            .Add(b => b.WithIsPublished(true))
            .Add(b => b.WithIsPublished(true));
    }

    public static CollectionBuilder<BlogPost, BlogPostBuilder> FiveDraftPosts()
    {
        return new CollectionBuilder<BlogPost, BlogPostBuilder>()
            .Add(b => b.WithIsPublished(false))
            .Add(b => b.WithIsPublished(false))
            .Add(b => b.WithIsPublished(false))
            .Add(b => b.WithIsPublished(false))
            .Add(b => b.WithIsPublished(false));
    }
} 

 

Komplexe Szenarien mit Conditional Builders

Für noch komplexere Testfälle können wir einen Conditional-Collection-Builder einführen, der Bedingungen für Teilmengen der Collection erlaubt. Der Vorteil liegt darin, dass wir verschiedene Gruppen von Entitäten mit unterschiedlichen Eigenschaften erstellen können, ohne für jede Gruppe explizit die Details angeben zu müssen. Statt zehn einzelne Add()-Aufrufe mit jeweils vollständiger Konfiguration zu schreiben, beschreiben wir das Szenario in einer kompakten, lesbaren Form.

Listing 4 zeigt einen Conditional-Collection-Builder, der über Generics für beliebige Entities verwendet werden kann. Mit den synonymen Methoden Where() und And() werden Teilmengen definiert, die mit der Methode Are() konfiguriert werden können. Dies wirkt zunächst abstrakt, wird aber klar, wenn man sich ein Beispiel wie das folgende vor Augen führt:

 

var posts = Create.Posts()
     .Where(4).Are(b => b.InCategory("Tech"))
     .And(2).Are(b => b.InCategory("Business"))
     .Build();

 

Diese Syntax kommuniziert die Testintention ohne unnötiges Rauschen. Wir sehen sofort: Es geht um vier Posts in der Kategorie „Tech“ und zwei in der Kategorie „Business“. Details wie CollectionBuilder sind hinter der DSL versteckt.

Um den Aufruf Create.Posts() zu ermöglichen, müssen wir allerdings noch die Create-Klasse wie folgt erweitern:

 

    public static ConditionalCollectionBuilder<BlogPost, BlogPostBuilder> Posts()
     {
         return new ConditionalCollectionBuilder<BlogPost, BlogPostBuilder>();
     }

 

Listing 4: Generischer Conditional-Collection-Builder
public class ConditionalCollectionBuilder<TEntity, TBuilder>
    where TBuilder : IBuilder<TEntity>, new()
{
    private readonly List<TEntity> _items = new();
    private int _currentCount = 0;

    public ConditionalCollectionBuilder<TEntity, TBuilder> Where(int count)
    {
        _currentCount = count;
        return this;
    }

    public ConditionalCollectionBuilder<TEntity, TBuilder> And(int count)
    {
        return Where(count);
    }

    public ConditionalCollectionBuilder<TEntity, TBuilder> Are(Action<TBuilder> configure)
    {
        for (int i = 0; i < _currentCount; i++)
        {
            var builder = new TBuilder();
            configure(builder);
            _items.Add(builder.Build());
        }
        _currentCount = 0;
        return this;
    }

    public IEnumerable<TEntity> Build() => _items;
} 

Ein praxisnahes Beispiel

Listing 5 zeigt einen vollständigen Test, der unsere DSL in einem realistischen Szenario verwendet. Der Test ist auch ohne tiefgreifende technische Kenntnis verständlich. Es geht um fünf Blogposts aus der Vorwoche in der Kategorie „Technology“, drei veröffentlichte Blogposts vom Vortag in der Kategorie „Business“ sowie vier Drafts ohne Kategorie. Anschließend werden diese Posts in einem Repository gespeichert und durch einen zu testenden BlogPostStatistcsService ausgewertet. Schließlich verifiziert der Test, dass diese Statistiken korrekt berechnet wurden.

Listing 5: Ein beispielhafter Test, der ein komplexes Szenario mit Hilfe eines Conditional-Collection-Builders aufbaut
[Fact]
public void GetBlogStatistics_MixedContent_ReturnsCorrectStats()
{
    var blogPosts = Create
        .Posts()
        .Where(5).Are(b => b.FromLastWeek().Published().InCategory("Technology"))
        .And(3).Are(b => b.FromYesterday().Published().InCategory("Business"))
        .And(4).Are(b => b.Draft().WithoutCategory())
        .Build();

    var repository = new InMemoryBlogRepository();
    repository.AddPosts(blogPosts);

    var service = new BlogStatisticsService(repository);

    var stats = service.GetStatistics();

    Assert.Equal(5+3, stats.PublishedPostsCount);
    Assert.Equal(4, stats.DraftsCount);
    Assert.Equal(5, stats.PostsInCategory("Technology"));
    Assert.Equal(3, stats.PostsInCategory("Business"));
} 

Fazit

Domain-spezifische Sprachen erweitern Testdata-Builder um eine entscheidende Dimension: Sie übersetzen technische Implementierungsdetails in fachliche Konzepte. Das Ergebnis sind Tests, die nicht nur wartbar und robust sind, sondern auch auf Anhieb verständlich.

Die Kombination aus Standard-Buildern, Collection-Buildern und DSL-Extension-Methods schafft ein mächtiges Werkzeugset. Einfache Fälle werden durch die DSL ausdrucksstark und lesbar, komplexe Spezialfälle bleiben durch die zugrunde liegenden Builder flexibel handhabbar.

Testdata-Builder mit DSLs sind mehr als nur ein technisches Pattern – sie sind eine Brücke zwischen der Sprache der Entwickler und der Sprache der Fachexperten. Sie machen Tests zu lebendiger Dokumentation, die zeigt, wie die Software funktioniert und welche Szenarien relevant sind. Und das ist letztlich das Ziel guter Tests: Nicht nur zu prüfen, ob der Code funktioniert, sondern auch zu kommunizieren, was er tun soll.

 

 

[1] Alexander Rampp, Bob, der Testdaten-Baumeister, dotnetpro 10/11/2025, Seite 104 ff.

 

Neueste Beiträge

UI-Gestaltung mit Uno - Plattformübergreifend entwickeln mit .NET und XAML
Architektur, Ökosystem, UX/UI-Strategien und Tooling der Uno Platform.
16 Minuten
Vor dem Prompt ist nach dem Prompt - KI für KMU, Teil 2
Wie bereitet man Anfragen an Large Language Models bestmöglich vor?
7 Minuten
13. Nov 2025
Generative AI und Python - Python und AI, Teil 4
Generative KI mit Python nutzen und so die Basis für eigene Anwendungen schaffen.
7 Minuten

Das könnte Dich auch interessieren

Hierarchische Testdata-Builder - Testdata-Builder, Teil 1
Lesbare Tests bei tiefen Objekthierarchien mit dem Collection-Builder-Pattern.
4 Minuten
Builder meets Faker - Testdata-Builder, Teil 2
Wer viele Testdaten braucht, liebt Bogus: Die Library erzeugt auf Knopfdruck realistische Daten und geht mit dem Builder-Pattern eine perfekte Kombination ein.
6 Minuten
12. Nov 2025
SOLID versus CUPID – Gegner oder Verbündete? - Softwaredesign
Die SOLID-Prinzipien gelten für Entwicklungsteams als goldene Regeln, um guten Code zu schreiben. Dan North übte 2016 Kritik daran und präsentierte als Gegenentwurf CUPID.
13 Minuten
16. Jun 2025
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige