Alternativas al uso de ObjectMother y Builders en los tests

Cuando estamos escribiendo tests automatizados, excepto en los casos más sencillos, es habitual que para testear el componente que queremos testear, necesitemos utilizar otros objetos, ya sea para construir el objeto que estamos testeando o como parámetros de los métodos que vamos a testear.

Si estamos siguiendo los principios canónicos de diseño orientado a objetos, huyendo de la obsesión por los tipos primitivos, encapsulando datos y comportamiento, manteniendo invariantes y demás, es probable que construir estos otros objetos y ponerlos en el estado que necesitamos resulte algo más laborioso de lo que nos gustaría.

Esto presenta dos inconvenientes fundamentales:

  • Se produce un acoplamiento entre el test y APIs de las dependencias que, en principio, no deberían importarnos mucho. Si para el test necesitamos tener un «cliente preferente», la forma de construir un cliente preferente no debería ser importante. Al introducir este acomplamiento tenemos tests más frágiles y más costosos de mantener.
  • Hay un pérdida de legibilidad, puesto que tenemos que intoducir más ruido en el test para configurar las dependencias y eso hace que sea más complicado ver qué es lo que realmente queremos testear y dificulta resaltar qué aspectos son los imporantes para el test y qué aspecto son accesorios.

Para resolver esto existen varias técnicas, algunas de las cuales ya hemos visto por aquí.

ObjectMother

Una opción es utilizar un ObjectMother, que es una clase encargada de proporcionarnos instancias de las clases que usamos con frecuencia. No voy a entrar en mucho detalle, tenéis más información en el post que escribí sobre ObjectMother, pero la ventaja es que nos ahorra depender de los constructores de otros objetos.

La principal limitación de este patrón es que si necesitamos construir objetos con diversas características (un cliente de Madrid, otro de Segovia, uno preferente, otro que haya excedido el riesgo, etc.), la clase que hace de ObjectMother empieza a ser un lío y acabamos acoplando unos tests a otros indirectamente a través del ObjectMother.

Aunque cada vez uso menos esta técnica, sigue resultando útil para datos muy estáticos que no cambian mucho. Los típicos «datos maestros» entre los que podemos incluir un par de países, un par de formas de pago, etc.

Builder

Para los casos en que el ObjectMother se queda corto, se pueden utilizar builders para no depender de las APIs testeadas. Con los Builders podemos construir objetos y ponerlos en estado que necesitamos sin necesidad de depender del API concreta del objeto, omitiendo además aquellos detalles que no son relevantes para el test y generando tests más legibles, como veíamos en este ejemplo de test usando builders.

Los builders son una solución bastante buena, pero tienen sus problemas. Para empezar, son costosos de escribir. Para que sean realmente cómodos de usar hay que diseñarlos con cuidado, pueden requerir bastante código y eso ralentiza la escritura de los tests. Esto lleva a intentar construir builders genéricos que podamos reutilizar en varios tests con la idea de amortizarlos, pero eso es un arma de doble filo, porque llega un momento en que el builder tiene tantas opciones que se hace casi tan complejo como el API del objeto que estamos construyendo, y la legibilidad del test (recuerda, uno de los objetivos de introducir el builder) se resiente.

Normalmente utilizo builder genéricos para construir objetos que se necesitan en muchos tests diferentes y cuyos requisitos de construcción son similares de unos tests a otros. Por ejemplo, puedo tener 800 tests que necesitan construir productos con distinto nombre, precio o tipo de impuesto, pero poco más, por lo que el builder acaba con un API relativamente simple y se amortiza. Si tengo requisitos muy concretos, utilizo builders internos a la clase de test, optimizados para ese caso de uso concreto. Eso me permite simplificar al máximo el API del builder e incrementar mucho la legibilidad de los tests, pero el esfuerzo es mayor, por lo que sólo uso esta opción cuando son test que me interesa mucho mantener legibles.

Creation Methods

La alternativa que más utilizo actualmente para construir objetos en los tests son los métodos de creación. La idea es encapsular la creación y configuración de objetos auxiliares en un método dentro de la propia clase de test.

