10 diciembre 2009

Test driven development... algunas lecciones aprendidas

En este post quería compartirles algunas cuestiones sobre Test Driven Development (TDD) que fui aprendiendo de los proyectos y personas con las que trabaje.

TDD != Testing en busca de bugs

Es muy común confundir TDD con testing: al menos comparten la palabra "test" en el nombre.

La diferencia esta en que uno generalmente usa la palabra "testing" para referirse a la búsqueda de bugs. Mientras que en TDD la intención es otra: uno busca agilizar el diseño, facilitando la incorporación de cambios.

A grandes rasgos TDD consiste en expresar con un programa las "expectativas" sobre lo que se va a desarrollar.


Supongamos, por ejemplo, que estamos aprendiendo C con libro de Kernighan y Ritchie y su ya famoso "Hello World", así que escribimos:

#include <stdio.h>
int main() {
    printf("Hello World\n")
    return 0;
}

Compilamos...

$ cc hello.c -o hello
hello.c: In function ‘main’:
hello.c:5: error: expected ‘;’ before ‘}’ token

Arreglamos código, compilamos y ejecutamos, hasta que vemos en la pantalla el resultado esperado:

Hello World

La diferencia usando TDD es que primero escribimos un programa para verificar el resultado esperado (algo que comúnmente hacemos de forma manual):

ejecutarPrograma();
verificar(salidaDePrograma, "Hello World");

Nuestro "test", no intenta buscar bugs probando diferentes casos, o casos "borde" que puedan ser problemáticos.
Si no que simplemente es una forma de establecer que es lo que queremos hacer.

Esta diferencia es sutil pero tiene muchas implicancias en la forma de trabajar:
  • No es necesario pensar casos de test complejos, solo basta con pensar que es lo que se quiere hacer.
  • Con TDD el ciclo de escribir código - compilar, se transforma en: escribir tests/código - compilar/ejecutar tests.
    Es decir, crear/ejecutar tests debe ser parte común del desarrollo. Si los tests tardan en ejecutarse se transforman en una carga.

En mi caso los "tests" usados de esta forma se convierten en una especie de "TODO list". En efecto muchas veces siento que si no tengo planteado un "test" (aunque sea en mi cabeza) no sé por donde empezar.

¿Vale la pena dedicar tiempo a escribir tests?


El ejemplo de "Hello World" muestra una cuestión evidente: programar el test puede ser mucho más difícil que hacer el programa a testear.
Cabe preguntarse si es siempre así, y si el trabajo adicional vale la pena.

En general no siempre es tan difícil escribir tests, y hay factores que influyen en la dificultad:
  • Componentes y frameworks "acoplados" a sistemas externos.
    Por ejemplo, si quisieran escribir el test del "Hello World" en C deberían capturar el STDOUT del programa. Suele ser fácil reemplazar la salida standard, aunque si el stream fuese parametrizable sería mucho más fácil.
  • Frameworks que no proveen interfaces separadas de a implementación.
    Siguiendo con el "Hello World", supongamos que podemos parametrizar el stream de salida, sería ideal poder capturar la salida en un String.
    Si el "Stream" que nos brinda el lenguaje no permite hacerlo, podemos hacer otra implementación que cumpla la misma interfaz y capture el String. Pero si "Stream" es una clase cerrada, estamos en problemas.
  • Diseños que no cumplen con el principio de Single Responsibility.
    Si la "responsabilidad" de lo que queremos testear es acotada, entonces el test también va a ser más corto y fácil de escribir.
  • Dependencias globales mutables (como Singletons que no son "singletons").
    En un post anterior ya hable sobre los problemas del estado global... así que voy a evitar la tentación de volver a comentarlos :)
  • Tests con grandes conjuntos de datos.
    Complican la mantención del test y por lo general también hacen que la ejecución sea más lenta.
  • El lenguaje/tecnología a usar influyen en la forma de trabajo.
    Por ejemplo en Smalltalk es muy común desarrollar mientras se ejecuta el test (completando lo que falta implementar en el debugger), ya que entorno permite modificar clases sin "parar el mundo". En Java este tipo de prácticas no es posible.
    En otro extremo el lenguaje C++ no cuenta con reflection out-of-the-box, y por lo tanto escribir tests es mucho más trabajoso (hay que indicarle al framework explicitamente que tests ejecutar).
    Es conveniente tener en cuenta la tecnología que se usa, por ejemplo en C++ intentería tener tests más grandes para evitar el "overhead" que implica crear tests nuevos. En Smalltalk, Java u otros lenguajes el entorno ayuda a que este problema no exista.

