Tests de unidad en lenguajes dinámicos
Por Jacobo Tarrío
20 de marzo de 2011

En esta serie os he hablado de varios temas relacionados con los tests de unidad, pero siempre he usado Java para los ejemplos. Si preferís utilizar lenguajes dinámicos como Python, Ruby o JavaScript, los mismos principios sirven, con una excepción que os facilitará muchísimo la vida.

En muchos de los lenguajes dinámicos más populares las funciones y clases son objetos de “primera clase”, así que es posible manipularlos igual que se puede manipular cualquier objeto: se pueden asignar a una variable, se pueden pasar como argumentos de una función, y, lo más crucial, se les puede asignar nuevos valores. Esto significa que, en estos lenguajes dinámicos, no es necesaria la inyección de dependencias para utilizar dobles para pruebas: sólo tenéis que asignar vuestro doble a la clase o función que queréis sustituir.

Vamos a ver un ejemplo en Python. He escrito este pequeño programita que muestra la temperatura actual en un aeropuerto, usando los datos meteorológicos que se pueden descargar por Internet desde el servidor del NOAA (la agencia meteorológica y oceanográfica de los EEUU):

#!/usr/bin/python
# temperature.py

import sys
import urllib2

METAR_URL = 'ftp://tgftp.nws.noaa.gov/data/observations/metar/stations/'

class Error(Exception):
    pass

def GetTemperature(station):
    try:
        f = urllib2.urlopen(METAR_URL + station.upper() + '.TXT')
        lines = f.readlines()
        f.close()
        for line in lines:
            if not line.startswith('METAR '):
                continue
            fields = line.split(' ')
            for field in fields:
                if '/' in field:
                    temp, dew = field.split('/', 2)
                    if temp[0] == 'M':
                        return -int(temp[1:])
                    else:
                        return int(temp)
        raise Error('Invalid format')
    except urllib2.URLError, e:
        raise Error(e)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        raise Error('Invalid arguments')
    print GetTemperature(sys.argv[1])

Este programa utiliza la biblioteca estándar urllib2 para conectarse al servidor FTP del NOAA, y descargar el fichero con las últimas observaciones para el aeropuerto elegido. Luego analiza el fichero, busca la primera línea que comienza por “METAR”, busca la temperatura y devuelve un entero, o lanza la excepción “Error” si hubo algún problema en algún punto de la función.

Si queréis, podéis probarlo y ejecutarlo en vuestra máquina; el programa toma un argumento que es el código ICAO del aeropuerto (el código de Barajas es LEMD; el del aeropuerto de Barcelona es LEBL; el de Santiago es LEST) y muestra un número en pantalla que es la temperatura en grados centígrados (o un volcado de pila si hubo un error).

Para escribir los tests de unidad en Python se utiliza PyUnit; cada test es un método cuyo nombre comienza por “test” en una clase que deriva de unittest.TestCase. Éste es un esqueleto para los tests de unidad:

#!/usr/bin/python

import unittest

class TemperatureTest(unittest.TestCase):

    def testGetTemperature(self):
        # código del test
        pass

if __name__ == '__main__':
    unittest.main()

Ahora sólo tenemos que crear nuevos tests que prueben los principales casos en que nos podríamos encontrar a la hora de llamar a la función GetTemperature. Esto podría tener este aspecto si (de momento) no nos preocupásemos de las dependencias:

#!/usr/bin/python

from StringIO import StringIO
import unittest

import temperature


class TemperatureTest(unittest.TestCase):

    def testGetPositiveTemperature(self):
        self.assertTrue(0 < temperature.GetTemperature('DNAA'))

    def testGetNegativeTemperature(self):
        self.assertTrue(0 > temperature.GetTemperature('BGAA'))

    def testErrorOpeningUrl(self):
        self.assertRaises(temperature.Error, temperature.GetTemperature, 'XXXX')

if __name__ == '__main__':
    unittest.main()

