Hôm nay, tôi sẽ nói về mẫu builder trong phát triển theo hướng kiểm thử. Nếu bạn đã làm việc với các bài kiểm thử, có lẽ bạn đã nhận thấy việc tạo tất cả dữ liệu đầu vào có thể tốn thời gian như thế nào. Thông thường, cùng một tập dữ liệu hoặc dữ liệu có một số khác biệt nhỏ được sử dụng trong nhiều bài kiểm thử trong bộ kiểm thử của hệ thống. Builder giúp ích ở đây. Nó phục vụ hai mục đích:
Để làm ví dụ, tôi sẽ lấy lớp Invoice
, phiên bản đơn giản nhất có thể như thế này:
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); }
Để tạo đối tượng Invoice
, tôi phải cung cấp nhiều giá trị cho các constructor của Invoice
và InvoiceLine
. Trong nhiều trường hợp, chỉ một phần thuộc tính có liên quan đến các bài kiểm tra cụ thể. Ở đây, các builder sẽ vào cuộc để giúp đỡ.
Trình tạo cho InvoiceLine
có thể trông giống như thế này:
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; } }
Trình tạo Invoice
có thể trông giống như thế này:
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; } }
Trong trường hợp khi một bài kiểm tra cần một đối tượng Invoice
chỉ cho thuộc tính total price, thì Invoice
có thể được tạo như thế này:
var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );
Vì tổng giá được tính bằng cách cộng các dòng hóa đơn và số lượng đơn vị mặc định cho dòng hóa đơn là 1, thì chỉ cần đặt giá đơn vị cho dòng hóa đơn là đủ. Nếu cần chức năng tương tự trong nhiều lần kiểm tra, chúng ta có thể tiến xa hơn và thêm phương thức sau vào InvoiceBuilder
:
public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }
Như đã đề cập ở trên, lớp builder là nơi tuyệt vời để thu thập tất cả các trường hợp phổ biến và trường hợp ngoại lệ cho lớp. Sau đây, tôi sẽ cung cấp một số trường hợp có thể xảy ra:
Theo quan điểm của tôi, đây là nơi tuyệt vời để thu thập kiến thức về các trường hợp khác nhau mà hệ thống của chúng tôi xử lý. Đây là cơ sở kiến thức hữu ích cho các nhà phát triển mới để hiểu hệ thống cần quản lý những gì. Nếu tôi mới vào nghề, tôi thậm chí có thể không nghĩ đến các trường hợp ngoại lệ có thể xảy ra. Sau đây là một ví dụ mã từ một số trường hợp được đề cập ở trên:
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) ); }
Bây giờ chúng ta có thể tạo hỗn hợp các mục trên. Ví dụ, nếu một trường hợp thử nghiệm cần hóa đơn cho khách hàng EU với các dòng hóa đơn có VAT hỗn hợp, tôi có thể thực hiện như sau:
[Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }
Đây chỉ là một ví dụ đơn giản nhưng tôi hy vọng bạn hiểu được khái niệm này.
Trình xây dựng hữu ích khi chúng ta có một đối tượng lớn, phức tạp nhưng chỉ có một vài trường có liên quan đến bài kiểm tra.
Một trường hợp hữu ích khác là khi tôi muốn kiểm tra nhiều kịch bản dựa trên các giá trị cụ thể. Tất cả các thuộc tính ngoại trừ một thuộc tính vẫn giữ nguyên và tôi chỉ thay đổi một thuộc tính. Điều này giúp dễ dàng làm nổi bật sự khác biệt, khiến dịch vụ hoặc đối tượng hoạt động khác đi.
Đầu tiên, bạn có thể tự tạo một lớp xây dựng. Điều này không đòi hỏi bất kỳ khoản đầu tư ban đầu nào về thời gian hoặc tiền bạc, và bạn có rất nhiều quyền tự do trong cách xây dựng nó. Sao chép, dán và thay thế có thể hữu ích, nhưng vẫn mất khá nhiều thời gian, đặc biệt là đối với các lớp lớn hơn.
Khi tôi bắt đầu tạo mã, tôi bắt đầu bằng cách thiết lập một bài kiểm tra duy nhất cho nó. Bài kiểm tra này thực sự không kiểm tra bất cứ thứ gì; nó chỉ chấp nhận một kiểu, lấy tất cả các thuộc tính bằng cách sử dụng phản chiếu, tạo một lớp xây dựng từ một mẫu được mã hóa cứng và ghi nó vào cửa sổ đầu ra của trình chạy thử nghiệm. Tất cả những gì tôi phải làm là tạo một tệp lớp và sao chép/dán nội dung từ cửa sổ đầu ra của trình chạy thử nghiệm.
Tất cả thông tin về BuilderGenerator có thể được tìm thấy tại đây . Nó giải thích về .NET Incremental Source Generator. Điều này có nghĩa là mã xây dựng được tạo lại trực tiếp khi lớp mục tiêu thay đổi. Vì vậy, không có rắc rối hoặc công việc thủ công nào so với các phương pháp trên. Chỉ cần tạo một lớp xây dựng, thêm thuộc tính BuilderFor
với loại lớp mục tiêu và tất cả các phương pháp With
đều được tạo tự động và sẵn sàng để sử dụng.
[BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }
Tôi chưa làm việc nhiều với nó, nhưng có vẻ như nó có lượng người dùng lớn với 82,7 nghìn lượt tải xuống tại thời điểm viết bài. Tôi nhận thấy một vài vấn đề khiến tôi phải chọn các tùy chọn khác:
Giải pháp không thể xây dựng nếu lớp xây dựng nằm trong một dự án khác với lớp mục tiêu. Nó có thể nằm trong một dự án khác, nhưng không gian tên phải giữ nguyên. Nếu không, bạn sẽ thấy các lỗi sau::
Nó không hỗ trợ các tham số của hàm tạo và sẽ báo lỗi nếu lớp mục tiêu không có hàm tạo không tham số.:
Hãy cùng khám phá những lựa chọn khác mà chúng ta có.
Đây là một thư viện rất phổ biến với hơn 82,2 triệu lượt tải xuống (và 186,1 nghìn lượt tải xuống cho phiên bản hiện tại) tại thời điểm viết bài. Theo tác giả của thư viện, đây là trình tạo dữ liệu giả có khả năng tạo ra nhiều đối tượng dựa trên các quy tắc được xác định trước. Nó không hoàn toàn giống với mẫu xây dựng trong TDD, nhưng có thể được điều chỉnh.
Có nhiều cách để sử dụng Bogus.Faker, nhưng tôi sẽ tập trung vào cách mô phỏng mẫu xây dựng ở đây.
Cách đơn giản nhất để tạo ra một đối tượng là sử dụng Bogus. Faker là:
[Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }
Nó tạo ra một thể hiện của InvoiceLine2
với các giá trị mặc định, nghĩa là null và zero. Để thiết lập một số giá trị, tôi sẽ sử dụng thiết lập sau:
[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); }
Mã ở trên tạo ra một đối tượng dòng hóa đơn với các giá trị ngẫu nhiên. Một ví dụ có thể trông như thế này:
{ "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }
Nó hữu ích, nhưng mỗi bài kiểm tra yêu cầu thiết lập riêng. Thay vào đó, chúng ta có thể tạo một lớp xây dựng:
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; } }
Cách sử dụng sẽ trông giống như thế này:
[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); }
Và kết quả đầu ra:
{ "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }
Theo quan điểm của tôi, nó dài dòng hơn một chút so với Builder Pattern thông thường. Ngoài ra, tôi không thích sử dụng các giá trị ngẫu nhiên. Đây không phải là vấn đề lớn, nhưng vấn đề phát sinh khi các thuộc tính của lớp được khởi tạo bằng một constructor và nó không có setter. Khi đó, nó không hoạt động như một builder và mỗi thiết lập trở thành static.
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) ) );
Đây cũng là một thư viện rất phổ biến với hơn 13,2 triệu lượt tải xuống (và 7,2 triệu lượt tải xuống cho phiên bản hiện tại). Mặc dù nó chưa được phát triển tích cực gần đây, phiên bản cuối cùng đã được phát hành vào năm 2019. Về cơ bản, nó rất giống với Bogus.Faker. Thậm chí có thể sử dụng lại Bogus để cung cấp các giá trị ngẫu nhiên bằng cách triển khai một IPropertyNamer cụ thể.
Hãy thử sử dụng nó mà không thiết lập bất kỳ thuộc tính nào:
[Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Nó tạo ra kết quả sau::
{ "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }
Mục đích của bài đăng này là chỉ ra cách tạo một lớp xây dựng có thể tái sử dụng. Hãy bắt đầu:
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); } }
Và đây là cách sử dụng:
[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); }
Và kết quả đầu ra:
{ "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
Tương tự như Bogus.Faker, bạn không thể ghi đè giá trị nếu thuộc tính lớp được thiết lập bằng cách sử dụng constructor và không có setter . Nếu bạn thử sử dụng phương thức With cho thuộc tính như vậy, nó sẽ không thành công với ngoại lệ sau:
System.ArgumentException : Property set method not found.
EasyTdd.Generators.Builder là một gói Nuget và hoạt động song song với EasyTdd - Visual Studio Extension . Gói này tận dụng trình tạo nguồn gia tăng .NET để tạo trình xây dựng từ các mẫu được sử dụng bởi tiện ích mở rộng EasyTdd. Trình tạo trình xây dựng xử lý các trình thiết lập thuộc tính, tham số trình xây dựng và kết hợp cả hai. Nó cũng hỗ trợ các tham số chung.
Đây là cách tôi thích để tạo ra một trình xây dựng. Sau đây là những lợi ích so với các tùy chọn khác:
Khi EasyTdd được cài đặt trong Visual Studio, hãy mở menu hành động nhanh trên lớp mục tiêu và chọn "Generate Incremental Builder":
Hành động này tạo ra một lớp xây dựng một phần với thuộc tính BuilderFor được đặt:
[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 ); } }
Bản thân mã xây dựng được tạo ra ở chế độ nền và lớp một phần này dành cho các thiết lập trường hợp chung/ngoại lệ. Bạn có thể thoải mái đặt giá trị mặc định thay vì default
.
Bạn có thể tìm hiểu thêm về cách thiết lập và cách thức hoạt động tại đây .
Điểm hay là nếu tôi cần các giá trị ngẫu nhiên, tôi có thể sử dụng Bogus ở đây:
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) ); }
Cách sử dụng:
[Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }
Và kết quả đầu ra:
{ "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }
EasyTdd cũng cung cấp khả năng tạo mã xây dựng đầy đủ mà không phụ thuộc vào gói EasyTdd.Generators Nuget. Điều này hữu ích nếu bạn không muốn hoặc không được phép phụ thuộc vào các thư viện của bên thứ ba. Tiện ích mở rộng tạo mã và tất cả đều nằm trong dự án của bạn, không phụ thuộc, không ràng buộc. Hãy thoải mái sửa đổi tất cả là của bạn. Phương pháp này cung cấp tất cả các lợi ích của trường hợp EasyTdd.Generators, ngoại trừ việc tự động tái tạo khi thay đổi lớp mục tiêu.
Trong trường hợp này, trình xây dựng cần được tạo lại thủ công (cũng bằng một vài cú nhấp chuột). Hai tệp được tạo ra để tránh mất các thiết lập khi tái tạo. Một tệp chứa khai báo lớp trình xây dựng, với tất cả các phương thức cần thiết, tệp còn lại chỉ dành cho các thiết lập và phương thức bổ sung, không dành cho tái tạo. Lớp có thể được tạo theo cách tương tự như trên, bằng cách mở menu hành động nhanh và nhấp vào "Tạo trình xây dựng":
Khi trình xây dựng đã được tạo, công cụ sẽ cung cấp tùy chọn mở lớp trình xây dựng hoặc tạo lại:
Trong bài đăng trên blog này, tôi đã giới thiệu mẫu xây dựng và cách sử dụng mẫu này trong phát triển theo hướng kiểm thử. Tôi cũng chỉ ra một số cách để triển khai mẫu này, bắt đầu từ triển khai thủ công, sử dụng các thư viện của bên thứ ba như Bogus.Faker và NBuilder, các trình tạo mã gia tăng như BuilderGenerator và EasyTdd.Generators.Builder và cuối cùng là tạo tất cả mã bằng tiện ích mở rộng EasyTdd Visual Studio. Mỗi phương pháp đều có điểm mạnh và điểm yếu riêng và hoạt động tốt trong các trường hợp đơn giản.
Tuy nhiên, khi xử lý các lớp bất biến, EasyTdd nổi bật nhờ xử lý các thay đổi thuộc tính như nhau, bất kể giá trị thuộc tính được khởi tạo bởi một setter hay thông qua một tham số constructor. EasyTdd hỗ trợ các mẫu và cho phép bạn tùy chỉnh đầu ra để phù hợp với sở thích của mình. EasyTdd cũng vượt trội hơn các phương pháp khác để triển khai một builder do tốc độ triển khai của nó. Nó cung cấp các công cụ trong Visual Studio để tạo tệp tự động chỉ bằng một vài cú nhấp chuột, tiết kiệm thời gian và công sức.
Ảnh của Markus Spiske trên Bapt