8 de junio de 2010

Porque TDD no es opcional

Como probablemente sabrán, Alcides y un servidor hemos estado los últimos meses dándole mantenimiento a una aplicación legacy en python. Bueno, creo que es una buena oportunidad de comentar algunos de los problemas más duros con los que nos hemos topado y, como a pesar de ser problemas comunes en la mayoría de los proyectos, al estar en un lenguaje dinámico como python estos se exacerban.

Creo que el problema principal es el excesivo nivel de acoplamiento presente en la totalidad de la aplicación. El siguiente ejemplo fue extraído del código de un proceso por lotes que genera una serie de archivos TXT para realizar transferencias electrónicas masivas:

  numero_cuenta, nombre_beneficiario = self.frame.obtenDatosBanco( str( numero_empleado ) )

nota: el código ha sido modificado para no comprometer la confidencialidad del cliente y para no ofender los castos ojos del amable lector con detalles de formato, nomenclatura, estilo, etc., presentes en el original)

En el ejemplo anterior, self.frame se refiere a una referencia que guarda el objeto a la forma que lo invocó. Desde ahí uno se puede preguntar ¿Porqué un objeto que ejecuta un proceso batch requiere una referencia a la interfaz de usuario?. Más aún, ¿Porqué ese elemento de la GUI implementa un método que claramente pertenece al dominio del negocio (obtenDatosBanco)?

Al analizar el objeto en cuestión, nos dimos cuenta que las llamadas a self.frame.* se encuentran por todas partes en el código. Prácticamente no había manera de saber qué podíamos afectar si cambiábamos el batch o la ventana.

Una complicación adicional es que python, al ser un lenguaje interpretado, no genera errores de compilación u otra clase de advertencia si, por algún descuido, modificamos adversamente las dependencias del código mientras lo modificamos. Si bien es cierto que existe una herramienta (¡Muy útil!) que nos asiste en el proceso, llamada pylint, lo cierto es que aún así es necesario leer y re-leer el código varias veces tomando notas intensivamente y tratando de descubrir todos los posibles efectos que pueda tener una modificación.

Ahora, si bien en cualquier pieza de código el proceso anterior resulta extenuante, cuando se trata de un mega-archivo fuente de al rededor de 40,000 líneas de código y con un nivel extremadamente alto de acoplamiento, es poco decir que la tarea se vuelve insufrible.

Existe una técnica que nos ha dado resultado, llamada "Pruebas de Caracterización", documentada por Michael Feathers en su libro "Working Effectively with Legacy Code". Básicamente, lo que hacemos es crear un arnés de pruebas alrededor del código que deseamos modificar. La diferencia con las pruebas unitarias tradicionales es que en este caso, las pruebas no pueden ser escritas antes que el código (obvio), por lo tanto no pueden ser una especificación de lo que el código debe hacer. En cambio, las pruebas documentan lo que el código hace actualmente.

Esto significa que es probable que el código actual contenga bugs. Sin embargo, nuestro arnés de pruebas puede o no detectar dichos bugs, ya que no es esa su función. En cambio, su función es la de hacer visible el comportamiento actual del código, de tal suerte que podamos hacer las modificaciones necesarias y aún estar razonablemente seguros de que el resto aún funciona. Lo que es más, el tener nuestras Pruebas de Caracterización en su lugar, ¡No excluye el escribir pruebas unitarias para el código nuevo que se deba escribir!

Usualmente la prueba más difícil es la primera: crear una instancia del objeto en cuestión dentro de nuestro arnés. En este caso, es algo así:

from pago_electronico import GeneradorArchivos

class TestGeneradorArchivos( TestCase ):
    def setUp( self ):
        self.gen = GeneradorArchivos( frame=None, batch_id=None )
    def testCrea( self ):
        """Verifica que sea capaz de crear una instancia en el ambiente de pruebas"""
        self.assertIsNotNone ( self.gen )

