Cómo NO escribir tests unitarios: Usando herencia

Después de dedicar el último post de la serie de cosas que aprendí por la malas sobre tests unitarios a cuestiones más «metodológicas», volvemos a centrarnos en detalles más relacionados con la implementación.

Una de las primeras cosas que se aprenden cuando se empieza a programar y en las que énfasis se pone, es evitar el código duplicado, DRY y todas esas siglas que tanto nos gustan.

En algunos de los posts anteriores de esta serie, los consejos estaban enfocados precisamente a eso, como no repetir la lógica necesaria para construir un objeto o para dejarlo en un estado determinado.

Sin embargo, este post va justo de lo contrario, a veces es preferible duplicar código para mantener los tests claros.

El aparente atractivo de la herencia

En los lenguajes orientados a objetos es muy tentador usar la herencia para evitar repetir código, muchas veces llegando al punto de «forzarla» y pervertir su intención original (modelar una relación “es-un”) para únicamente aprovechar la herencia de implementación y poder emplear métodos de utilidad en las clases derivadas. Esto es un antipatrón en sí mismo que puede solventarse con composición, pero esa es otra historia.

Al escribir tests, especialmente en un contexto BDD es muy frecuente crear jerarquías de clases para testear escenarios similares:

public class When_a_customer_is_created {...}
public class When_a_preferred_customer_is_created : When_a_customer_is_created {...}
public class When_a_regular_customer_is_created : When_a_customer_is_created {...}

Muchos frameworks BDD recomiendan este estilo al a hora de escribir y organizar los tests, pero hay que tener cuidado con cómo se aplica.

Es frecuente que entre los distintos casos que queramos testear sólo haya pequeñas diferencias en el setup, por lo que parece una buena idea aprovechar el patrón template method y dejar en la clase base casi todo el setup excepto esas pequeñas diferencias, que se resuelven con métodos abstractos:

public abstract class When_a_customer_is_created
{
    protected Customer customer;

    public void Setup()
    {
        customer = new Customer(“Lucas”, IsPreferred());
    }
    
    protected abstract bool IsPreferred();

    public void Then_the_customer_has_a_name() { … }
    public void Then_the_customer_has_an_account_code() { … }
}

public class When_a_regular_customer_is_created : When_a_customer_is_created
{
    protected override bool IsPreferred() { return false; }

    public void Then_the_customer_has_no_special_discounts() { … }
}

public class When_a_preferred_customer_is_created : When_a_customer_is_created
{
    protected override bool IsPreferred() { return true; }

    public void Then_the_customer_has_a_ten_percent_discount() { … }
}

A simple vista, este código puede parecer una buena idea, ya que conseguimos ejecutar todas las comprobaciones comunes en todos los contextos y, además, podemos modificar ligeramente el contexto inicial y añadir nuevas comprobaciones para esos escenarios modificados.

Escribir los tests así tiene varios problemas:

Por una parte, es más difícil saber lo que está pasando en cada test. Es necesario comprender el diseño de la jerarquía de clases para conocer cual es el escenario testeado en cada clase.

Además, el setup de los tests puede acabar muy lejos de los propios tests, lo que complica aún más entender cómo funcionan.

Por otro lado, se acaban acoplando unos escenarios de test a otros a través de un setup común, lo que hace que cambios en test pueda provocar problemas en otro.

Duplica y compón

En situaciones como la anterior, suele ser mejor repetir el código que recurrir a la herencia, porque es más importante la claridad y la independencia del test que el ahorro de código.

Parte del comportamiento repetido, especialmente en el setup, se puede evitar mediante composición, encapsulándolo en otras clases que puedan reutilizarse en distintos tests, como por ejemplo los builders que vimos hace poco.

Cuándo merece la pena heredar

Hay casos en los que utilizar herencia entre las clases de test no sólo no es un problema, sino que es recomendable.

Cuando tenemos abstracciones muy extendidas en el código, por ejemplo Controllers en una aplicación MVC, y es necesario preparar siempre algunas cosas para poder testearlas, es útil contar con una clase base que haga el trabajo por nosotros.

Generalmente, cuando se produce esta situación encontramos también un layer supertype en el código de producción, y la clase base de los tests nos puede ayudar con la preparación de las dependencias ambientales del layer supertype.

De hecho, resulta intuitivo que si existe un ControllerBase del que derivan todos los Controller, exista también un ControllerTestBase del que deriven todos sus tests.

En estos casos, como se trata de una abstracción muy extendida en la aplicación, la complejidad introducida por usar herencia en los tests se ve compensada por dos factores:

  • Habrá muchos tests que se beneficien del ahorro de código.
  • Al usarse mucho, será más sencillo recordar como funciona. Es más fácil entender algo que usas todos los días que algo que tocas cada 6 meses.

Conclusiones

Utilizar herencia en los tests, especialmente aprovechando patrones como template method, es muy tentador e, inicialmente, parece una buena idea por el ahorro de código que supone. Sin embargo, hay que tener cuidado porque es muy fácil acabar teniendo muchos tests acoplados, difíciles de modificar por separado y difíciles de entender.

Siempre hay que primar la facilidad de leer el código frente a la de escribirlo, pero en el caso de los tests esto es aún más importante, porque cuando falle el test deberemos ser capaces de saber rápidamente si el fallo está en el propio test o en el código que está testeando. Si el setup de un test está escondido en una jerarquía de clases, es mucho más complicado saber esto.

Cuando en el código de producción encontremos un layer supertype es un buen indicador de que tal vez (sólo tal vez), pueda ser útil introducir una clase base para los tests de ese tipo de objetos que nos ayude con su construcción.

3 comentarios en “Cómo NO escribir tests unitarios: Usando herencia

  1. Interesantes artículos sobre pruebas unitarias. Sólo un apunte: en general se agradecería que se facilitara bibliografía sobre los temas tratados. Este es un aspecto se echa en falta en la mayor parte de los blogs técnicos.

    Personalmente recomiendo el libro «The Art of Unit Testing» para aprender bien cómo se deben realizar las pruebas unitarias. El código es en C# y todo se expone dentro del «.NET Framework». Considero que es de estudio obligado para todo desarrollador en .NET.

  2. Gracias por la recomendación, Lars.

    La verdad es que no suelo poner bibliografía como tal porque no suelo leer demasiados libros técnicos y luego muchas veces no recuerdo donde he leído cada cosa.

    En los casos en que recuerdo el libro, lo suelo citar como con el circuit beaker de hace unos cuantos posts.

  3. Estoy de acuerdo contigo en tu post, gracias por enlazarlo via twitter. El experimento que estamos llevando a cabo nosotros con algunos de nuestros tests usa herencia, pero es un poquito diferente. Los tests residen en la clase base, la abstracta. Las clases hijas no contienen tests. Es algo que no habia probado hasta ahora. Tienes todo el contexto en la clase base salvo detalles muy reducidos y la idea es no tener que ir a mirarlos a las clases hijas. Los tests son totalmente independientes entre si y nos resultan totalmente entendibles.
    Nos ha reducido el numero de tests considerablemente y con ello preveemos que tambien el mantenimiento cuando nos pidan cambios en el comportamiento en un futuro cercano. Y lo tenemos a un paso para deshacer la herencia porque no hay mas que dos niveles, padre e hijos.

    Saludos :-)

Comentarios cerrados.