Heute werde ich über das Builder-Muster in der testgetriebenen Entwicklung sprechen. Wenn Sie bereits mit Tests arbeiten, haben Sie wahrscheinlich bemerkt, wie zeitaufwändig es sein kann, alle Eingabedaten zu erstellen. Oft wird derselbe Datensatz oder Daten mit geringfügigen Unterschieden für viele Tests in der Testsuite eines Systems verwendet. Der Builder hilft hier. Er dient zwei Zwecken:
Als Beispiel nehme ich die Klasse Invoice
. Eine sehr vereinfachte Version könnte etwa so aussehen:
public class Invoice { public Invoice( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { InvoiceNo = invoiceNo; InvoiceDate = invoiceDate; Customer = customer; CountryCode = countryCode; Lines = lines; } public string InvoiceNo { get; } public string Customer { get; } public string CountryCode { get; } public DateTime InvoiceDate { get; } public decimal TotalAmount => Lines.Sum(x => x.TotalPrice); public IReadOnlyList<InvoiceLine> Lines { get; } } public class InvoiceLine { public InvoiceLine( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { ItemCode = itemCode; UnitCount = unitCount; UnitPrice = unitPrice; Vat= vat; } public string ItemCode { get; } public decimal UnitCount { get; } public decimal UnitPrice { get; } public decimal Vat { get; } public decimal TotalPrice => UnitCount * UnitPrice * (1 + Vat / 100); }
Um ein Invoice
Objekt zu erstellen, muss ich den Konstruktoren von Invoice
und InvoiceLine
viele Werte bereitstellen. In vielen Fällen ist nur ein Teil der Eigenschaften für bestimmte Tests relevant. Hier kommen Builder zur Hilfe.
Der Builder für InvoiceLine
könnte etwa so aussehen:
public partial class InvoiceLineBuilder { private string _itemCode; private decimal _unitCount; private decimal _unitPrice; private decimal _vat; public static implicit operator InvoiceLine(InvoiceLineBuilder builder) => builder.Build(); public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( "001", 1, 100, 21 ); } public InvoiceLineBuilder( string itemCode, decimal unitCount, decimal unitPrice, decimal vat) { _itemCode = itemCode; _unitCount = unitCount; _unitPrice = unitPrice; _vat = vat; } public InvoiceLine Build() { return new InvoiceLine( _itemCode, _unitCount, _unitPrice, _vat ); } public InvoiceLineBuilder WithItemCode(string value) { _itemCode = value; return this; } public InvoiceLineBuilder WithUnitCount(decimal value) { _unitCount = value; return this; } public InvoiceLineBuilder WithUnitPrice(decimal value) { _unitPrice = value; return this; } public InvoiceLineBuilder WithVat(decimal vat) { _vat = value; return this; } }
Der Builder für Invoice
könnte etwa so aussehen:
public partial class InvoiceBuilder { private string _invoiceNo; private string _customer; private string _countryCode; private DateTime _invoiceDate; private IReadOnlyList<InvoiceLine> _lines; public static implicit operator Invoice(InvoiceBuilder builder) => builder.Build(); public static InvoiceBuilder Default() { return new InvoiceBuilder( "S001", "AB VeryImportantCustomer", "SV", DateTime.Parse("2024-01-01"), new [] { InvoiceLineBuilder .Default() .Build() } ); } public InvoiceBuilder( string invoiceNo, string customer, string countryCode, DateTime invoiceDate, IReadOnlyList<InvoiceLine> lines) { _invoiceNo = invoiceNo; _customer = customer; _countryCode = countryCode; _invoiceDate = invoiceDate; _lines = lines; } public Invoice Build() { return new Invoice( _invoiceNo, _invoiceDate, _lines ); } public InvoiceBuilder WithInvoiceNo(string value) { _invoiceNo = value; return this; } public InvoiceBuilder WithCustomer(string value) { _customer = value; return this; } public InvoiceBuilder WithCountryCode(string value) { _countryCode = value; return this; } public InvoiceBuilder WithInvoiceDate(DateTime value) { _invoiceDate = value; return this; } public InvoiceBuilder WithLines(IReadOnlyList<InvoiceLine> value) { _lines = value; return this; } public InvoiceBuilder WithLines(params InvoiceLine[] value) { _lines = value; return this; } }
Falls für einen Test ein Invoice
nur wegen der Gesamtpreiseigenschaft benötigt wird, kann Invoice
folgendermaßen erstellt werden:
var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );
Da der Gesamtpreis durch die Addition der Rechnungszeilen berechnet wird und die Standardstückzahl für die Rechnungszeile 1 ist, reicht es aus, den Stückpreis für die Rechnungszeile festzulegen. Wenn in mehreren Tests ähnliche Funktionen benötigt werden, können wir noch weiter gehen und dem InvoiceBuilder
die folgende Methode hinzufügen:
public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }
Wie oben erwähnt, ist die Builder-Klasse ein großartiger Ort, um alle gängigen und Sonderfälle für die Klasse zu sammeln. Hier werde ich einige dieser möglichen Fälle angeben:
Aus meiner Sicht ist es ein großartiger Ort, um Wissen über die verschiedenen Fälle zu sammeln, die unser System behandelt. Es dient als nützliche Wissensbasis für neue Entwickler, um zu verstehen, was das System verwalten muss. Wenn ich neu auf einem Gebiet bin, denke ich möglicherweise nicht einmal an mögliche Randfälle. Hier ist ein Codebeispiel aus einigen der oben genannten Fälle:
public static InvoiceBuilder ForEUCountry() { return Default() .WithCountryCode("SV"); } public static InvoiceBuilder ForUSA() { return Default() .WithCountryCode("USA"); } public static InvoiceBuilder ForChina() { return Default() .WithCountryCode("CN"); } public InvoiceBuilder WithRegularVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(21) ); } public InvoiceBuilder WithReducedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(9), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); } public InvoiceBuilder WithMixedVat() { return this .WithLines( InvoiceLineBuilder .Default .WithItemCode("S001") .WithVat(21), InvoiceLineBuilder .Default .WithItemCode("S002") .WithVat(9) ); }
Jetzt können wir eine Mischung aus den oben genannten Elementen erstellen. Wenn beispielsweise ein Testfall eine Rechnung für einen EU-Kunden mit Rechnungspositionen mit gemischter Mehrwertsteuer erfordert, kann ich Folgendes tun:
[Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }
Dies ist nur ein einfaches Beispiel, aber ich hoffe, Sie verstehen das Konzept.
Ein Builder ist nützlich, wenn wir ein großes, komplexes Objekt haben, aber nur wenige Felder für den Test relevant sind.
Ein weiterer nützlicher Fall ist, wenn ich mehrere Szenarien basierend auf bestimmten Werten testen möchte. Alle Eigenschaften bis auf eine bleiben gleich und ich ändere nur eine. Dadurch ist es einfacher, den Unterschied hervorzuheben, der dazu führt, dass sich der Dienst oder das Objekt anders verhält.
Erstens können Sie selbst eine Builder-Klasse erstellen. Dies erfordert keine anfängliche Investition von Zeit oder Geld und Sie haben viel Freiheit bei der Erstellung. Kopieren, Einfügen und Ersetzen kann nützlich sein, nimmt aber dennoch ziemlich viel Zeit in Anspruch, insbesondere bei größeren Klassen.
Als ich mit der Codegenerierung begann, richtete ich zunächst einen einzigen Test dafür ein. Dieser Test testete eigentlich nichts; er akzeptierte lediglich einen Typ, rief alle Eigenschaften mithilfe von Reflektion ab, erstellte eine Builder-Klasse aus einer fest codierten Vorlage und schrieb sie in das Ausgabefenster des Testläufers. Ich musste lediglich eine Klassendatei erstellen und den Inhalt aus dem Ausgabefenster des Testläufers kopieren/einfügen.
Alles über BuilderGenerator finden Sie hier . Dort wird der .NET Incremental Source Generator erklärt. Das bedeutet, dass der Builder-Code live neu generiert wird, wenn sich die Zielklasse ändert. Im Vergleich zu den oben genannten Methoden gibt es also keinen Aufwand oder manuelle Arbeit. Erstellen Sie einfach eine Builder-Klasse, fügen Sie das BuilderFor
Attribut mit dem Zielklassentyp hinzu, und alle With
Methoden werden automatisch generiert und sind einsatzbereit.
[BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }
Ich habe nicht viel damit gearbeitet, aber es scheint eine breite Benutzerbasis zu haben, mit 82,7.000 Downloads zum Zeitpunkt des Schreibens. Mir sind ein paar Probleme aufgefallen, die mich dazu veranlasst haben, andere Optionen zu wählen:
Die Lösung kann nicht erstellt werden, wenn sich die Builder-Klasse in einem anderen Projekt befindet als die Zielklasse. Sie kann sich in einem anderen Projekt befinden, aber der Namespace muss derselbe bleiben. Andernfalls werden die folgenden Fehler angezeigt:
Es unterstützt keine Konstruktorparameter und schlägt fehl, wenn die Zielklasse keinen parameterlosen Konstruktor hat.:
Lassen Sie uns herausfinden, welche anderen Optionen wir haben.
Dies ist eine sehr beliebte Bibliothek mit insgesamt über 82,2 Millionen Downloads (und 186,1 K für die aktuelle Version) zum Zeitpunkt des Schreibens. Wie der Autor der Bibliothek angibt, handelt es sich um einen Fake-Datengenerator, der in der Lage ist, zahlreiche Objekte basierend auf vordefinierten Regeln zu erstellen. Es ist nicht genau das, was das Builder-Muster in TDD ist, aber es kann angepasst werden.
Es gibt mehrere Möglichkeiten, Bogus.Faker zu verwenden, aber ich werde mich hier darauf konzentrieren, wie das Builder-Muster nachgeahmt werden kann.
Der einfachste Weg, ein Objekt mit Bogus.Faker zu erstellen, ist:
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }
Es erstellt eine Instanz von InvoiceLine2
mit Standardwerten, also Nullen und Nullen. Um einige Werte festzulegen, verwende ich das folgende Setup:
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>() .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Der obige Code erstellt ein Rechnungszeilenobjekt mit zufälligen Werten. Ein Beispiel könnte so aussehen:
{ "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }
Es ist nützlich, aber jeder Test erfordert sein eigenes Setup. Stattdessen können wir eine Builder-Klasse erstellen:
public class InvoiceLineBuilder: Faker<InvoiceLine2> { public static InvoiceLineBuilder Default() { var faker = new InvoiceLineBuilder(); faker .RuleFor(x => x.ItemCode, f => f.Random.AlphaNumeric(5)) .RuleFor(x => x.UnitPrice, f => f.Random.Decimal(10, 1000)) .RuleFor(x => x.UnitCount, f => f.Random.Number(1, 5)) .RuleFor(x => x.Vat, f => f.PickRandom(21, 9, 0)); return faker; } }
Die Verwendung würde ungefähr so aussehen:
[Test] public void BogusTest() { var faker = TestDoubles.Bogus.InvoiceLineBuilder .Default() .RuleFor(x => x.ItemCode, f => "S001") .RuleFor(x => x.UnitPrice, f => 100); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Und die Ausgabe:
{ "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }
Aus meiner Sicht ist es etwas ausführlicher als das normale Builder-Muster. Außerdem bin ich kein Fan davon, zufällige Werte zu verwenden. Es ist kein großes Problem, aber es treten Probleme auf, wenn die Eigenschaften einer Klasse mit einem Konstruktor initialisiert werden und dieser keine Setter hat. Dann funktioniert es nicht als Builder und jedes Setup wird statisch.
var faker = new InvoiceLineBuilder(); faker .CustomInstantiator(f => new InvoiceLine( f.Random.AlphaNumeric(5), f.Random.Decimal(10, 1000), f.Random.Number(1, 5), f.PickRandom(21, 9, 0) ) );
Dies ist auch eine sehr beliebte Bibliothek mit über 13,2 Millionen Downloads insgesamt (und 7,2 Millionen für die aktuelle Version). Obwohl sie in letzter Zeit nicht aktiv weiterentwickelt wurde, wurde die letzte Version 2019 veröffentlicht. Im Wesentlichen ist sie Bogus.Faker sehr ähnlich. Es sollte sogar möglich sein, Bogus zur Bereitstellung zufälliger Werte wiederzuverwenden, indem ein bestimmter IPropertyNamer implementiert wird.
Versuchen wir es, ohne irgendwelche Eigenschaften festzulegen:
[Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Es wird die folgende Ausgabe erzeugt:
{ "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }
Das Ziel dieses Beitrags ist es, zu zeigen, wie man eine wiederverwendbare Builder-Klasse erstellt. Fangen wir an:
public class InvoiceLineBuilder { public static ISingleObjectBuilder<InvoiceLine2> Default() { return Builder<InvoiceLine2> .CreateNew() .With(x => x.ItemCode, "S001") .With(x => x.UnitCount, 1) .With(x => x.UnitPrice, 100) .With(x => x.Vat, 21); } }
Und hier ist die Verwendung:
[Test] public void NBuilderTest() { var invoiceLine = TestDoubles.NBuilder.InvoiceLineBuilder .Default() .With(x => x.ItemCode, "S002") .With(x => x.Vat, 9) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Und die Ausgabe:
{ "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
Ähnlich wie bei Bogus.Faker können Sie Werte nicht überschreiben, wenn eine Klasseneigenschaft mit einem Konstruktor festgelegt wird und keinen Setter hat . Wenn Sie versuchen, die With-Methode für eine solche Eigenschaft zu verwenden, schlägt dies mit der folgenden Ausnahme fehl:
System.ArgumentException : Property set method not found.
EasyTdd.Generators.Builder ist ein Nuget- Paket und arbeitet im Tandem mit EasyTdd – der Visual Studio-Erweiterung . Dieses Paket nutzt einen inkrementellen .NET-Quellcodegenerator, um Builder aus Vorlagen zu erstellen, die von der EasyTdd-Erweiterung verwendet werden. Der Builder-Generator verarbeitet Eigenschaftensetter, Konstruktorparameter und eine Kombination aus beidem. Es unterstützt auch generische Parameter.
Dies ist meine bevorzugte Methode zum Erstellen eines Builders. Hier sind die Vorteile im Vergleich zu den anderen Optionen:
Wenn EasyTdd im Visual Studio installiert ist, öffnen Sie das Schnellaktionsmenü der Zielklasse und wählen Sie „Inkrementellen Builder generieren“:
Diese Aktion erstellt eine partielle Builder-Klasse mit dem festgelegten BuilderFor-Attribut:
[EasyTdd.Generators.BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder( () => default, // Set default itemCode value () => default, // Set default unitCount value () => default, // Set default unitPrice value () => default // Set default vat value ); } }
Der Builder-Code selbst wird im Hintergrund generiert und diese partielle Klasse ist für allgemeine/Randfall-Setups vorgesehen. Sie können gerne Standardwerte anstelle von default
festlegen.
Mehr zur Einrichtung und Funktionsweise erfahren Sie hier .
Das Gute daran ist, dass ich Bogus hier verwenden kann, wenn ich Zufallswerte benötige:
public static InvoiceLineBuilder Random() { var f = new Faker(); return new InvoiceLineBuilder( () => f.Random.AlphaNumeric(5), () => f.Random.Decimal(10, 1000), () => f.Random.Number(1, 5), () => f.PickRandom(21, 9, 0) ); }
Verwendung:
[Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Und die Ausgabe:
{ "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
EasyTdd bietet außerdem eine vollständige Builder-Codegenerierung ohne Abhängigkeit zum EasyTdd.Generators Nuget-Paket. Dies ist nützlich, wenn Sie nicht von Drittanbieterbibliotheken abhängig sein möchten oder dürfen. Die Erweiterung generiert den Code und alles befindet sich in Ihrem Projekt, ohne Abhängigkeiten und ohne Bedingungen. Sie können es beliebig ändern, alles liegt bei Ihnen. Dieser Ansatz bietet alle Vorteile des EasyTdd.Generators-Falls, außer der automatischen Regeneration bei Zielklassenänderungen.
In diesem Fall muss der Builder manuell neu generiert werden (ebenfalls mit wenigen Klicks). Dabei werden zwei Dateien generiert, um zu verhindern, dass die Setups bei der Neugenerierung verloren gehen. Eine Datei enthält die Builder-Klassendeklaration mit allen erforderlichen Methoden, die andere ist nur für Setups und zusätzliche Methoden vorgesehen, die nicht neu generiert werden sollen. Die Klasse kann auf ähnliche Weise wie oben beschrieben generiert werden, indem Sie das Schnellaktionsmenü öffnen und auf „Builder generieren“ klicken:
Wenn der Builder bereits generiert ist, bietet das Tool an, die Builder-Klasse zu öffnen oder neu zu generieren:
In diesem Blogbeitrag habe ich das Builder-Muster und seine Verwendung in der testgetriebenen Entwicklung vorgestellt. Außerdem habe ich mehrere Möglichkeiten zur Implementierung gezeigt, angefangen bei der manuellen Implementierung über die Verwendung von Drittanbieterbibliotheken wie Bogus.Faker und NBuilder, inkrementelle Codegeneratoren wie BuilderGenerator und EasyTdd.Generators.Builder bis hin zur Generierung des gesamten Codes durch die EasyTdd Visual Studio-Erweiterung. Jede Methode hat ihre Stärken und Schwächen und funktioniert in einfachen Fällen gut.
Beim Umgang mit unveränderlichen Klassen zeichnet sich EasyTdd jedoch dadurch aus, dass es Eigenschaftsänderungen gleich behandelt, unabhängig davon, ob ein Eigenschaftswert durch einen Setter oder durch einen Konstruktorparameter initialisiert wird. EasyTdd unterstützt Vorlagen und ermöglicht es Ihnen, die Ausgabe Ihren Wünschen entsprechend anzupassen. EasyTdd übertrifft auch andere Methoden zur Implementierung eines Builders aufgrund seiner Implementierungsgeschwindigkeit. Es bietet Tools in Visual Studio, um Dateien mit nur wenigen Klicks automatisch zu generieren, was Zeit und Aufwand spart.
Foto von Markus Spiske auf Unsplash