Inicialmente, la prueba falla, ya que se requiere pasar una instancia del frame antes mencionado para inicializar la instancia. Sin embargo, no queremos crear una instancia de ese frame para esta prueba ya que por una parte, es una clase monstruo de arriba de 11 mil lineas de código que a su vez incluye toneladas de dependencias con ella. Por otra parte, requeriría la inicialización del sistema wxPython para el manejo de interfaz de usuario, lo que nos implicaría inicializar una instancia de wx.App... bueno, creo que ustedes captan la idea.

En su lugar, lo que queremos hacer es sustituir esa referencia a frame con otra cosa que se más fácil de crear y que podamos controlar.

Una de las propiedades de frame que utiliza GeneradorArchivos es connection, que regresa una referencia a la conexión con la base de datos. Anteriormente había desarrollado una sub-clase de TestCase para esos caso (con el plus de que esta última se asegura de abrir y cerrar la conexión cuando sea necesario, evitando la fuga de recursos). Así, lo primero que hago es cambiar TestGeneradorArchivos para ser un descendiente de mi clase:

from pago_electronico import GeneradorArchivos
from tests.custom import ConnectedDatabaseTest

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=None )

La llamada al método setUp de la superclase es necesaria para asegurarnos que la inicialización de la conexión se haga correctamente. Noten que también se pasa self como el parámetro frame.

Ahora bien, para todos los que leen esto que están acostumbrados a C#, Java, etc., dirán "eso es un error". Claramente, en esos lenguajes no podría hacer esto, ya que TestGeneradorArchivos no es de la clase adecuada que espera el constructor de GeneradorArchivos. Sin embargo, recordemos que:

Python es un lenguaje dinámico al que no le importa mucho la clase particular a la que pertenecen nuestros objetos, sino su tipo.

¿Su tipo?, ¿Que, no es lo mismo? er... no.

  • La clase de un objeto es el molde a partir del cual se crean instancias del mismo. Todo lo que tiene la clase terminará siendo parte de los objetos que a partir de ella se creen.

  • El tipo de un objeto esta definido por el conjunto de mensajes a los que dicho objeto puede responder.

En los lenguajes estáticos como Visual Basic, Java, C#, etc., la clase de un objeto define también su tipo, ya que también define los mensajes a los que ese objeto responderá. En dichos lenguajes y otros similares, clase y tipo son conceptos inseparables (o casi, si consideramos el uso de interfaces).

Por otro lado, en python podemos pasar una referencia a cualquier objeto como parámetro de una función y esta no se inmutará. Lo único que importa es que si la función llama a foo.bar(), el objeto que pasemos en el parámetro foo responda al mensaje bar.

De vuelta a nuestro ejemplo, vemos que la primera prueba pasa. Hora de escribir una segunda prueba. En este caso, las pruebas de caracterización son más como mini-pruebas de integración, ya que describen escenarios en lugar de solo verificar el resultado de una sola operación. Veamos:

    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()

Esta prueba falla inmediatamente, ya que al no especificar un batch_id, la clase lanza una caja de dialogo para obtener dicho ID del usuario. Sin embargo, como no hemos incluido wxPython en nuestra prueba, levanta una excepción.

Esto se arregla fácilmente pasando la información requerida en el constructor:

    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )

Claro, primero nos aseguramos que el batch 1234 exista y cumpla con los requisitos que necesitamos para nuestra prueba.

Ahora la prueba falla, pues está intentando acceder al método GetCurrentDate del frame. De nuevo, ¿que tiene que hacer un método como GetCurrentDate ahí, no lo sé. Sin embargo, le daremos lo que requiere. Como estamos pasando una referencia a self en el constructor, crearemos un método stub en TestGeneradorArchivos:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def GetCurrentDate( self ):
        return ( 1, 3, 2010 )

Y así sucesivamente.

