Testing en django: mejoras


A menudo, cuando me pongo a hacer algo en Django y escribo mis primeros tests, los noto pesados y desordenados. Me resulta difícil diferenciar entre tests unitarios, de integracción y de aceptación.

De la misma manera, suele ser una aventura añadir coverage, ya que nunca me acuerdo de cómo se hace.

En este artículo describiré cómo hacer ambas cosas.

Entorno

Lo primero es crear un entorno con lo que vamos a necesitar. Para ello crearemos un entorno virtual, lo que nos aislará un poco del resto. Para este artículo usaré python 2.7, aunque debería ser perfectamente compatible con python 3.

Creemos el entorno:

$ virtualenv venv

Ahora comencemos a usarlo:

$ . venv/bin/activate
(venv)$

Lo primero es instalar todo lo que nos hará falta. Para ello, creamos el archivo requirements.txt con el siguiente contenido:

Django==1.6.2
coverage==3.7.1
mock==1.0.1

Y ahora lo instalamos:

(venv)$ pip install -r requirements.txt

Así seguro que tendréis las mismas versiones que yo.

Proyecto

Vamos con un proyecto muy sencillo. Será más que eso: será mínimo:

(venv)$ django-admin startproject prj
(venv)$ cd prj
(venv)prj$ python manage.py startapp app

De todo esto ya hablé en el artículo Creación de un sitio básico Django, así que por eso voy rápido.

A menudo necesito un par de funciones para transformar datetime en timestamps y viceversa, de manera que mis javascripts puedan comunicarse vía REST, así que éstas van a ser las funciones a probar:

# archivo prj/app/views.py

from datetime import datetime
import time

def millis2datetime(timestamp, dater=datetime.now):
    if timestamp:
        return datetime.fromtimestamp(timestamp/1000.0)
    return dater()

def datetime2millis(dtime, timer=time.time):
    if dtime:
        return time.mktime(dtime.utctimetuple()) * 1000
    return timer() * 1000

Una vez tenemos todo esto... Comencemos.

Un poquito de organización

División en ficheros

¿A nadie más que a mí le molesta tener todos los tests en un único archivo tests.py?

Pues resulta que Django estś buscando todos los archivos que se llamen "test_*", por lo que podemos crear el módulo "tests" (es decir, crear el directorio "tests" con un archivo vacío "__init__.py") y se ejecutarán todos los archivos cuyo nombre comience por "test_".

Sin embargo, esto me molesta también, ya que es información redundante. ¿No estamos ya dentro del módulo tests?

Dado que Django sólo toma como test cualquier clase que herede de TestCase (ya sea de django.test o de unittest), es estúpido filtrar también por el nombre del archivo.

Por esa razón me gusta utilizar la opción -p"*.py", de manera que me busque los tests en todos los archivos python, y no tener que preocuparme del nombre:

(venv)$ manage.py test -p"*.py"

unit/integration/acceptance

Otra cosa que me mosquea es tener que esperar mucho a la ejecución de los tests. Por eso me gusta tener unos tests de ejecución ultra rápida, los unitarios, que no necesitan la base de datos ni acceso a disco. De hecho, quiero asegurarme de que éstos no pueden acceder a la base de datos de ninguna manera, con el fin de evitar cualquier despiste.

Django no soporta nada de esto. De hecho, si alguna TestSuite requiere una fixture, automáticamente generará una BBDD vacía e insertará los datos. No quiero eso.

La solución es implementar mi propio Runner:

# archivo runners/__init__.py

from django.test.runner import DiscoverRunner
from django.utils import unittest
from unittest.suite import TestSuite

class CustomizedRunner(DiscoverRunner):
    def build_suite(self, *args, **kwargs):
        suite = super(CustomizedRunner, self).build_suite(*args, **kwargs)
        filtered = TestSuite()

        for test in suite:
            if self.package in str(test):
                filtered.addTest(test)
        return filtered

class UnitRunner(CustomizedRunner):
    package = '.unit.'

    def setup_databases(self, *args, **kwargs):
        return

    def teardown_databases(self, *args, **kwargs):
        return

class IntegrationRunner(CustomizedRunner):
    package = '.integration.'

class AcceptanceRunner(CustomizedRunner):
    package = '.acceptance.'

Como podéis ver, me creo el CustomizedRunner que hereda de DiscoverRunner. Así no me tengo que preocupar de reinventar la rueda y Django me ofrece todo lo que necesito. Lo único que me falta es un filtro para los tests.

Por eso creo tres Runners distintos: UnitRunner, IntegrationRunner y AcceptanceRunner. Éstos, básicamente, filtran los tests que contengan cierta cadena en su nombre de módulo.

