今日は、テスト駆動開発におけるビルダー パターンについてお話します。すでにテストに取り組んでいる場合は、すべての入力データを作成するのにどれほど時間がかかるかお気づきでしょう。多くの場合、システムのテスト スイートでは、同じデータ セット、またはわずかに異なるデータが、多くのテストで使用されます。ビルダーはここで役立ちます。ビルダーには次の 2 つの目的があります。
例としてInvoice
クラスを取り上げます。非常に簡略化されたバージョンは次のようになります。
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); }
Invoice
オブジェクトを作成するには、 Invoice
とInvoiceLine
のコンストラクターに多くの値を提供する必要があります。多くの場合、特定のテストに関連するのはプロパティの一部だけです。ここで、ビルダーが役立ちます。
InvoiceLine
のビルダーは次のようになります。
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; } }
Invoice
ビルダーは次のようになります。
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; } }
テストで合計価格プロパティのためだけにInvoice
オブジェクトが必要な場合は、次のようにInvoice
を作成できます。
var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );
合計価格は請求書の明細を合計して計算され、請求書の明細のデフォルトの単位数は 1 なので、請求書の明細の単価を設定するだけで十分です。複数のテストで同様の機能が必要な場合は、さらに進んで、 InvoiceBuilder
に次のメソッドを追加できます。
public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }
上で述べたように、ビルダー クラスは、クラスのすべての一般的なケースとエッジ ケースを収集するのに最適な場所です。ここでは、考えられるケースをいくつか示します。
私の観点からすると、システムが処理するさまざまなケースに関する知識を収集するのに最適な場所です。新しい開発者がシステムで管理する必要があるものを理解するのに役立つ知識ベースとして機能します。私がその分野に不慣れな場合は、起こり得るエッジケースについて考えることすらできないかもしれません。次に、上記のケースのいくつかからのコード例を示します。
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) ); }
これで、上記の組み合わせを作成できます。たとえば、テスト ケースで、請求書の明細に VAT が混在する EU の顧客向けの請求書が必要な場合は、次の操作を実行できます。
[Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }
これは単なる簡単な例ですが、概念を理解していただければ幸いです。
ビルダーは、大規模で複雑なオブジェクトがあり、テストに関連するフィールドが少数の場合に便利です。
もう 1 つの便利なケースは、特定の値に基づいて複数のシナリオをテストする場合です。1 つを除くすべてのプロパティは同じままで、1 つだけを変更します。これにより、サービスまたはオブジェクトの動作が異なる原因となる違いを強調表示しやすくなります。
まず、ビルダー クラスを自分で作成できます。これには、時間やお金の初期投資は必要なく、構築方法の自由度も高くなります。コピー、貼り付け、置換は便利ですが、特に大規模なクラスの場合は、かなりの時間がかかります。
コード生成を始めたとき、まずは 1 つのテストを設定することから始めました。このテストでは、実際には何もテストしませんでした。型を受け入れ、リフレクションを使用してすべてのプロパティを取得し、ハードコードされたテンプレートからビルダー クラスを作成し、それをテスト ランナーの出力ウィンドウに書き込むだけでした。私がしなければならなかったのは、クラス ファイルを作成し、テスト ランナーの出力ウィンドウからコンテンツをコピーして貼り付けることだけでした。
BuilderGenerator の詳細については、こちらを参照してください。.NET インクリメンタル ソース ジェネレーターについて説明しています。つまり、ターゲット クラスが変更されると、ビルダー コードがライブで再生成されます。したがって、上記の方法に比べて面倒なことや手作業はありません。ビルダー クラスを作成し、ターゲット クラス タイプでBuilderFor
属性を追加するだけで、すべてのWith
メソッドが自動的に生成され、使用できるようになります。
[BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }
私はあまり使っていませんが、執筆時点で 82.7K のダウンロード数があり、幅広いユーザーベースがあるようです。他のオプションを選択する理由となった問題がいくつかありました。
ビルダー クラスがターゲット クラスと異なるプロジェクトにある場合、ソリューションはビルドに失敗します。別のプロジェクトにあることは可能ですが、名前空間は同じである必要があります。そうでない場合、次のエラーが表示されます。
コンストラクター パラメーターをサポートしておらず、ターゲット クラスにパラメーターなしのコンストラクターがない場合、エラーが発生します。
他にどのような選択肢があるか調べてみましょう。
これは非常に人気のあるライブラリで、執筆時点で合計 8220 万回以上ダウンロードされています (現在のバージョンでは 186.1K)。ライブラリの作者が述べているように、これは事前定義されたルールに基づいて多数のオブジェクトを生成できる偽のデータ ジェネレータです。TDD のビルダー パターンとまったく同じではありませんが、適応できます。
Bogus.Faker を使用する方法はいくつかありますが、ここではビルダー パターンを模倣する方法に焦点を当てます。
オブジェクトを作成する最も簡単な方法は、Bogus.Faker を使用することです。
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }
デフォルト値(null と 0)を持つInvoiceLine2
のインスタンスを作成します。値を設定するには、次の設定を使用します。
[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); }
上記のコードは、ランダムな値を持つ請求書明細オブジェクトを作成します。例は次のようになります。
{ "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }
これは便利ですが、テストごとに独自の設定が必要です。代わりに、ビルダー クラスを作成できます。
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; } }
使用法は次のようになります。
[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); }
出力は次のようになります。
{ "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }
私の観点からすると、これは通常のビルダー パターンよりも少し冗長です。また、ランダム値を使用するのも好きではありません。大きな問題ではありませんが、クラスのプロパティがコンストラクターを使用して初期化され、セッターがない場合に問題が発生します。その場合、ビルダーとして機能せず、各セットアップが静的になります。
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) ) );
これも非常に人気のあるライブラリで、合計ダウンロード数は 1,320 万回を超えています (現在のバージョンでは 720 万回)。最近は積極的に開発されていませんが、最後のバージョンは 2019 年にリリースされました。本質的には、Bogus.Faker と非常によく似ています。特定の IPropertyNamer を実装することで、Bogus を再利用してランダムな値を提供することも可能になります。
プロパティを設定せずに使用してみましょう。
[Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
次の出力が生成されます:
{ "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }
この投稿の目的は、再利用可能なビルダー クラスを作成する方法を示すことです。 では始めましょう:
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); } }
使用方法は次のとおりです。
[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); }
出力は次のようになります。
{ "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
Bogus.Faker と同様に、クラス プロパティがコンストラクターを使用して設定され、 setter がない場合は、値をオーバーライドできません。このようなプロパティに With メソッドを使用しようとすると、次の例外が発生して失敗します。
System.ArgumentException : Property set method not found.
EasyTdd.Generators.Builder は NuGet パッケージであり、 EasyTdd (Visual Studio 拡張機能)と連携して動作します。このパッケージは、.NET 増分ソース ジェネレーターを活用して、EasyTdd 拡張機能で使用されるテンプレートからビルダーを作成します。ビルダー ジェネレーターは、プロパティ セッター、コンストラクター パラメーター、および両方の組み合わせを処理します。また、ジェネリック パラメーターもサポートします。
これは私がビルダーを作成する際に好んで使う方法です。他のオプションと比較した利点は次のとおりです。
EasyTdd が Visual Studio にインストールされたら、ターゲット クラスのクイック アクション メニューを開き、「増分ビルダーの生成」を選択します。
このアクションは、BuilderFor 属性が設定された部分ビルダー クラスを作成します。
[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 ); } }
ビルダー コード自体はバックグラウンドで生成され、この部分クラスは一般的な/エッジ ケースの設定を目的としています。 default
の代わりにデフォルト値を自由に設定してください。
設定方法や仕組みの詳細については、こちらをご覧ください。
良い点は、ランダムな値が必要な場合、ここで Bogus を使用できることです。
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) ); }
使用法:
[Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
出力は次のようになります。
{ "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
EasyTdd は、EasyTdd.Generators Nuget パッケージに依存しない完全なビルダー コード生成も提供します。これは、サードパーティ ライブラリに依存したくない場合や、依存が許可されていない場合に便利です。拡張機能はコードを生成し、すべてがプロジェクト内に含まれます。依存関係や制約はありません。自由に変更してください。すべてあなたのものです。このアプローチは、ターゲット クラスの変更時に自動的に再生成されることを除き、EasyTdd.Generators の場合のすべての利点を提供します。
この場合、ビルダーを手動で再生成する必要があります (これも数回クリックするだけです)。再生成時にセットアップが失われないように、2 つのファイルが生成されます。1 つのファイルには、必要なすべてのメソッドを含むビルダー クラスの宣言が含まれ、もう 1 つのファイルは、再生成を意図していないセットアップと追加のメソッドのみを対象としています。クラスは、上記と同様に、クイック アクション メニューを開いて [ビルダーの生成] をクリックすることで生成できます。
ビルダーがすでに生成されている場合、ツールはビルダー クラスを開くか、再生成するかを提案します。
このブログ記事では、ビルダー パターンとテスト駆動開発でのその使用法を紹介しました。また、手動実装から、Bogus.Faker や NBuilder などのサードパーティ ライブラリの使用、BuilderGenerator や EasyTdd.Generators.Builder などの増分コード ジェネレーターの使用、そして最後にすべてのコードを EasyTdd Visual Studio 拡張機能で生成するといった、いくつかの実装方法も示しました。各方法には長所と短所があり、単純なケースではうまく機能します。
ただし、不変クラスを扱う場合、EasyTdd は、プロパティ値がセッターによって初期化されるか、コンストラクター パラメーターによって初期化されるかに関係なく、プロパティの変更を平等に処理することで際立っています。EasyTdd はテンプレートをサポートしており、好みに合わせて出力をカスタマイズできます。また、EasyTdd は実装のスピードが速いため、ビルダーを実装する他の方法よりも優れています。Visual Studio にツールが用意されており、数回クリックするだけでファイルを自動的に生成できるため、時間と労力を節約できます。
UnsplashのMarkus Spiskeによる写真