El primer test pide la temperatura de un aeropuerto de Nigeria y comprueba que su temperatura es positiva, el segundo pide la temperatura de un aeropuerto de Groenlandia y comprueba que la temperatura es negativa, y el tercero pide la temperatura de un aeropuerto inexistente y comprueba que la función lanza una excepción.

Hay al menos tres problemas bastante gordos con este test; el primero es el ya conocido de que los tests no deberían necesitar acceso a Internet para funcionar ni deberían depender de nada que no sea la propia función que se está probando. El segundo es un poco más insidioso: ¿quién nos garantiza que en Nigeria siempre habrá temperaturas sobre cero? ¿Quién nos garantiza que en Groenlandia siempre hará frío? ¿Y quién nos garantiza que el aeropuerto “XXXX” no existe? Nadie, nadie y nadie. El tercero es el más insidioso de todos: aún suponiendo que la función siempre nos devuelva valores positivos para Nigeria o negativos para Groenlandia, ¿cómo podemos comprobar que esos valores son correctos? Si nos dice que en Groenlandia hace -600 grados, eso es claramente incorrecto; pero si nos dice que hace -5, ¿es correcto o no? ¿Cómo puede el test saberlo? El test tendría que descargarse los datos del METAR, analizarlos y compararlos con el valor correcto… pero eso es meterse en un berenjenal de cuidado.

Para evitar todos estos problemas debemos utilizar un doble para pruebas que sustituya a la función urllib2.urlopen y proporcione un contenido controlado por nosotros; de esta manera siempre sabremos que la función devuelve lo que tiene que devolver. Si este programa estuviese hecho en Java tendría que inyectar una instancia de urllib2 para poder utilizar un doble para pruebas en el test; como es Python, en cambio, sólo tengo que asignar un nuevo valor:

def testGetPositiveTemperature(self):
    oldurlopen = temperature.urllib2.urlopen
    temperature.urllib2.urlopen = lambda url: StringIO('METAR ABCD 123456Z 12/34 7890\n')
    actual = temperature.GetTemperature('abcd')
    temperature.urllib2.urlopen = oldurlopen
    self.assertEqual(12, actual)

Como podéis ver, guardo la función urlopen antigua y la sustituyo por una que devuelve un objeto StringIO (que tiene el mismo interfaz que el objeto devuelto por urlopen, así que puede hacer las veces de objeto “fake”) con un contenido de ejemplo. Luego llamo a GetTemperature, restauro el valor antiguo de urlopen y compruebo que la función me ha devuelto el valor esperado.

Si escribimos varios tests podemos utilizar las funciones setUp y tearDown para guardar y restaurar el valor antiguo de urlopen antes y después de cada test:

#!/usr/bin/python

from StringIO import StringIO
import unittest

import temperature


class TemperatureTest(unittest.TestCase):

    def setUp(self):
        self._oldurlopen = temperature.urllib2.urlopen

    def tearDown(self):
        temperature.urllib2.urlopen = self._oldurlopen
    
    def testGetPositiveTemperature(self):
        temperature.urllib2.urlopen = lambda url: StringIO('METAR ABCD 123456Z 12/34 7890\n')
        self.assertEqual(12, temperature.GetTemperature('abcd'))

    def testGetNegativeTemperature(self):
        temperature.urllib2.urlopen = lambda url: StringIO('METAR ABCD 123456Z M12/M34 7890\n')
        self.assertEqual(-12, temperature.GetTemperature('abcd'))

    def testInvalidFormat(self):
        temperature.urllib2.urlopen = lambda url: StringIO('foo bar\n')
        self.assertRaises(temperature.Error, temperature.GetTemperature, 'abcd')

    def testErrorOpeningUrl(self):
        def FakeUrlopen(url):
            raise temperature.urllib2.URLError('foo')
        temperature.urllib2.urlopen = FakeUrlopen
        self.assertRaises(temperature.Error, temperature.GetTemperature, 'abcd')


if __name__ == '__main__':
    unittest.main()

