paint-brush
Presentamos Builder: su aliado en el desarrollo basado en pruebas (TDD)por@easytdd
482 lecturas
482 lecturas

Presentamos Builder: su aliado en el desarrollo basado en pruebas (TDD)

por Kazys Račkauskas21m2024/08/23
Read on Terminal Reader

Demasiado Largo; Para Leer

Builder: su compañero en el desarrollo basado en pruebas (TDD) El constructor permite a los desarrolladores construir objetos de datos de prueba paso a paso, utilizando una interfaz fluida que mejora la legibilidad y reduce la verbosidad. La clase Builder es un excelente lugar para definir y recopilar todos los objetos comunes y de casos extremos. En muchos casos, solo una parte de las propiedades son relevantes para pruebas específicas.
featured image - Presentamos Builder: su aliado en el desarrollo basado en pruebas (TDD)
Kazys Račkauskas HackerNoon profile picture

Patrón de constructor

Hoy hablaré sobre el patrón Builder en el desarrollo basado en pruebas. Si ya trabajas con pruebas, probablemente habrás notado lo lento que puede ser crear todos los datos de entrada. A menudo, se utiliza el mismo conjunto de datos, o datos con ligeras diferencias, en muchas pruebas de la suite de pruebas de un sistema. El patrón Builder ayuda en este caso. Tiene dos propósitos:


  • El constructor permite a los desarrolladores construir objetos de datos de prueba paso a paso, utilizando una interfaz fluida que mejora la legibilidad y reduce la verbosidad.


  • La clase constructora es un lugar excelente para definir y recopilar todos los objetos comunes y de casos extremos. Por ejemplo, para un Pasajero, podría ser un Hombre, una Mujer, un Niño, una Niña, un Bebé, etc. Para un Itinerario, podría ser un Viaje de ida, un Viaje de ida y vuelta, Directo, Indirecto, etc.


A modo de ejemplo tomaré la clase Invoice , una versión muy simplificada podría ser algo como esto:

 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); }

Para crear un objeto Invoice , tengo que proporcionar muchos valores a los constructores de Invoice y InvoiceLine . En muchos casos, solo una parte de las propiedades son relevantes para pruebas específicas. En este caso, los constructores son de gran ayuda.


El constructor de InvoiceLine podría verse así:

 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; } }


El generador de Invoice podría verse así:

 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; } }


En caso de que una prueba necesite un objeto Invoice solo por su propiedad de precio total, entonces Invoice se puede crear de esta manera:

 var invoice = InvoiceBuilder .Default() .WithLines( InvoiceLineBuilder .Default .WithUnitPrice(158) );


Como el precio total se calcula sumando las líneas de factura y el recuento de unidades predeterminado para la línea de factura es 1, entonces es suficiente establecer el precio unitario para la línea de factura. Si se necesita una funcionalidad similar en varias pruebas, podríamos ir más allá y agregar el siguiente método a InvoiceBuilder :

 public static InvoiceBuilder DefaultWithTotalPrice(decimal totalPrice) { return new InvoiceBuilder( "S001", DateTime.Parse("2023-01-01"), new[] { InvoiceLineBuilder .Default() .WithUnitPrice(totalPrice) .Build() } ); }

Colección de configuraciones predefinidas

Como se mencionó anteriormente, la clase constructora es un excelente lugar para recopilar todos los casos comunes y extremos de la clase. Aquí, proporcionaré algunos de esos posibles casos:


  • Una factura con artículos que tienen IVA regular
  • Una factura con artículos con IVA reducido
  • Una factura con artículos que tienen IVA mixto
  • Una factura a un país de la UE
  • Una factura a un país de NA
  • Una factura para China


Desde mi punto de vista, es un gran lugar para recopilar conocimientos sobre los diferentes casos que maneja nuestro sistema. Sirve como una base de conocimientos útil para que los nuevos desarrolladores comprendan lo que el sistema necesita gestionar. Si soy nuevo en un campo, es posible que ni siquiera piense en posibles casos extremos. A continuación, se muestra un ejemplo de código de algunos de los casos mencionados anteriormente:

 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) ); }