Desde un punto de vista de management, quizás la cuestión esta en cual es el "retorno de inversión" de lidiar con todo esto. Para mi el valor esta en que:

  • Al tener tests automatizados uno tiene más confianza a la hora de hacer refactorings o cambios en el diseño.
    Esto puedo afirmarlo con mi experiencia: trabaje en un sistema financiero donde teníamos una gran cantidad de tests (si no recuerdo mal, más de 10.000).
    Tuve que hacer un rediseño importante de una parte "core" del sistema, hice los cambios y emepecé a ver los tests que fallaban.
    Los tests que entendí, los arregle. Los que no, los consulte con sus autores y los arreglamos en conjunto. Todo este trabajo hubiese llevado muchísimo más tiempo sin tests automatizados.
  • Ayuda a tener un diseño más abierto a los cambios
    La razón es simple: cuanto más acoplado esta un objeto al uso de sistemas o frameworks externos, más difícil es testearlo. Por lo tanto con TDD uno tiende a diseñar de forma tal de disminuir este acoplamiento.
    Lo mismo ocurre cuando los objetos tienen dependencias globales mutables: se vuelven dificiles de testear por que un test cambia el estado global, haciendo que otro test falle. Por lo tanto uno tiende a evitar este tipo de globales.
    A la larga las consecuencias de usar TDD son beneficiosas para el diseño.

No todo es color verde...

Como mencione antes: la intención de TDD es facilitar cambios y ayudar en el diseño. No encontrar bugs.

Por lo tanto aunque la aplicación pase los tests de unidad, puede contener errores funcionales (casos de test mal planteados), bugs por casos "especiales" y problemas de UI.

Volviendo al ejemplo de este sistema en el que trabaje con más de 10.000 tests de unidad:
Al principio no teniamos un equipo de QA, los tests automáticos pasaban y algunos de nosotros probamos la aplicación durante el desarrollo.

Llego el día "D": empaquetamos todo para producción y llevamos el software al cliente.

A las horas recibimos un llamado: un botón de la UI permanecía deshabilitado y el cliente no podía acceder a una funcionalidad del programa (funcionalidad que estaba desarrollada, pero inaccesible). Asi que tuvimos que corregir el problema y hacer un nuevo deploy.

Los tests de unidad no reemplazan a un equipo de QA. No es la intención de TDD reemplazar las prácticas comunes de testing y verificación de calidad.

La diferencia esta en los detalles

Supongamos los convencí de las "bondades" de TDD. Asi que empiezan a practicarlo en un proyecto.

Al tiempo hay una alta probabilidad de que empiece a pasar lo siguiente. Algunos tests fallan pero arreglarlos es un problema: los tests se volvieron enormes e inmantenibles. Solución rápida: los tests que fallan se ignoran. Un par de meses despúes los tests que fallan se siguen ignorando, y se pierde la ventaja de TDD: si necesitamos hacer un cambio de diseño no tenemos forma de saber que rompimos.

¿Por que se llega a este punto?
Principalmente es una cuestión de la costumbre del equipo en hacer y mantener los tests.
Pero además hay un montón de detalles que con el tiempo se acumulan cual bola de nieve e influyen en que los tests se vuelvan inmantenibles. Algunos "detalles" a tener en cuenta:

No depender de bases de datos externas