Por ejemplo, si estamos testeando un filtro sobre productos en base a la categoría a la que pertenecen y el nombre del producto, esos son los únicos datos que nos interesan y podríamos tener un método así:

private Product Product(string category, string name)
{
  var category = new Category(category);
  var tax = ObjectMother.Tax1;
  var product = new Product(name, tax);
  product.Category = category;
  return product;
}

[Test]
public void Filters_Products_By_Full_Text_Search()
{
  var products = new[]
  {
    Product("Ropa", "Camiseta"),
	Product("Ropa", "Pantalón"),
	Product("Menaje", "Copa"),
  };

  var result = products.Filter("opa");
  Assert.That(result.Length, Is.Equalto(3));
}

En el ejemplo se ve además cómo esta técnica interactúa con otra de las expuestas anteriormente, el ObjectMother, para acceder a datos estáticos (un tipo de impuesto predeterminado), que no nos interesa lo más mínimo para el test.

Este sistema es mucho más rápido de implementar que un builder y nos da la flexibilidad de diseñar el API del método exactamente cómo la necesitamos para los tests que vamos a escribir.

Como contrapartida, no conseguimos aislar tanto los tests de las APIs necesarias para construir y configurar los objetos. Si tenemos 80 clases de tests que construyen productos y cambiamos el constructor de producto, tendremos que cambiarlo en 80 sitios. Es una mejora con respecto a tener que cambiarlo en los 800 tests que usan productos, pero sigue siendo peor que cambiarlo sólo en el builder o en el ObjectMother.

Además, si tenemos muchas variantes de objetos a construir en una misma clase de test, necesitaremos crear muchos métodos, que no siempre son fáciles de nombrar (AddInvoice, AddInvoiceWithPreferredCustomer, AddInvoiceWithPreferredCustomerFromMadrid, etc.), o utilizar muchos parámetros en nuestros métodos de creación.

Si vamos por la vía de usar muchos parámetros, tendremos el problema de perder legibilidad en el test cuando tengamos varios parámetros del mismo tipo:

private Invoice Invoice(string customer, decimal customerDiscount, string product, decimal quantity, decimal price,  decimal shippingCost) { ... }

[Test]
public void Some_Invoice_Test()
{
  // ¿Qué es cada parámetro? ¿La cantidad es 1 o 4? ¿Y el precio?
  var invoice = Invoice("Paco", 0.1m, "Paraguas", 1m, 4m, 2.5m);
  ...
}

Cuando tenemos varios parámetros con el mismo tipo es difícil saber qué es cada parámetro por el contexto (aunque a veces el contexto ayuda, como «Paco» y «Paraguas», es fácil distinguir el cliente del producto).

A veces para solventar esto se introduce un parameter object para agrupar los parámetros y poder ponerles nombre. Esto incluso permite aplicar el patrón builder al parameter object para no tener que indicar todos los parámetros si no son necesarios:

[Test]
public void Some_Invoice_Test()
{
  var invoice = Invoice(
    Customer("Paco").Discount(0.1m),
	Product("Paraguas").Price(4),
	1m,
	2.5m);
	
  // ...
}

Los builders pueden ser genéricos o específicos a la clase de tests, dependiendo del escenario pero la idea es similar. Si usamos esta técnica podemos repartir la lógica entre el método y el builder, lo que nos ayuda a mantener los builders más genéricos y reutilizables, y dejar las cosas «muy especiales» para los métodos específicos de una clase de tests. Lo malo es que seguimos teniendo que implementar y mantener los builders.

No es mi técnica preferida porque me parece que lleva bastante trabajo, pero a veces es una buena salida y la suelo emplear de vez en cuando.

En lugar de builders, podemos construir el parameter object mediante propiedades. Hay que escribir un poco más y no es igual de potente, pero nos ahorra algo de código:

private class CustomerInfo
{
  public CustomerInfo()
  {
    Name = "";
	Discount = 0;
  }

