Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 4 Min.

Hierarchische Testdata-Builder

Lesbare Tests bei tiefen Objekthierarchien mit dem Collection-Builder-Pattern.
© EMGenie

Wenn Entitäten verschachtelte Abhängigkeiten haben oder komplexe Objektgraphen bilden, stoßen einfache Testdata-Builder an ihre Grenzen. Wir zeigen, wie man Builder hierarchisch strukturiert und dabei sowohl Flexibilität als auch Einfachheit bewahrt.

Von einfachen zu hierarchischen Buildern

Der Grundlagen-Artikel „Bob, der Testdaten-Baumeister“ in der dotnetpro-Ausgabe 10-11/2025 [1] hat gezeigt, wie Testdata-Builder die Wartbarkeit und Lesbarkeit von Tests verbessern. Doch in der Praxis haben wir es oft mit komplexeren Strukturen zu tun: Ein Blogpost hat nicht nur einen Author und eine Category, sondern auch eine Liste von Comments, die wiederum Replies enthalten können. Solche verschachtelten Objektgraphen erfordern einen durchdachteren Ansatz.

Hierarchische Builder lösen dieses Problem, indem sie das Builder-Pattern rekursiv anwenden. Jede Entität in der Hierarchie erhält ihren eigenen Builder, und diese Builder arbeiten nahtlos zusammen. Das Ergebnis ist ein Fluent-API, das auch komplexe Testszenarien klar und verständlich abbildet.

Erweiterung des Blog-Systems

Wir erweitern unser Blog-System aus dem Grundlagen-Artikel [1] um Comments und Replies (Bild 1). Ein Comment kann mehrere Replies haben, wodurch eine zweistufige Hierarchie entsteht (Listing 1).

Erweiterung unseres Blogsystems um Comments und Replies (Bild 1)

Erweiterung unseres Blogsystems um Comments und Replies (Bild 1)

© Autor
Listing 1: Erweitertes Blog-System mit Comments und Replies
public class Comment
{
    public required String UserName { get; init; }
    public required String Text { get; init; }
    public required DateTime Time { get; init; }
    public IEnumerable<Reply> Replies { get; init; } = [];
}

public class Reply
{
    public required int Id { get; init; }
    public required string Content { get; init; }
    public required string UserName { get; init; }
    public DateTime Time { get; init; }
}

public class BlogPost
{
    // ... bestehende Properties ...
    public IEnumerable<Comment> Comments { get; init; } = [];
}
 

Collection-Builder für flexible Listen

Der Schlüssel zu lesbaren Tests mit Collections liegt im Collection-Builder-Pattern. Statt Listen direkt zu befüllen, verwenden wir einen Builder, der ein Fluent-API für das Hinzufügen von Elementen bietet (Listing 2).

Die Add-Methode nimmt eine Funktion entgegen, die einen CommentBuilder konfiguriert. Dies ermöglicht eine sehr natürliche Syntax beim Erstellen von Tests. Der Builder kann im BlogPostBuilder dann wie in Listing 3 gezeigt integriert werden.

 

Listing 2: Collection-Builder für Comments
public class CommentCollectionBuilder
{
    private readonly List<Comment> _comments = new();

    public CommentCollectionBuilder Add(
        Func<CommentBuilder, CommentBuilder> configure)
    {
        var builder = new CommentBuilder();
        var configuredBuilder = configure(builder);
        _comments.Add(configuredBuilder.Build());
        return this;
    }

    public List<Comment> Build() => _comments;
}
 
Listing 3: Integration des Collection-Builders in BlogPostBuilder
public class BlogPostBuilder
{
    // ... bestehende Felder ...
    private List<Comment> _comments = new();

    public BlogPostBuilder WithComments(
        Func<CommentCollectionBuilder, CommentCollectionBuilder> configure)
    {
        var builder = new CommentCollectionBuilder();
        var configuredBuilder = configure(builder);
        _comments = configuredBuilder.Build();
        return this;
    }

    public BlogPost Build()
    {
        return new BlogPost
        {
            // ... bestehende Properties ...
            Comments = _comments
        };
    }
}
 

Verschachtelte Builder für tiefe Hierarchien

Bei verschachtelten Strukturen wie Comments mit Replies wenden wir das gleiche Prinzip rekursiv an. Der CommentBuilder erhält einen eigenen Collection-Builder für Replies (Listing 4).

 

Listing 4: CommentBuilder mit verschachteltem Reply-Collection-Builder
public class ReplyCollectionBuilder
{
    private readonly List<Reply> _replies = new();

    public ReplyCollectionBuilder Add(
        Func<ReplyBuilder, ReplyBuilder> configure)
    {
        var builder = new ReplyBuilder();
        var configuredBuilder = configure(builder);
        _replies.Add(configuredBuilder.Build());
        return this;
    }

    public IEnumerable<Reply> Build() => _replies;
}

public class CommentBuilder
{
    private int _id = 1;
    private string _content = "Great post!";
    private Author _author = new AuthorBuilder().Build();
    private DateTime _createdDate = DateTime.Now.AddHours(-2);
    private List<Reply> _replies = new();