Hacer que un test de unidad requiera de una base de datos es problemático por que:
  • El entorno de desarrollo es dificil de configurar: cada desarrollador tiene que configurar los drivers y conexiones para poder empezar a trabajar con los tests.
  • Si se usa una base compartida: un cambio hace que los tests fallen para el resto de los desarrolladores. Para hacer TDD los tests se deben correr todo el tiempo, asi que tener tests que fallen por cambios de otros es inadminsible.
  • Es más trabajoso para usar un servidor de integración continua: hay que asegurarse de que el servidor de integración use correctamente la base de datos de prueba.
  • La ejecución de los tests suele ser más lenta, asi que correr todos los tests no es algo que se haga muy seguido.

En el caso de que un test necesite una base de datos es necesario distinguir:
  • Que es lo que se esta testeando: ¿Quiero realmente testear el acceso a la base de datos?
  • ¿Puedo reemplazar el acceso a la base de datos, por una implementación que "simule" la respuesta y no acceda a una base de datos real?

Muchas veces uno quiere testear la implementación a la base de datos, por ejemplo se usa Hibernate para responder algunas consultas y cambiar la implementación por una que simule la base no aporta nada.
En esos casos lo mejor es usar una base en memoria (como HSQLDB) y levantarla durante el test.

HSQLDB es liviano, soporta Hibernate y toda la sintaxis de SQL. El unico punto en contra es que si uno quiere "ver" como se guardan las cosas en la base hay que frenar el test para no bajar el servidor.

El siguiente es un pequeño ejemplo de como usar HSQLDB en un test:

public class TestDatabase {
    public static final String DEFAULT_DATABASE_NAME = "test";
    public static final int DEFAULT_SERVER_PORT = 9001;

    private Server hsqldbServer;
    private HsqlProperties hsqldbProperties;
    private String jdbcConnectionUrl;
    
    public TestDatabase(String databaseName, int serverPort) {
        hsqldbProperties = new HsqlProperties();
        jdbcConnectionUrl = "jdbc:hsqldb:hsql://localhost:" + serverPort + "/" + databaseName;
        hsqldbProperties.setProperty("server.port", serverPort);
        hsqldbProperties.setProperty("server.database.0", databaseName);
        hsqldbProperties.setProperty("server.dbname.0", databaseName);
    }

    public static TestDatabase startWithDefaultConfiguration() {
        return new TestDatabase(DEFAULT_DATABASE_NAME, DEFAULT_SERVER_PORT).start();
    }

    private TestDatabase start() {
        hsqldbServer = new Server();
        hsqldbServer.setProperties(hsqldbProperties);
        hsqldbServer.start();
        return this;
    }

    public void shutdown() {
        if (hsqldbServer == null) return;
        hsqldbServer.shutdown();
    }
}

public class AccesoALaBaseTest {
    private static TestDatabase database;

    @BeforeClass
    public static void startTestDatabase() {
        database = TestDatabase.startWithDefaultConfiguration();
    }
    
    @AfterClass
    public static void shutdownHsqldbServer() {
        database.shutdown();
    }
}

No usar herencia para compartir instancias de prueba

Usar herencia para compartir instancias entre tests es un error muy común.