  public string Name { get;set; }
  public decimal Discount {get; set; }
}

[Test]
public void Some_Invoice_Test()
{
  var invoice = Invoice(
    new CustomerInfo { Name = "Paco" },
	new ProductInfo { Price = 4 },
	1m,
	2.5m);
	
  // ...
}

Usando objetos y propiedades podemos jugar con el constructor para definir valores por defecto y así en cada test sólo necesitamos hacer explícitas las propiedades que importan, al estilo de lo que haríamos con el builder. La verdad es que es una opción que no uso casi nunca porque me parece que obliga a escribir demasiado al invocar el creation method, especialmente por tener que incluir el nombre del parameter object (el new CustomerInfo) y lo encuentro poco legible.

Si vamos un paso más allá, podemos hacer esto mismo sin ningún parameter object, utilizando argumentos con nombre y valores por defecto en el método original:

private Invoice Invoice(
  string customer = "", decimal customerDiscount = 0,
  string product = "",  decimal quantity = 1, decimal price = 1,
  decimal shippingCost = 0) { ... }

[Test]
public void Some_Invoice_Test()
{
  var invoice = Invoice(
  	  customer: "Paco", customerDiscount: 0.1m,
	  product: "Paraguas", quantity: 1, price: 4,
	  shippingCost: 2.5m);
  ...
}

Esta técnica nos permite identificar claramente el rol de cada parámetro gracias a su nombre y, gracias a los valores por defecto, podemos omitir aquellos parámetros que no son relevantes para el test. Hay quien se pone nervioso cuando el creation method empieza a acumular muchos parámetros, y es cierto que puede ser un problema, pero al final es un problema que tienes acotado en un punto (ese método), por lo que es relativamente manejable.

Una limitación importante es que C# es muy restrictivo en los valores por defecto que se pueden usar para los argumentos de un método, y hay ocasiones en que esta técnica se nos queda corta justo por eso. Podemos recurrir al estilo tradicional de tener distintas sobrecargas, pero no es igual de flexible.

Últimamente estoy utilizando más esta técnica, aunque todavía es pronto para decidir cuánto me gusta.

Conclusión

Al escribir tests es muy importante que sean fáciles de mantener y que se pueda confiar en ellos. Para eso es crítico que sean fáciles de entender y que cuando fallen sea porque lo que están testeando ha dejado de funcionar, no porque se ha producido un cambio en otra área de la aplicación.

Para conseguir esto es útil independizar los tests de la construcción y preparación de los objetos «auxiliares» (prefiero evitar el término dependencia) que necesitamos en el test.

Existen muchas técnicas, entre ellas las que hemos visto en este post. Todas tienen valor en distintos contextos, pero si tuviera que resumir mi estrategia actual sería algo así:

Usa ObjectMother para datos maestros (países, formas de pago, impuestos, roles, usuarios, etc.). En generl, para cualquier cosa que casi nunca importa en el test pero que necesitas para construir los objetos o realizar las operaciones.

Si puedes apañarte con métodos de creación, hazlo. Es lo más rápido de implementar y a veces es también lo más legible. Lo idea es usar métodos de creación sin nada más, pero si no queda más remedio, añade parámetros con nombre o builders para construir parameter objects.

Cuando veas que estás construyendo el mismo tipo de objeto en muchos tests, refactoriza a un builder para aislarte de cambios en su API. Tus métodos de creación pueden llamar a ese builder si hace falta y te ahorrarás problemas a medio plazo.

Como siempre, nada de esto está escrito en piedra. Comprende lo que te ofrecen los distintos tipos de soluciones, analiza tu caso y diseña tu propia estrategia.