La única diferencia la pone el UnitRunner, en el que me aseguro de que la base de datos no se toca. No es extrictamente necesario, pero no está de más.

Ahora puedo crearme la siguiente estructura:

tests
├── __init__.py
├── acceptance
│   └── __init__.py
├── integration
│   └── __init__.py
└── unit
    └── __init__.py

Y podré situar los tests en el lugar adecuado.

Para seleccionar los tests que quiero ejecutar, basta con cambiar el runner:

(venv)$ manage.py test -p"*.py" --testrunner runners.UnitRunner
(venv)$ manage.py test -p"*.py" --testrunner runners.IntegrationRunner
(venv)$ manage.py test -p"*.py" --testrunner runners.AcceptanceRunner

Cobertura

Esta estructura de tests me permite algo más: puedo seleccionar la cobertura en función del tipo de test. Puede parecer una tontería, pero me interesa separar la cobertura de tests unitarios de la de los de integración, y la de aceptación no me interesa en absoluto.

Veamos primero cómo se ejecuta para los tests unitarios:

(venv)$ coverage run --source=. --omit="manage,**/test*" \
        manage.py test app/ --testrunner runners.UnitRunner -p"*.py"

Un poco larga la línea, pero fácil de entender. Tan solo le indico dónde están los fuentes y que no me interesa la cobertura de los archivos de test ni de los propios de Django. Ahora muestro el resultado:

(venv)$ coverage report

Fácil, ¿eh?

Aún más fácil

Yo soy muy vago y no voy a acordarme de una línea como ésa. Por eso he decidido meterlo todo en un Makefile:

# Archivo Makefile

modules=app

all: pep8 flakes test

full_test:: run_unit_tests run_integration_tests run_acceptance_tests report

test:: run_unit_tests run_integration_tests report

unit:: run_unit_tests report

run_unit_tests:
    @echo Running UNIT tests...
    @coverage run --source=. --omit="manage.py,**/test*"\
        manage.py test --testrunner runners.UnitRunner

run_integration_tests:
    @echo Running INTEGRATION tests...
    @coverage run --source=. --omit="manage.py,**/test*" -a\
        manage.py test --testrunner runners.IntegrationRunner

run_acceptance_tests:
    @echo Running ACCEPTANCE tests...
    @python manage.py test --testrunner runners.AcceptanceRunner

report:
    @coverage report

html_report:
    @coverage html -d coverage

pep8:
    @pep8 --statistics ${modules}

flakes:
    @pyflakes ${modules}

Como veréis, he añadido algunos detalles para pasar el pep8 y pyflakes, y algunas etiquetas para saber qué está pasando y generar informes.

Los tests unitarios crearán el archivo de cobertura, los de integración añadirán sus datos y los de aceptación no generarán cobertura en absoluto.

De esta manera puedo ejecutar los tests de unit tantas veces como quiera, que serán muy rápidos.

Los tests

Por si alguien desea probar todo esto con datos reales, aquí están los tests:

from datetime import datetime
import time


def millis2datetime(timestamp, dater=datetime.now):
    if timestamp:
        return datetime.fromtimestamp(timestamp/1000.0)
    return dater()


def datetime2millis(dtime, timer=time.time):
    if dtime:
        return time.mktime(dtime.utctimetuple()) * 1000
    return timer() * 1000

Y aquí tenéis un ejemplo de ejecución:

(venv)$ make unit
Running UNIT tests...
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Name           Stmts   Miss  Cover
----------------------------------
app/__init__       0      0   100%
app/admin          1      1     0%
app/models         1      1     0%
app/views         10      0   100%
manage             6      0   100%
prj/__init__       0      0   100%
prj/settings      17      0   100%
prj/urls           4      4     0%
prj/wsgi           4      4     0%
----------------------------------
TOTAL             43     10    77%

Se puede afinar aún más, evitando entrar en algunos archivos que no nos interesan ( wsgi, settings), pero creo que esta parte es bastante sencilla comparada con todo lo anterior XD

Más información

En la documentación de Django podéis encontrar todo lo necesario.

Por otra parte, hace un tiempo que llevo pensando en hacer unas plantillas para hacer proyectos en Django y AngularJS, y hace poco que encontré un proyecto. Sin embargo, éste usaba versiones muy antiguas copiadas sobre el propio proyecto. Lo mejoré para que las resolviera como dependencias e hice el pull-request, pero aún no me han hecho ni caso.

Podéis encontrar este proyecto con el nombre de angularjs-django-rest-framework-seed. Ya le he añadido estas mejoras.

El proyecto original parece un poco abandonado :(


Comentarios

Comments powered by Disqus