Supongamos que estamos testeando la implementación de CajaDeAhorro. Para crear una instancia de la caja de ahorros, necesitamos una instancia de Cliente, que a su vez necesita una instancia de Contrato, que a su vez necesita una instancia de... bueno se hacen a la idea: crear todas estas instancias es trabajoso, nos tomamos el trabajo :(

Despúes queremos testear CuentaCorriente, y queremos reusar las instancias que creamos para el test de CajaDeAhorro.
Muchas veces se suele crear una superclase para el test, supongamos AbstractTest, donde se colocan estas instancias que necesitamos compartir.
Los desarrolladores empiezan a heredar todos los tests de AbstractTest, y van "subiendo" instancias que quieren compartir entre tests.

¿Se ve el problema?
AbstractTest termina siendo una gran bolsa de gatos. Cuando se quiere refactorizar AbstractTest hay otro problema: es engorroso buscar las referencias a cada variable usada en las subclases. Por lo general este refactoring implica mucho trabajo y nadie lo hace. AbstractTest sobrevive a varias versiones transformandose en una inmensa bola de lodo.

La solución:
No usar herencia para compartir instancias entre los tests (usar herencia para re usar código es mala idea, en este caso es malisima).
Es preferible crear una clase, por ejemplo ClienteTestResource (suelo usar el sufijo "TestResource" para esas clases) que brinda instancias de Cliente para los tests.

Nada evita que ClienteTestResource no se convierta tambien en una bolsa de gatos, pero la cuestión es más controlada: uno puede crear clases distintas para agrupar recursos necesarios en los tests. Y a diferencia de AbstractTest, los desarrolladores solo usan ClienteTestResource cuando lo necesitan.

Evitar código redundante

Otro problema común es la actitud de: "hago copy & paste, total es un test!".

Esto es perjudicial: los tests tambien hay que mantenerlos. Si hay que hacer un "copy & paste" para crear instancias que se necesitan en el test... usar un "TestResource".

Si hay que implementar un método en común para facilitar el testing: ¿Entonces por que no implementarlo en el modelo? Por ejemplo, si estoy testeando el balance de una cuenta y el codigo del test es algo asi:

cuenta.balanceAl(crearFecha("01/01/2009"));
...

private function Date crearFecha(String s) {
      SimpleDateFormat sdf .....
      return sdf.parse(s);
}

Y empiezo a copiar "crearFecha" en varios tests, la alternativa es dar una interfaz amigable en Cuenta (la otra alternativa es tener una interfaz más amigable para construir instancias de Date en general, un buen ejemplo de esto en Smalltalk es el framework Chaltén, que pemite expresar fechas como: "Jaunary first, 2009" donde Jaunary es un objeto first y , son mensajes... asi que la expresión es basicamente da una fecha) que permita escribir:

cuenta.balanceAl("01/01/2009");

Eso no quita que el metodo balanceAl(Date) no tenga que existir. El formateo de fechas depende del Locale y no debería usarse con un Locale implicito en la aplicación. Sin embargo es conveniente pensar en la facilidad de uso de las interfaces que uno provee: las interfaces de los objetos son como las interfaces graficas.

Tener interfaces que permitan expresar las cosas de forma natural, usando defaults apropiados y simplificando ciertas cuestiones, ayuda muchisimo a la hora de escribir tests.

La clave es pensar que cuando uno programa esta construyendo un lenguaje, y esto abarca tambien a los tests. No es lo mismo escribir:

assertThat(coleccion, hasItem("hola"));

Que escribir:

boolean found = false;
for (String s : coleccion) {
    if (s.equals("hola")) found = true;
}
assertTrue(found);

(Nota: sé que existe coleccion.contains("hola") pero quería explicitar la fealdad de esta alternativa ;) )

Un test por "observación"

Supongamos que para una CajaDeAhorro queremos testear que:
  • Los depositos incrementan el balance.
  • Las extracciones decrementan el balance.
  • No se puede hacer una extracción si no hay fondos.

Uno puede estar tentado a testear todo esto junto:

@Test public void depositoYExtraccion() {
assertThat(cuenta.balance(), is(monto));
cuenta.depositar(monto);
assertThat(cuenta.balance(), is(monto * 2));
cuenta.extraer(cuenta.balance());
assertThat(cuenta.balance(), is(0));
try {
    cuenta.extraer(monto);
    fail();
} catch (NoHayFondosException e) {}
}

Pero tiene algunos problemas:
  • Si el test falla es necesario debuggear para saber si el problema esta en depositar, extraer o balance.
  • El ejemplo es chico, pero en tests más grandes los errores tienden ser más dificiles de ver.

En comparación esta solución es mejor:

@Test public void losDepositosIncrementanElBalance() {
    assertThat(cuenta.balance(), is(0));
    cuenta.depositar(monto);
    assertThat(cuenta.balance(), is(monto));
}
@Test public void lasExtraccionesDecrementanElBalance() {
    assertThat(cuenta.balance(), is(not(0)));
    cuenta.extraer(cuenta.balance());
    assertThat(cuenta.balance(), is(0));
}
@Test(expected=NoHayFondosException.class)
public void noSePuedeHacerUnaExtraccionSiNoHayFondos() {
    assertThat(cuenta.balance(), is(not(0)));
    cuenta.extraer(cuenta.balance() * 2);
}

Parece más largo pero tiene varias ventajas:
  • Es más facil ver que esta fallando.
  • Si un test falla es más facil de corregir.
  • Tiene (para mi) una implicación psicologica: ir haciendo pequeños tests que van pasando, en general me ayuda a tener un mejor ritmo de trabajo. Incluso entre pequeños test a veces se me ocurren rediseños que no habia pensado originalmente.

Evitar tests no deterministicos

Cuando el resultado esperado depende de un valor del "entorno", por ejemplo: la hora actual o un número random. Es común terminar con tests no deterministicos: a veces pasan y otras veces no.

Por ejemplo supongamos que estamos testeando una aplicación que genera registros de auditoria con la hora actual:

registroEsperado = new RegistroAuditoria(nuevoTimestamp, etc, etc, etc);
sistema.ejecutarMetodoQueGeneraAuditoria();
assertThat(sistema.logDeAuditoria(), hasItem(registroEsperado));

El problema es que si la comparación de RegistroAuditoria involucra comparar el "time stamp", entonces el test pasa o falla según la resolución de la hora y si hubo pausas en el medio de la ejecución (por ejemplo se ejecutó el GC).

Una forma de evitar este tipo de cosas es "fijar" estos valores. Por ejemplo podemos tener una interfaz Clock que provee la hora:

Clock fixedClock = new FixedClock(nuevoTimestamp);
sistema = new Sistema(fixedClock);
registroEsperado = new RegistroAuditoria(nuevoTimestamp, etc, etc, etc);
sistema.ejecutarMetodoQueGeneraAuditoria();
assertThat(sistema.logDeAuditoria(), hasItem(registroEsperado));

Ya no hay más problemas de indeterminismo :)

Evitar delays

Muchas veces por razones de concurrencia, se termina agregando al test un "delay" (por ejemplo se ejecuta un thread y quiero esperar a que el thread actualize un valor que despues voy a verificar).

Este tipo de test tiene dos problemas:
  • El delay hace que los tests corran más lento.
  • Por lo general resultan en tests no-deterministicos por que dependen de como la plataforma maneja la ejecución de los threads.

La alternativa es investigar un poco más lo que se esta testeando. Si es necesario tener el thread separado en el test conviene usar un semanforo (o monitor en Java) para hacer un wait hasta que se modifique el valor.

Lamentablemente no tengo ningun ejemplo a mano para mostrar en el blog.
Lo he hecho tanto en Smalltalk y en Java, y quizas lo unico que puedo agregar es que las cosas concurrentes son extremandamente dificiles de testear.
Algunas veces una solución intermedia es usar "yield" para que la VM ejecute otros procesos, esto salva el delay pero puede generar tambien tests no-deterministicos... la mejor opción sigue siendo usar algun tipo de semaforo.

De todas formas vale la pena hacer la "investigación" para evitar el delay: uno termina aprendiendo muchas cosas de concurrencia :), y tener un delay de un 1seg en varios tests es muy molesto.

Usar mock objects con cuidado

Un mock object es simplemente una implementación "de mentira" que se usa para reemplazar en un test a una implementación real (esta es al menos mi definición de mock object).

En el ejemplo anterior FixedClock es una implementación de mentira de Clock, que nos facilita el testing.

Existen frameworks de mock objects que por lo general:
  • Facilitan la implementación de mock objects usando meta-programación.
  • Permiten hacer tests de caja blanca, pudiendo validar si se ejecuto o no un método del mock object.