Ahora podemos crear una combinación de los elementos anteriores. Por ejemplo, si un caso de prueba necesita una factura para un cliente de la UE con líneas de factura que tienen IVA combinado, puedo hacer lo siguiente:

 [Test] public void SomeTest() { //arrange var invoice = InvoiceBuilder .ForEU() .WithMixedVat(); //act ... //assert ... }

Este es sólo un ejemplo simple, pero espero que entiendas el concepto.


Un constructor es útil cuando tenemos un objeto grande y complejo, pero solo unos pocos campos son relevantes para la prueba.


Otro caso útil es cuando quiero probar varios escenarios en función de valores específicos. Todas las propiedades, excepto una, permanecen iguales y solo cambio una. Esto hace que sea más fácil resaltar la diferencia, que hace que el servicio u objeto se comporte de manera diferente.

Formas de crear la clase Builder

Codifica con tus propias manos

En primer lugar, puedes crear una clase de constructor por tu cuenta. Esto no requiere ninguna inversión inicial de tiempo o dinero, y tienes mucha libertad para crearla. Copiar, pegar y reemplazar puede ser útil, pero aún así lleva bastante tiempo, especialmente para clases más grandes.

Crea tu propio generador de código

Cuando comencé con la generación de código, comencé configurando una sola prueba para ello. Esta prueba en realidad no probaba nada; solo aceptaba un tipo, recuperaba todas las propiedades mediante la reflexión, creaba una clase de generador a partir de una plantilla codificada y la escribía en la ventana de salida del ejecutor de pruebas. Todo lo que tenía que hacer era crear un archivo de clase y copiar y pegar el contenido de la ventana de salida del ejecutor de pruebas.

Generador de constructores

Aquí encontrará toda la información sobre BuilderGenerator. Explica el generador de código fuente incremental .NET. Esto significa que el código del generador se regenera en vivo cuando cambia la clase de destino. Por lo tanto, no hay problemas ni trabajo manual en comparación con los métodos anteriores. Simplemente cree una clase de generador, agregue el atributo BuilderFor con el tipo de clase de destino y todos los métodos With se generarán automáticamente y estarán listos para usar.

 [BuilderFor(typeof(InvoiceLine))] public partial class InvoiceLineBuilder { public static InvoiceLineBuilder Default() { return new InvoiceLineBuilder() .WithItemCode("S001") .WithUnitCount(1); } }

No he trabajado mucho con él, pero parece tener una amplia base de usuarios con 82,7 mil descargas al momento de escribir esto. Noté un par de problemas que me hicieron elegir otras opciones:


  • La solución no se puede compilar si la clase de compilación está en un proyecto diferente al de la clase de destino. Puede estar en otro proyecto, pero el espacio de nombres debe seguir siendo el mismo. De lo contrario, verá los siguientes errores:

  • No admite parámetros de constructor y falla con errores si la clase de destino no tiene un constructor sin parámetros.

Exploremos qué otras opciones tenemos.

Generador de Bogus.Faker

Esta es una biblioteca muy popular con más de 82,2 millones de descargas totales (y 186,1 mil para la versión actual) al momento de escribir este artículo. Como afirma el autor de la biblioteca, es un generador de datos falso capaz de producir numerosos objetos basados en reglas predefinidas. No es exactamente lo que es el patrón de construcción en TDD, pero se puede adaptar.


Hay varias formas de utilizar Bogus.Faker, pero aquí me centraré en cómo imitar el patrón del constructor.


La forma más sencilla de crear un objeto con Bogus.Faker es:

 [Test] public void BogusTest() { var faker = new Faker<InvoiceLine2>(); var invoiceLine = faker.Generate(); Assert.IsNotNull(invoiceLine); }


Crea una instancia de InvoiceLine2 con valores predeterminados, es decir, valores nulos y ceros. Para establecer algunos valores, utilizaré la siguiente configuración:

 [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); }


El código anterior crea un objeto de línea de factura con valores aleatorios. Un ejemplo podría ser el siguiente:

 { "ItemCode": "gwg7y", "UnitCount": 3.0, "UnitPrice": 597.035612417891230, "Vat": 0.0, "TotalPrice": 1791.106837253673690 }


Es útil, pero cada prueba requiere su propia configuración. En su lugar, podemos crear una clase constructora:

 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; } }


El uso sería algo así:

 [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); }


Y el resultado:

 { "ItemCode": "S001", "UnitCount": 2.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 218.00 }

Desde mi punto de vista, es un poco más detallado que el patrón Builder normal. Además, no soy partidario de utilizar valores aleatorios. No es un gran problema, pero surgen problemas cuando las propiedades de una clase se inicializan mediante un constructor y no tiene configuradores. Entonces no funciona como constructor y cada configuración se vuelve estática.

 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) ) );

Constructor N

Esta también es una biblioteca muy popular con más de 13,2 millones de descargas totales (y 7,2 millones para la versión actual). Aunque no se ha desarrollado activamente recientemente, la última versión se lanzó en 2019. Básicamente, es muy similar a Bogus.Faker. Incluso debería ser posible reutilizar Bogus para proporcionar valores aleatorios implementando un IPropertyNamer específico.


Intentemos usarlo sin configurar ninguna propiedad:

 [Test] public void NBuilderTest() { var invoiceLine = Builder<InvoiceLine2> .CreateNew() .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


Produce el siguiente resultado:

 { "ItemCode": "ItemCode1", "UnitCount": 1.0, "UnitPrice": 1.0, "Vat": 1.0, "TotalPrice": 1.01 }


El objetivo de esta publicación es mostrar cómo crear una clase de constructor reutilizable. Comencemos:

 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); } }


