今天,我将讨论测试驱动开发中的构建器模式。如果您已经在进行测试,您可能已经注意到创建所有输入数据是多么耗时。通常,系统测试套件中的许多测试都会使用同一组数据或略有不同的数据。构建器在这里有所帮助。它有两个用途:
为了举例子,我将采用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) ); }
现在我们可以创建上述内容的混合。例如,如果测试用例需要为欧盟客户提供发票,且发票行包含混合增值税,我可以执行以下操作:
[Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }
这只是一个简单的例子,但我希望你能理解这个概念。
当我们有一个大型、复杂的对象,但只有几个字段与测试相关时,构建器很有用。
另一个有用的情况是当我想根据特定值测试多个场景时。除一个属性外,所有属性都保持不变,而我只更改一个属性。这样可以更轻松地突出显示导致服务或对象行为不同的差异。
首先,您可以自己创建一个构建器类。这不需要任何时间或金钱的初始投入,而且您可以自由选择如何构建它。复制、粘贴和替换可能很有用,但这仍然需要相当多的时间,尤其是对于较大的类。
当我开始使用代码生成时,我首先为其设置了一个测试。这个测试实际上并没有测试任何东西;它只是接受一个类型,使用反射检索所有属性,从硬编码模板创建一个构建器类,并将其写入测试运行器输出窗口。我所要做的就是创建一个类文件并从测试运行器的输出窗口复制/粘贴内容。
有关 BuilderGenerator 的所有信息都可以在这里找到。它解释了 .NET 增量源生成器。这意味着当目标类发生变化时,构建器代码会实时重新生成。因此,与上述方法相比,没有任何麻烦或手动工作。只需创建一个构建器类,添加具有目标类类型的BuilderFor
属性,所有With
方法都会自动生成并可供使用。
[BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }
我没有用过太多,但它似乎拥有广泛的用户群,截至撰写本文时下载量已达 82.7K。我注意到几个问题,因此我选择了其他选项:
如果构建器类与目标类位于不同的项目中,则解决方案构建失败。它可以位于另一个项目中,但命名空间必须保持不变。否则,您将看到以下错误:
它不支持构造函数参数,如果目标类没有无参数构造函数,则会失败并出现错误。:
让我们探索一下我们还有哪些其他选择。
这是一个非常受欢迎的库,截至撰写本文时,总下载量超过 82.2M(当前版本为 186.1K)。正如该库的作者所说,它是一个虚假数据生成器,能够根据预定义的规则生成大量对象。它与 TDD 中的构建器模式并不完全相同,但可以进行调整。
有几种使用 Bogus.Faker 的方法,但我将重点介绍如何模仿构建器模式。
创建对象的最简单方法是使用 Bogus.Faker:
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }
它创建了一个具有默认值(即空值和零)的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 }
从我的角度来看,它比常规的 Builder 模式更冗长一些。此外,我不喜欢使用随机值。这不是一个大问题,但是当使用构造函数初始化类的属性并且它没有 setter 时,就会出现问题。然后它就不能用作构建器了,并且每个设置都变成静态的。
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) ) );
这也是一个非常受欢迎的库,总下载量超过 1320 万次(当前版本为 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 案例的所有优点,除了目标类更改时自动重新生成。
在这种情况下,需要手动重新生成构建器(也只需单击几下即可)。生成两个文件以避免在重新生成时丢失设置。一个文件包含构建器类声明以及所有必要的方法,另一个文件仅用于设置和附加方法,不用于重新生成。可以通过打开快速操作菜单并单击“生成构建器”以与上述类似的方式生成类:
当构建器已生成时,该工具将打开构建器类或重新生成:
在这篇博文中,我介绍了构建器模式及其在测试驱动开发中的应用。我还展示了几种实现方法,从手动实现开始,使用第三方库(如 Bogus.Faker 和 NBuilder)、增量代码生成器(如 BuilderGenerator 和 EasyTdd.Generators.Builder),最后使用 EasyTdd Visual Studio 扩展生成所有代码。每种方法都有其优点和缺点,在简单情况下效果很好。
然而,在处理不可变类时,EasyTdd 脱颖而出,无论属性值是通过 setter 还是通过构造函数参数初始化,它都能平等地处理属性更改。EasyTdd 支持模板,并允许您自定义输出以符合您的偏好。EasyTdd 还因其实现速度而超越了其他实现构建器的方法。它在 Visual Studio 中提供了工具,只需单击几下即可自动生成文件,从而节省时间和精力。
照片由Unsplash上的Markus Spiske拍摄