En mi experiecia estos frameworks terminan derivando en tests dificiles de mantener:
  • Los tests de "caja blanca" suelen ser una mala idea, al primer refactoring fallan y se convierten en un "dolor" de mantener. En esos casos es mejor re-plantearse el caso de test (y recordar cual es la intención de los tests en TDD).
  • En general es mucho más facil tener una interfaz y hacer la implementación de mentira para el test que usar un framework de mock objects.
  • Cuando no se generan dependencias adicionales (explicación a continuación) y no hay problemas como en el ejemplo de FixedClock, es más facil usar objetos reales que mock objects (usar un "TestResource" puede ayudar a escribir menos codigo en estos casos).
  • En lenguajes dinamicos como Smalltalk es muchisimo mas facil crear mock objects, pero tambien tienen problemas: si se usan muchos mock objects y se hace un refactoring, es probable que los test compilen y pasen... aunque deberían haber fallado.

Un pequeño hint: usar mock objects no es necesariamente malo, pero necesitar de mucha logica en un mock object es signo de que algo esta mal. A veces no se necesita un mock object si no una implementación más sencilla de la "interfaz" que queremos simular.

Ser cuidadoso con las dependencias de los tests

Ya estoy llegando al final de este enorme post.

El tema de las dependencias de tests requiere un poco más de explicación, asi que voy a ser breve:

Muchas veces uno expresa los tests a más alto nivel por ejemplo:

"El usuario agrega items al carrito de compras. Procede al checkout, donde obtiene una factura de los items que compró"

Este test abarca toda una historia de usuario y a veces es bueno automatizarlo.

Sin embargo hay que tener en cuenta que este tipo de test no solo es más largo, si no que posiblemente dependa de otras implementaciones: por ejemplo el carrito de compras se implementa en un proyecto, la generación de factura en otro y quien usa ambas implementaciones (llamemosle Cajero) usa interfaces asi que el proyecto donde esta el Cajero no depende de una implementacion particular de carrito de compra o el generador de factura.

¿Donde colocamos el test?
Si lo hacemos en el proyecto donde esta Cajero, y no usamos mock objects, generamos dependencias adicionales que no son necesarias (o quizas dependencias circulares que son problematicas). Y si usamos mock objects para todo, el test se vuelve más dificil de mantener.

Lo mejor en estos casos es ver el test a otro nivel. Si quieren pueden llamarle "test de integración" (siguiendo una convención que usabamos en Mercap, prefiero llamarles "user story test" por que abarcan una historia de usuario, y reservar el nombre "test de integración" para tests que verifican la integración con sistemas externos).

Es conveniente que este tipo de test este en un proyecto separado. De esta forma se evitan dependencias circulares, y es más facil compartir información entre dintintos "user story tests" (usando de "TestResources").

Además este tipo de test suele ser más lento, y por lo general no se ejecuta con la misma frecuencia que los tests de unidad.

El problema de este tipo de test es que son más grandes, tienen más dependencias y por lo tanto más dificiles de mantener.

Pero cuando las fechas de entrega apremian, a veces no hay tiempo para estar haciendo muchos tests a nivel unitario. En esos casos con tests de este estilo se puede testear la implementación manteniendo un estilo TDD (uno escribe el "user story test" antes de empezar), sin necesidad de crear un test por cada una de las clases que se usan "internamente". En este caso las herramientas de covertura pueden ayudar a ver que se cubre con el "user story test" y que no.

Si bien esta es una alternativa que complementa a los tests de unidad, no los reemplaza: los tests de unidad son una buena forma de trabajar a "nivel micro", pueden ejecutarse todo el tiempo durante el desarrollo y son más faciles de mantener.

Espero no haberlos dormido con este post enorme, trate de resumir un poco las "lecciones aprendidas" con TDD.
Hasta la proxima :)

1 comentario:

  1. En cierto modo estoy comenzando en las prácticas de programación extrema (orientado a Flex), y sin lugar a dudas se agradece muchísimo este tipo de posts para tener un comienzo más amigable. Muchas gracias.

    ResponderBorrar