7 comentarios en “Alternativas al uso de ObjectMother y Builders en los tests

  1. Juan Lladó dijo:

    ¡Enhorabuena por el post!

    Después de un tiempo yo también he llegado a un estrategia similar a la que tú usas.

    Lo único que a veces me da problemas es cuando tengo que construir un objeto bastante grande/complejo (ej: objetos que contienen listas, respuestas de un WS) que se hace muy tedioso. Aunque, algunas veces suele ser un olor y el problema está en que estoy violando SRP.

    ¿Te has encontrado con ese problema?

  2. Gracias Juan,

    Sí, ese caso se puede/suele dar. A veces (no tantas, la verdad) es indicativo de un exceso de responsabilidad, pero generalmente es que, simplemente, la estructura de datos es compleja; por ejemplo, una factura. Puedes simplificarla agrupando partes en otros objetos y evitar así primitive obession (información del cliente, información del producto, datos de entrega, etc.), pero al final para construir el objeto necesitas construir cada parte.

    Una ventaja de partirlo en trozos es que para cada trozo puedes utilizar la técnica más apropiada, y a lo mejor para construir el objeto factura acabas mezclando un ObjectMother (para la forma de pago), un Builder (para los productos) y un creation method (para la propia factura).

  3. Como apunta el tener muchos parámetros; en los casos en que el dominio es muy grande y compartido por muchos test (ej. validar la digestión (ej. parseo) de un protocolo, testear workflow en una ui entera) empiezo por una clase generadora de casos, que crea instancias aleatoriamente en base a configuraciones (que pueden venir de línea de comandos o de un archivo de configuración, lo que me permite utilizar después los test para integración, explorar el dominio, pruebas de rendimiento, generar ejemplos para documentación a terceros, etc…). Normalmente las propias clases del modelo me sirven (caso del protocolo) o sino creo unas adhoc (caso de testear la ui). En general como los casos son generados aleatoriamente no tengo que arrastrar parámetros (si la distribución de un parámetro es relevante suelo meterla en la configuración y ya la tengo disponible para todo lo comentado). Así, mis validaciones intentan explorar todo el dominio en base a las aserciones generales. Para los casos en que no quiero parametrizar una distribución (ej. la longitud de cierto comentario de texto) añado un parámetro opcional al proceso del test básico (el test en sí sólo llama a los procesos de test) y si eso afecta a un conjunto de datos amplio (ej. un portfolio entero) entonces añado un modificador del caso base (ej. `Function`) que permite modificar «al gusto» para cada test (ej. añadiendo un comentario larguísimo). Pero éstos test no me gustan, porque definen un comportamiento puntual y si hiciera eso ¡tendría que tener miles de test que replicarían la definición funcional del sistema! (lo cual es muy difícil de gestionar). Así, yo prefiero lanzar muchos test aleatorios y que sea el propio sistema (ej. vía excepciones o Either cuando está codificado) y aserciones genéricas en los test (ej. un Portfolio activo debe tener alguna hoja). Cierto que si se produce un problema no tienes un test explícito que te lo diga, pero tienes casos generales que te llevarán fácilmente a ese problema (porque se ha producido cierta excepción o fallado cierta aserción).

  4. Me parece muy interesante ese enfoque estilo property-based testing.

    La pega que le veo es que pierdes parte del aspecto «documental» de los tests. Es cierto que dejas documentadas propiedades que actúan como invariantes, pero a cambio no tienes un ejemplo sencillo de lo que se supone que debería pasar en un escenario concreto. De todas formas, me gusta la idea ya sea como estrategia base de testing o como complemento.

    ¿Cómo haces para construir los generadores de entidades? Cuando tienes entidades dependientes de otras, cada una con sus invariantes, ¿los generadores los haces a mano? ¿Dejas que sea todo aleatorio y sólo alimentas tus casos de test con entidades que han pasado ciertos criterio?

    Te lo digo porque si haces los generadores a mano (que supongo que no), al final has movido el problema de sitio, pero si todo es aleatorio y no filtras nada, al final casi todo lo que testeas son excepciones en los límites del sistema quejándose de parámetros incorrectos, ¿no?

  5. «La pega que le veo es que pierdes parte del aspecto “documental” de los tests. »

    Ya sabes mi opinión sobre TDD (y que los test sean documentación preferencial apunta a ello) sólo uso test para integración o regresión. Precisamente, evito que sean documentación haciendo que sean lo más «tontos» posible.

    «¿Dejas que sea todo aleatorio y sólo alimentas tus casos de test con entidades que han pasado ciertos criterio?»

    Normalmente creo clases generadoras (ej. «RandomPortfolio») que toman datos de una configuración general (ej. «minimumSheets=3; maximumSheets=120») que puede indicarse por línea de comandos o fichero de configuración. De esa forma puedo crear fácilmente casos (y de paso configuraciones como extremos de servicios, bases de datos, credenciales, etc…). Por ejemplo, si tenías un fallo cuando el nº de hojas eran 666 creas una configuración con «minimumSheets=maximumSheets=666» y te lanzará test aleatorios cuyo número de hojas van de 666 a 666. Si resulta que has creado un servicio web puedes crear configuraciones «ejemplo1.config», «ejemplo2.config», … y crear automáticamente una traza con esos ejemplos (en que ciertos datos como «nombre=Fé€m☂12» te da igual como se generen). O puedes crear trazas con cargas (casos) personalizados para temas de rendimiento, etc. Supongo que es parecido a los ObjectMother esos.

    No se si he entendido bien tu dicotomía «mover de sitio» vs «instancias raras» porque es obvia e inevitable. La generación de una instancia *siempre* está acoplada a la implementación (obvio) y sólo en la medida que la implementación lo permita su generación estará desacoplada del test. Precisamente cuanto menos acoplada esté (menos necesita saber el test «del negocio»), menos falta hará el propio test (porque tu implementación estará representando fielmente los casos válidos de instancia).

    Es decir, sólo si has codificado totalmente (normalmente en el sistema de tipos, por ejemplo refinando `Integer` a `NaturalNumber` a `PersonAge`) la correctitud (invariantes) de tu modelo, te puedes ir al extremo de testear «instancias raras». En este caso (para mi utópico en un entorno empresarial), ya sólo debes conectar esas instancias autogeneradas con tus procesos empresariales y «voilá» esperar si hay algún fallo o no (o verificar algún invariante muy general no considerado en tu implementación). QuickCheck en estado puro.

    Con frecuencia (ej. en Haskell) no es posible/deserable tal definición y es por ello que también hace falta crear esos generadores (que creo dices es «mover de sitio») escribiendo `instance Random X where` e inevitablemente acoplados.

    A mi este esquema me resulta muy práctico porque no sólo lo uso como test y porque el acople es «próximo» al ideal de quickcheck en el que mis test tienen la forma `successResult(doReadPortfolio(doUpdatePortfolio(doCreatePortfolio())))` en que defino los procesos empresariales que definen «cosas» que ocurren «normalmente» pero en un gran (o pequeño) número de casos.

    Como decía, evito hacer test como `failResult(doUpdatePortfolio(doCreatePortfolio(), p => p.name = randomName(1000,2000)))` donde en el workflow normal he indicado explícitamente que debe fallar si el nombre tiene entre 1000 y 2000 caracteres. No es lo ideal claro, lo ideal es hacerlos (ej. se te puede escapar un caso que produzca cierto overflow, etc.), pero repito que prefiero hacer test «tontos» orientados a integración y como los de éste último ejemplo sólo para regresión.

    No se si me he ido por las ramas.

  6. «su generación estará desacoplada del test»
    su generación estará desacoplada en el test (el test estará desacoplado de)

  7. No te has ido por las ramas, al contrario. Muchas gracias por tomarte la molestia de explicarlo, llevaba tiempo dándole vueltas a usar este tipo de tests para ciertas partes de un dominio y me has dado mucho que pensar :-)

    En cuanto a lo de la documentación, no lo decía tanto por la parte de TDD (que tampoco suelo usar), sino porque a veces es más fácil entender un ejemplo concreto que aprehender un conjunto de reglas generales. Es verdad que el ejemplo concreto es más (mal)interpretable, pero suele ser práctico para comunicarse con otros humanos.

    Lo dicho, muchas gracias por la explicación!

Comentarios cerrados.