Otro problema con el que nos topamos es que repetidamente GeneradorArchivos intenta crear cajas de diálogo para mandar mensajes al usuario o pedir información del mismo. Normalmente mientras estamos haciendo pruebas de caracterización, no hacemos refactorings. Esto es, porque para hacerlos correctamente, ¡Necesitamos pruebas que cubran el código que queremos refactorizar! Es como el problema de la gallina y el huevo.

Sin embargo, en este caso los refactorings son extremadamente simples y podemos realizarlos con un nivel aceptable de seguridad:

class GeneradorArchivos( object ):
    def obtenPrimerSecuencial( self ):
        return wx.GetTextFromUser( caption=u"Número Secuencial", message=u"Digite el Número Secuencial a usar", default_value="1" )

    def genera(self):
        ...
        inicial = self.obtenPrimerSecuencial()
        ...

A continuación, podemos aplicar la técnica estándar de sustitución de métodos en una prueba unitaria:

class GeneradorArchivosPrueba( GeneradorArchivos ):
    def __init__( self, frame, batch_id ):
        super( GeneradorArchivosPrueba, self ).__init__( frame, batch_id )
    def obtenPrimerSecuencial( self ):
        return "1"

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivosPrueba( frame=self, batch_id=1234 )

En este caso, hay otros métodos que queremos redefinir en GeneradorArchivos, por lo que conservaremos la clase GeneradorArchivosPruebas. Sin embargo, si solo necesitáramos redefinir obtenPrimerSecuencial o un par de métodos simples más, existe una forma más sencilla de hacerlo:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        def regresaUno(): return "1"
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )
        self.gen.GeneradorArchivosPruebas = regresaUno

o incluso:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def setUp( self ):
        super( TestGeneradorArchivos, self ).setUp()
        self.gen = GeneradorArchivos( frame=self, batch_id=1234 )
        self.gen.GeneradorArchivosPruebas = ( lambda "1" )

Ahora que ya logramos ejecutar correctamente a GeneradorArchivos en un arnés de prueba, ¿qué probamos? Uno de los métodos que extrajimos al eliminar el uso de diálogos fue muestraResumen, que simplemente muestra un resumen del trabajo realizado por el batch:

class GeneradorArchivos( object ):
    ....
    def muestraResumen( self, registros_totales, registros_negativos ):
        Mensajes.MuestraAviso( FMT_RESUMEN % ( registros_totales, registros_negativos ), parent=self.frame )

bueno, podemos utilizar este método para verificar estos resultados:

class GeneradorArchivosPrueba( GeneradorArchivos ):
    registros_totales = 0
    registros_negativos = 0
    def muestraResumen( self, registros_totales, registros_negativos ):
        self.registros_totales = registros_totales
        self.registros_negativos = registros_negativos

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()
        assert self.gen.registros_totales == -1
        assert self.gen.registros_negativos == -1

Esto nos da un primer acercamiento a las pruebas de caracterización. Al correr el proceso batch en una base de datos de prueba contra el batch número 1234, descubrimos que procesa un total de 36 registros, de los cuales 4 son negativos. Como nuestra prueba debe describir lo que el programa hace actualmente, incluimos estos resultados en nuestra prueba:

class TestGeneradorArchivos( ConnectedDatabaseTest ):
    def testGenera( self ):
        """Genera los archivos de pago y los compara contra un set de 'buenos conocidos'."""
        self.gen.genera()
        assert self.gen.registros_totales == 36
        assert self.gen.registros_negativos == 4

Este proceso continúa hasta que nos sentimos satisfechos con nuestras pruebas de caracterización, rompiendo dependencias y detectando resultados. A continuación pudimos hacer las modificaciones que se nos pidieron y de paso pudimos darle una muy necesaria limpieza al código—no tanto como hubiésemos querido, pero es un comienzo.

Saludos y espero que haya sido de su interés.

No hay comentarios:

Publicar un comentario