오늘은 테스트 주도 개발에서 빌더 패턴에 대해 이야기하겠습니다. 이미 테스트를 진행하고 있다면 모든 입력 데이터를 만드는 데 얼마나 많은 시간이 소요되는지 알고 계실 것입니다. 종종 동일한 데이터 집합이나 약간의 차이가 있는 데이터가 시스템 테스트 모음의 여러 테스트에서 사용됩니다. 빌더가 여기서 도움이 됩니다. 두 가지 목적을 제공합니다.
예를 들어 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 ... }
이는 단순한 예일 뿐이지만, 개념을 이해하셨으면 좋겠습니다.
빌더는 크고 복잡한 객체가 있지만 테스트와 관련된 필드가 몇 개뿐인 경우에 유용합니다.
또 다른 유용한 사례는 특정 값에 따라 여러 시나리오를 테스트하려는 경우입니다. 하나를 제외한 모든 속성은 동일하게 유지되고 하나만 변경합니다. 이렇게 하면 서비스나 객체가 다르게 동작하는 차이를 강조하기가 더 쉬워집니다.
첫째, 직접 빌더 클래스를 만들 수 있습니다. 이는 초기 시간이나 돈 투자가 필요 없으며 빌드 방법에 많은 자유가 있습니다. 복사, 붙여넣기 및 바꾸기가 유용할 수 있지만, 특히 대규모 클래스의 경우 여전히 상당한 시간이 걸립니다.
코드 생성을 시작했을 때, 저는 단일 테스트를 설정하는 것으로 시작했습니다. 이 테스트는 실제로 아무것도 테스트하지 않았습니다. 그저 유형을 수락하고, 리플렉션을 사용하여 모든 속성을 검색하고, 하드코딩된 템플릿에서 빌더 클래스를 만들고, 테스트 러너 출력 창에 썼습니다. 제가 해야 할 일은 클래스 파일을 만들고 테스트 러너의 출력 창에서 콘텐츠를 복사/붙여넣기 하는 것뿐이었습니다.
BuilderGenerator에 대한 모든 내용은 여기에서 확인할 수 있습니다. .NET Incremental Source Generator에 대해 설명합니다. 즉, 대상 클래스가 변경될 때 빌더 코드가 라이브로 재생성됩니다. 따라서 위의 방법과 비교했을 때 번거로움이나 수동 작업이 없습니다. 빌더 클래스를 만들고 대상 클래스 유형으로 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); }
기본값, 즉 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와 비슷하게 클래스 속성이 생성자를 사용하여 설정되고 세터가 없는 경우 값을 재정의할 수 없습니다 . 이러한 속성에 With 메서드를 사용하려고 하면 다음 예외와 함께 실패합니다.
System.ArgumentException : Property set method not found.
EasyTdd.Generators.Builder는 Nuget 패키지이며 EasyTdd(Visual Studio Extention) 와 함께 작동합니다. 이 패키지는 .NET 증분 소스 생성기를 활용하여 EasyTdd 확장 프로그램에서 사용하는 템플릿에서 빌더를 만듭니다. 빌더 생성기는 속성 세터, 생성자 매개변수 및 두 가지의 조합을 처리합니다. 또한 일반 매개변수도 지원합니다.
이것은 빌더를 만드는 데 제가 선호하는 방법입니다. 다른 옵션과 비교한 이점은 다음과 같습니다.
Visual Studio에 EasyTdd가 설치되면 대상 클래스에서 빠른 작업 메뉴를 열고 "증분 빌더 생성"을 선택합니다.
이 작업은 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는 속성 값이 세터에 의해 초기화되든 생성자 매개변수를 통해 초기화되든 속성 변경을 동등하게 처리하여 두드러집니다. EasyTdd는 템플릿을 지원하고 선호도에 맞게 출력을 사용자 지정할 수 있습니다. EasyTdd는 또한 구현 속도 때문에 빌더를 구현하는 다른 방법을 능가합니다. Visual Studio에서 도구를 제공하여 몇 번의 클릭만으로 파일을 자동으로 생성하여 시간과 노력을 절약합니다.
Unsplash 에 Markus Spiske 가 찍은 사진