    public CommentBuilder WithContent(string content)
    {
        _content = content;
        return this;
    }

    public CommentBuilder WithReplies(
        Func<ReplyCollectionBuilder, ReplyCollectionBuilder> configure)
    {
        var builder = new ReplyCollectionBuilder();
        var configuredBuilder = configure(builder);
        _replies = configuredBuilder.Build();
        return this;
    }

    public Comment Build()
    {
        return new Comment
        {
            Id = _id,
            Content = _content,
            Author = _author,
            CreatedDate = _createdDate,
            Replies = _replies
        };
    }
}
 

Generische Collection-Builder für Wiederverwendbarkeit

Bei genauerer Betrachtung fällt auf, dass ReplyCollectionBuilder und CommentCollectionBuilder nahezu identisch sind – sie unterscheiden sich lediglich im Typ der Entität. Diese Duplikation können wir durch einen generischen CollectionBuilder eliminieren (Listing 5). Der Builder enthält Typparameter für die Entität (TEntity) und für den Builder, der diese Entität erstellen kann (TBuilder). Um die Build-Methode der Builder typsicher aufrufen zu können, führen wir zudem ein Interface IBuilder<TEntity> ein, das von allen Buildern implementiert werden muss.

Mit diesem generischen Builder können wir nun die spezifischen Collection-Builder ersetzen (Listing 6).

Listing 5: Generischer CollectionBuilder für beliebige Entitäten
public class CollectionBuilder<TEntity, TBuilder> : IBuilder<IEnumerable<TEntity>>
    where TBuilder : IBuilder<TEntity>, new()
{
    private readonly List<TEntity> _items = new();

    public CollectionBuilder<TEntity, TBuilder> Add(Action<TBuilder>? configure = null)
    {
        var builder = new TBuilder();
        configure?.Invoke(builder);
        _items.Add(builder.Build());
        return this;
    }

    public IEnumerable<TEntity> Build()
    {
        return _items;
    }
}

public interface IBuilder<TEntity>
{
    TEntity Build();
}

// Erweiterung bestehender aller Bestehender Builder (z. B. BlogPostBuilder):
public class BlogPostBuilder: IBuilder<BlogPost>
 
Listing 6: Generischen CollectionBuilder integrieren
public class BlogPostBuilder
{
    // ... bestehende Felder ...
    private List<Comment> _comments = new();

    public BlogPostBuilder WithComments(
        Func<CollectionBuilder<Comment, CommentBuilder>, 
             CollectionBuilder<Comment, CommentBuilder>> configure)
    {
        var builder = new CollectionBuilder<Comment, CommentBuilder>();
        var configuredBuilder = configure(builder);
        _comments = configuredBuilder.Build();
        return this;
    }
    
    // ... Rest der Klasse ...
}

public class CommentBuilder
{
    // ... bestehende Felder ...
    private List<Reply> _replies = new();

    public CommentBuilder WithReplies(
        Func<CollectionBuilder<Reply, ReplyBuilder>, 
             CollectionBuilder<Reply, ReplyBuilder>> configure)
    {
        var builder = new CollectionBuilder<Reply, ReplyBuilder>();
        var configuredBuilder = configure(builder);
        _replies = configuredBuilder.Build();
        return this;
    }
    
    // ... Rest der Klasse ...
}
 

Der generische CollectionBuilder reduziert Code-Duplikation und macht das Testdata-Builder-Pattern skalierbarer. Für neue Entitäten mit Collections braucht kein neuer Collection-Builder mehr erstellt zu werden – der generische Builder kann einfach wiederverwendet werden. Die Typsicherheit bleibt dabei vollständig erhalten.

Hierarchische Builder in der Praxis

Mit dieser Struktur können wir nun komplexe Testszenarien elegant und lesbar ausdrücken (Listing 7). Die Verschachtelung der Builder spiegelt dabei die Struktur unserer Domänenobjekte wider.

Listing 7: Test mit hierarchischen Buildern
[Fact]
public void GetBlogPostWithDiscussion_ReturnsCompleteThread()
{
    // Arrange
    var blogPost = new BlogPostBuilder()
        .WithTitle("Hierarchical Builders")
        .WithComments(comments => comments
            .Add(comment => comment
                .WithContent("Great article!")
                .WithReplies(replies => replies
                    .Add(reply => reply
                        .WithContent("Thanks!"))
                    .Add(reply => reply
                        .WithContent("Glad you liked it!"))))
            .Add(comment => comment
                .WithContent("Very helpful, thanks!")))
        .Build();

    // Act & Assert
    Assert.Equal(2, blogPost.Comments.Count);
    Assert.Equal(2, blogPost.Comments[0].Replies.Count);
}
 

Die Lesbarkeit ist bemerkenswert: Der Test liest sich fast wie natürliche Sprache, und die Struktur der Testdaten ist auf einen Blick erfassbar. Änderungen an einzelnen Elementen sind auf einfache Weise möglich, ohne die gesamte Testdaten-Struktur anpassen zu müssen.