Igual que en Java, en Python hay frameworks para construir objetos “mock” fácilmente; el que conozco es Mox, que funciona de forma bastante parecida a EasyMock. Con mox tenemos que crear una instancia de la clase Mox, y luego llamar a sus métodos CreateMock (para crear un mock de una clase) o CreateMockAnything (para crear un mock de cualquier objeto), ReplayAll para pasar a modo “replay” y VerifyAll.

Por ejemplo, si en el primer test que describí en este artículo sustituyésemos la función lambda por un mock, tendríamos algo similar a esto:

def testGetPositiveTemperature(self):
    m = mox.Mox()
    oldurlopen = temperature.urllib2.urlopen
    temperature.urllib2.urlopen = m.CreateMockAnything()
    mock_file = StringIO('METAR ABCD 123456Z 12/34 7890\n')
    temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndReturn(mock_file)
    m.ReplayAll()
    self.assertEqual(12, temperature.GetTemperature('abcd'))
    temperature.urllib2.urlopen = oldurlopen
    m.VerifyAll()

Como el patrón “guardar-asignar-restaurar” es tan habitual, Mox nos proporciona funciones para realizar esa operación fácilmente; la más habitual es StubOutWithMock, que sustituye cualquier objeto por un mock, como en el siguiente ejemplo:

def testGetPositiveTemperature(self):
    m = mox.Mox()
    m.StubOutWithMock(temperature.urllib2, 'urlopen')
    mock_file = StringIO('METAR ABCD 123456Z 12/34 7890\n')
    temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndReturn(mock_file)
    m.ReplayAll()
    self.assertEqual(12, temperature.GetTemperature('abcd'))
    m.VerifyAll()
    m.UnsetStubs()

Si reescribimos todos los tests para utilizar mocks en lugar de funciones escritas a mano, el fichero queda así:

#!/usr/bin/python

import mox
from StringIO import StringIO
import unittest

import temperature


class TemperatureTest(unittest.TestCase):
    
    def setUp(self):
        self._mox = mox.Mox()
        self._mox.StubOutWithMock(temperature.urllib2, 'urlopen')

    def tearDown(self):
        self._mox.UnsetStubs()

    def testGetPositiveTemperature(self):
        metar = StringIO('METAR ABCD 123456Z 12/34 7890\n')
        temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndReturn(metar)
        self._mox.ReplayAll()
        self.assertEqual(12, temperature.GetTemperature('abcd'))
        self._mox.VerifyAll()

    def testGetNegativeTemperature(self):
        metar = StringIO('METAR ABCD 123456Z M12/M34 7890\n')
        temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndReturn(metar)
        self._mox.ReplayAll()
        self.assertEqual(-12, temperature.GetTemperature('abcd'))
        self._mox.VerifyAll()

    def testInvalidFormat(self):
        metar = StringIO('foo bar\n')
        temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndReturn(metar)
        self._mox.ReplayAll()
        self.assertRaises(temperature.Error, temperature.GetTemperature, 'abcd')
        self._mox.VerifyAll()

    def testErrorOpeningUrl(self): 
        temperature.urllib2.urlopen(temperature.METAR_URL + 'ABCD.TXT').AndRaise(temperature.urllib2.URLError('foo'))
        self._mox.ReplayAll()
        self.assertRaises(temperature.Error, temperature.GetTemperature, 'abcd')
        self._mox.VerifyAll()


if __name__ == '__main__':
    unittest.main()

Y con esto hemos llegado casi al final de la serie sobre tests de unidad. En la próxima, y última, entrega aclararé unas cuantas cosas que se me han quedado en el tintero y contestaré las preguntas que me enviéis. El que tenga alguna duda, que hable ahora o calle para siempre :)

(Primer artículo, siguiente artículo).

Otros artículos sobre “Web personal (2008-2015)”, “tests de unidad”, “programación”.
Índice.
Salvo indicación en contrario, esta página y su contenido son Copyright © Jacobo Tarrío Barreiro. Todos los Derechos Reservados. Información sobre tratamiento de datos y condiciones de uso.