Y aquí está el uso:

 [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); }


Y el resultado:

 { "ItemCode": "S002", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }


De manera similar a Bogus.Faker, no se pueden anular valores si una propiedad de clase se establece mediante un constructor y no tiene un definidor . Si intenta utilizar el método With para una propiedad de este tipo, fallará con la siguiente excepción:

 System.ArgumentException : Property set method not found.

Generadores EasyTdd.Builder

EasyTdd.Generators.Builder es un paquete Nuget y funciona en conjunto con EasyTdd, la extensión de Visual Studio . Este paquete aprovecha un generador de código fuente incremental .NET para crear generadores a partir de plantillas utilizadas por la extensión EasyTdd. El generador de generadores maneja los establecedores de propiedades, los parámetros del constructor y una combinación de ambos. También admite parámetros genéricos.


Esta es mi forma preferida de crear un constructor. Estos son los beneficios en comparación con las otras opciones:

  • La clase constructora se genera con solo unos pocos clics.


  • Se utiliza un generador de código fuente incremental para la generación de la clase de compilación. Esto hace que la clase de compilación se actualice automáticamente con cada cambio en la clase de código fuente.


  • Soporte para creación de plantillas. Puedes adaptar fácilmente la plantilla a mis necesidades.


  • Soporte perfecto para clases que pueden inicializarse utilizando parámetros de constructor, propiedades de establecedor o una combinación de ambos.


  • Soporte de clase genérica.


Cuando EasyTdd esté instalado en Visual Studio, abra el menú de acciones rápidas en la clase de destino y seleccione "Generar generador incremental":

Esta acción crea una clase de generador parcial con el atributo BuilderFor establecido:

 [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 ); } }

El código del generador se genera en segundo plano y esta clase parcial está destinada a configuraciones de casos comunes o extremos. No dude en establecer valores predeterminados en lugar de default .


Puede encontrar más información sobre cómo configurarlo y cómo funciona aquí .


La parte buena es que si necesito valores aleatorios, puedo usar Bogus aquí:

 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) ); }


Uso:

 [Test] public void EasyTddBuilder() { var invoiceLine = TestDoubles.Builders.InvoiceLineBuilder .Random() .WithUnitPrice(100) .WithUnitCount(1) .Build(); Assert.IsNotNull(invoiceLine); ToJson(invoiceLine); }


Y el resultado:

 { "ItemCode": "ana0i", "UnitCount": 1.0, "UnitPrice": 100.0, "Vat": 9.0, "TotalPrice": 109.00 }

EasyTdd puro

EasyTdd también ofrece la generación completa de código de compilación sin la dependencia del paquete Nuget EasyTdd.Generators. Esto es útil si no desea o no se le permite depender de bibliotecas de terceros. La extensión genera el código y todo está en su proyecto, sin dependencias ni ataduras. Siéntase libre de modificarlo, todo es suyo. Este enfoque ofrece todos los beneficios del caso EasyTdd.Generators, excepto la regeneración automática en caso de cambios en la clase de destino.


En este caso, el generador debe regenerarse manualmente (también con unos pocos clics). Se generan dos archivos para evitar perder las configuraciones en la regeneración. Un archivo contiene la declaración de la clase del generador, con todos los métodos necesarios, el otro está destinado únicamente a las configuraciones y métodos adicionales, que no están destinados a la regeneración. La clase se puede generar de forma similar a la anterior, abriendo el menú de acciones rápidas y haciendo clic en "Generar generador":

Cuando el constructor ya está generado, la herramienta ofrece abrir la clase constructora o regenerarla:

Resumen

En esta entrada del blog, presenté el patrón de construcción y su uso en el desarrollo basado en pruebas. También mostré varias formas de implementarlo, comenzando con la implementación manual, utilizando bibliotecas de terceros como Bogus.Faker y NBuilder, generadores de código incrementales como BuilderGenerator y EasyTdd.Generators.Builder y, finalmente, tener todo el código generado por la extensión EasyTdd de Visual Studio. Cada método tiene sus fortalezas y debilidades y funciona bien en casos simples.


Sin embargo, cuando se trabaja con clases inmutables, EasyTdd se destaca por manejar los cambios de propiedad de manera uniforme, ya sea que un valor de propiedad se inicialice mediante un establecedor o mediante un parámetro de constructor. EasyTdd admite plantillas y le permite personalizar la salida para que coincida con sus preferencias. EasyTdd también supera otros métodos de implementación de un generador debido a su velocidad de implementación. Proporciona herramientas en Visual Studio para generar archivos automáticamente con solo unos pocos clics, lo que ahorra tiempo y esfuerzo.


Foto de Markus Spiske en Unsplash