KI-gestützte Erstellung hierarchischer Builder

Ein KI-Assistent wie zum Beispiel GitHub Copilot kann uns auch bei hierarchischen Buildern unterstützen. Der Schlüssel liegt in präzisen Prompts, die das gewünschte Pattern klar beschreiben:

 

Erstelle einen CommentBuilder mit verschachteltem 
CollectionBuilder für Replies. Der CommentBuilder soll:
- Eine WithReplies-Methode haben, die einen generischen CollectionBuilder<Reply, ReplyBuilder> konfiguriert
- Die Methode soll eine Func<CollectionBuilder<Reply, ReplyBuilder>, CollectionBuilder<Reply, ReplyBuilder>> als Parameter nehmen
Verwende das gleiche Pattern wie beim BlogPostBuilder mit CollectionBuilder für Comments.

 

Noch effizienter wird die Arbeit mit einer erweiterten Instruction-Datei. Diese ergänzt die bestehenden Konventionen aus dem Grundlagen-Artikel [1] um Regeln für hierarchische Strukturen (siehe Listing 8).

Listing 8: Erweiterte Instructions-Datei für hierarchische Builder
## Generischer Collection-Builder

### Verwende CollectionBuilder<TEntity, TBuilder>
- Statt spezifische Collection-Builder zu erstellen, verwende den generischen CollectionBuilder definiert in der Datei CollectionBuilder.cs
- TEntity ist der Typ der Entität in der Collection
- TBuilder ist der Builder-Typ für diese Entität

### Integration in Parent-Builder

Bei Entities mit Collections:
  - Verwende WithCollectionName-Methoden (z.B. WithComments, WithReplies)
  - Parameter ist Func<CollectionBuilder<TEntity, TBuilder>, CollectionBuilder<TEntity, TBuilder>>
  - Initialisiere Collections mit leeren Listen als Default

#### Beispiel:
```csharp
public class ParentBuilder
{
  private List<Child> _children = new();

  public ParentBuilder WithChildren(Func<CollectionBuilder<Child, ChildBuilder>, CollectionBuilder<Child, ChildBuilder>> configure)
  {
    var builder = new CollectionBuilder<Child, ChildBuilder>();
    var configuredBuilder = configure(builder);
    _children = configuredBuilder.Build();
    return this;
  }
}
```

### Verschachtelung

- Wende das gleiche Pattern rekursiv an
- Jede Ebene der Hierarchie folgt den gleichen Regeln
- Halte die API konsistent über alle Ebenen hinweg
- Verwende immer den generischen CollectionBuilder
 

Fazit

Hierarchische Testdata-Builder erweitern das Builder-Pattern um die Fähigkeit, komplexe Objektgraphen elegant abzubilden. Durch die Kombination eines generischen CollectionBuilders mit verschachtelten Buildern entstehen Fluent-APIs, die auch anspruchsvolle Testszenarien klar und wartbar machen.

Der generische CollectionBuilder ist dabei ein entscheidender Baustein: Er eliminiert Code-Duplikation und macht das Pattern skalierbar. Anstatt für jede Collection einen eigenen Builder zu erstellen, kann derselbe generische Builder für alle Entitäten wiederverwendet werden, während die Typsicherheit vollständig erhalten bleibt.

Der Schlüssel zum Erfolg liegt in der konsistenten Anwendung des Patterns über alle Hierarchieebenen hinweg. Jede Ebene folgt den gleichen Regeln, wodurch das API intuitiv und vorhersehbar bleibt. In Kombination mit KI-Tools wie GitHub Copilot und durchdachten Instruction-Files können hierarchische Builder effizient erstellt und gewartet werden – die KI lernt das Pattern und wendet es konsistent auf neue Entitäten an.

 

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

Neueste Beiträge

Machine Learning mit Python – Von Daten zu Modellen - Python und AI, Teil 2
Zu Beginn eines Machine-Learning-Projekts steht die gründliche Datenvorbereitung. Feature Engineering bezeichnet die Auswahl oder Erzeugung relevanter Attribute aus den Rohdaten, um die Modellleistung zu verbessern.
8 Minuten
Attraktives GUI mit Spectre.Console - Best of NuGet, Teil 6
Mit der Bibliotheksfamilie Spectre.Console steht ein neues Produkt ante portas, das die Realisierung von visuell ansprechenden Kommandozeileninterfaces zu erleichtern sucht.
7 Minuten
29. Okt 2025

Das könnte Dich auch interessieren

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
Der Weg durch den Quoridor - dotnetpro.contest 01/2017 - Aufgabe
Eine Bot-KI für das Brettspiel Quoridor entwickeln.
7 Minuten
Mistral-AI-Modelle für Databricks Data Intelligence Platform - Databricks
Databricks hat eine Partnerschaft inklusive Beteiligung an der Serie-A-Finanzierung von Mistral AI bekannt gegeben.
3 Minuten
21. Mär 2024
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige