Escribiendo tu primera Django app, parte 5

Este tutorial comienza donde dejó el Tutorial 4. Hemos construido una aplicación de preguntas, y ahora vamos a agregar algunos tests automáticos para la misma.

Introduciendo testing automatizado

Qué son los tests automatizados?

Los tests son rutinas simples que chequean el funcionamiento de nuestro código.

El testing opera a diferentes niveles. Algunos tests pueden aplicar a un detalle menor - un método particular de un modelo devuelve el valor esperado?, mientras otros verifican el funcionamiento general del software - una secuencia de entradas del usuario producen el resultado deseado? No difiere del tipo de pruebas que uno hacía en el Tutorial 1, usando el shell para examinar el comportamiento de un método, o correr la aplicación e ingresar datos para chequear cómo se comporta.

Lo que diferencia a los tests automatizados es que el trabajo de hacer las pruebas lo hace el sistema por uno. Uno crea un conjunto de tests una vez, y luego a medida que se hacen cambios a la app, se puede verificar que el código todavía funciona como estaba originalmente pensado, sin tener que usar tiempo para hacer testing manual.

Por qué es necesario tener tests

Por qué crear tests, y por qué ahora?

Uno podría pensar que ya tiene suficiente con ir aprendiendo Python/Django, y todavía tener que aprender algo más puede parecer demasiado y quizás innecesario. Después de todo nuestra aplicación de encuestas está funcionando; tomarse el trabajo de escribir tests automatizados no va a hacer que funcione mejor. Si crear esta aplicación de encuestas es la última tarea de programación con Django que vas a hacer, es cierto, no necesitás saber cómo crear tests automatizados. Pero, si no es el caso, este es un excelente momento para aprenderlo.

Los tests nos ahorrarán tiempo

Hasta cierto punto, ‘chequear que todo parece funcionar’ es un test satisfactorio. En una aplicación más sofisticada, uno podría tener docenas de interacciones complejas entre componentes.

Un cambio en cualquiera de esos componentes podría tener consecuencias inesperadas en el comportamiento de la aplicación. Chequear que todavía ‘parece funcionar’ podría significar recorrer el funcionamiento del código con veinte variaciones diferentes de pruebas para estar seguros de que no se rompió nada - no es un buen uso del tiempo.

Esto es particularmente cierto cuando tests automatizados podrían hacerlo por uno en segundos. Si algo se rompió, los tests también ayudan a identificar el código que está causando el comportamiento inesperado.

Algunas veces puede parecer una tarea que nos distrae de nuestro creativo trabajo de programación para dedicarnos al poco atractivo asunto de escribir tests, especialmente cuando uno sabe que el código está funcionando correctamente.

Sin embargo, la tarea de escribir tests rinde mucho más que gastar horas probando la aplicación manualmente o intentando identificar la causa de un problema que se haya producido con un cambio.

Los tests no sólo identifican problemas, los previenen

Es un error pensar que los tests son un aspecto negativo del desarrollo.

Sin tests, el propósito o comportamiento esperado de una aplicación podría ser poco claro. Incluso siendo código propio, algunas veces uno se encuentra tratando de adivinar qué es lo que hacía exactamente.

Los tests cambian eso; iluminan el código desde adentro, y cuando algo va mal, iluminan la parte que va mal - aún si uno no se dio cuenta de que algo va mal.

Los tests hacen el código más atractivo

Uno podría crear una obra de software brillante, pero muchos desarrolladores simplemente se van a rehusar de verla porque no tiene tests; sin tests, no van a confiar en ese software. Jacob Kaplan-Moss, uno de los desarrolladores originales de Django, dice “El código sin tests está roto por diseño”.

El hecho de que otros desarrolladores quieran ver tests en nuestro software antes de tomarlo seriamente es otra razón para empezar a escribir tests.

Los tests ayudan a trabajar a un equipo

Los puntos anteriores están escritos desde el punto de vista de un único desarrollador manteniendo una aplicación. Aplicaciones complejas son mantenidas por equipos. Los tests garantizan que otros colegas no rompan nuestro código sin darse cuenta (y que uno no rompe el de ellos sin saberlo). Si uno quiere vivir de la programación con Django, debe ser bueno escribiendo tests!

Estrategias de testing básicas

Hay varias maneras de aprender a escribir tests.

Algunos programadores siguen una disciplina llamada “test-driven development” (desarrollo dirigido por tests); los tests se escriben antes de escribir el código. Puede parecer contra intuitivo, pero en realidad es similar a lo que la mayoría de la gente haría: describir un problema, luego crear el código que lo resuelve. Test-driven development simplemente formaliza el problema como un caso de test Python.

A menudo, alguien nuevo en testing va a crear código y más tarde decidir que debería tener algunos tests. Tal vez hubiera sido mejor escribir los tests antes, pero nunca es tarde para empezar.

A veces es difícil darse cuenta por dónde empezar al escribir tests. Si uno escribió varios miles de líneas de Python, elegir qué testear puede no ser fácil. En ese caso, es provechoso escribir el primer test la próxima vez que uno hace un cambio, ya sea una nueva funcionalidad o un fix de un bug.

Vamos a hacer eso entonces.

Escribiendo nuestro primer test

Identificamos un bug

Por suerte, hay un pequeño bug en la aplicación polls que podemos arreglar: el método Question.was_published_recently() devuelve True si una Question fue publicada el día anterior (que está bien), pero también si el campo pub_date es en el futuro (que no está bien!).

Podemos verlo en el Admin; creamos una pregunta cuya fecha es en el futuro; veremos que el listado de preguntas nos dice que fue publicada recientemente.

También podemos verlo usando el shell:

>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

Dado que las cosas en el futuro no son ‘recientes’, esto es incorrecto.

Creamos un test que exponga el bug

Lo que acabamos de hacer en el shell para verificar el problema es exactamente lo que podemos hacer en un test automatizado. Hagámoslo entonces.

El lugar por convención para los tests de una aplicación es el archivo tests.py - el corredor de tests va a buscar los tests allí automáticamente, en aquellos archivos cuyo nombre empiece po test.

Ponemos el siguiente código en el archivo tests.py en la aplicación polls:

polls/tests.py
import datetime

from django.utils import timezone
from django.test import TestCase

from .models import Question


class QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() should return False for questions whose
        pub_date is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertEqual(future_question.was_published_recently(), False)

Lo que hemos hecho aquí es crear una subclase de django.test.TestCase con un método que crea una instancia de Question con un valor de pub_date en el futuro. Luego chequeamos la salida de was_published_recently() - que debería ser False.

Corriendo los tests

En la terminal, podemos correr nuestro test:

$ python manage.py test polls

y veremos algo como:

Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertEqual(future_question.was_published_recently(), False)
AssertionError: True != False

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

Qué paso aquí:

  • python manage.py test polls buscó tests en la aplicación polls

  • encontró una subclase de django.test.TestCase

  • creó una base de datos especial para los tests

  • buscó los métodos de test - aquellos cuyo nombre comienza con test

  • en test_was_published_recently_with_future_question creó una instancia de Question cuyo valor para el campo pub_date es 30 días en el futuro

  • ... y usando el método assertEqual(), descubrió que was_published_recently() devuelve True, a pesar de que queríamos que devolviera False

La corrida nos informa qué test falló e incluso la línea en la que se produjo la falla.

Arreglando el bug

Ya sabemos cuál es el problema: Question.was_published_recently() debería devolver False si pub_date tiene un valor en el futuro. Corregimos el método en models.py, para que sólo devuelva True si además la fecha es en el pasado:

polls/models.py
def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

y corremos el test nuevamente:

Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

Después de identificar un bug, escribimos el test que lo expone y corregimos el problema en el código para que nuestro test pase.

Muchas cosas pueden salir mal con nuestra aplicación en el futuro, pero podemos estar seguros de que no vamos a reintroducir este bug inadvertidamente, porque sólo correr el test nos lo haría notar inmediatemente. Podemos considerar esta porción de nuestra aplicación funcionando y cubierta a futuro.

Tests más exhaustivos

Mientras estamos aquí, podemos mejorar la cobertura del método was_published_recently(); de hecho, sería vergonzoso si arreglando un bug hubiéramos introducido uno nuevo.

Agregamos dos métodos más a la misma clase, para testear el comportamiento del método de forma más exhaustiva:

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() should return False for questions whose
    pub_date is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=30)
    old_question = Question(pub_date=time)
    self.assertEqual(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() should return True for questions whose
    pub_date is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertEqual(recent_question.was_published_recently(), True)

Y ahora tenemos tres tests que confirman que Question.was_published_recently() devuelve valores sensatos para preguntas pasadas, recientes y futuras.

De nuevo, polls es una aplicación simple, pero sin importar lo complejo que pueda crecer en el futuro o la interacción que pueda tener con otro código, tenemos alguna garantía de que el método para el cual hemos escrito tests se comportará de la manera esperada.

Testeando una view

La aplicación polls no discrimina: va a publicar cualquier pregunta, incluyendo aquellas cuyo campo pub_date tiene un valor en el futuro. Deberíamos mejorar esto. Tener un pub_date en el futuro debería significar que la pregunta se publica en ese momento, pero permanece invisible hasta entonces.

Un test para una view

Cuando arreglamos el bug de arriba, escribimos un test primero y luego el código que lo arreglaba. De hecho fue un ejemplo simple de test-driven development, pero no importa en realidad el orden en que lo hicimos.

En nuestro primer test nos concentramos en el comportamiento interno del código. Para este test, queremos chequear el comportamiento como lo experimentaría un usuario mediante el browser.

Antes de intentar arreglar cualquier cosa, veamos las herramientas a nuestra disposición.

El cliente para test de Django

Django provee un cliente para test, Client, para simular la interacción del usuario con el código a nivel view. Podemos usarlo en tests.py o incluso en el shell.

Empezaremos de nuevo con el shell, donde necesitamos hacer un par de cosas que no serán necesarias en tests.py. La primera es crear el ambiente de test en el shell:

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() instala un renderizador de templates que nos permitirá examinar algunos atributos adicionales en las respuestas, tales como response.context, que de otra forma no estarían disponibles. Notar que este método no configura una base de datos para testing, entonces lo que corramos a continuación va a ser contra la base de datos existente y la salida podría diferir dependiendo de las preguntas que hayamos creado.

A continuación necesitamos importar la clase del cliente para test (luego en tests.py vamos a usar la clase django.test.TestCase, que viene con su propio cliente, así que este paso no será requerido):

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

Con eso listo, podemos pedirle al cliente que haga trabajo por nosotros:

>>> # get a response from '/'
>>> response = client.get('/')
>>> # we should expect a 404 from that address
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.core.urlresolvers import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
'\n\n\n    <p>No polls are available.</p>\n\n'
>>> # note - you might get unexpected results if your ``TIME_ZONE``
>>> # in ``settings.py`` is not correct. If you need to change it,
>>> # you will also need to restart your shell session
>>> from polls.models import Question
>>> from django.utils import timezone
>>> # create a Question and save it
>>> q = Question(question_text="Who is your favorite Beatle?", pub_date=timezone.now())
>>> q.save()
>>> # check the response once again
>>> response = client.get('/polls/')
>>> response.content
'\n\n\n    <ul>\n    \n        <li><a href="/polls/1/">Who is your favorite Beatle?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
[<Question: Who is your favorite Beatle?>]

Mejorando nuestra view

La lista de preguntas nos muestra entradas que no están publicadas todavía (i.e. aquellas que tienen pub_date en el futuro). Arreglémoslo.

En el Tutorial 4 reemplazamos las funciones de view en views.py por ListView:

polls/views.py
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

response.context_data['latest_question_list'] extrae los datos que la view pone en el contexto.

Necesitamos corregir el método get_queryset y cambiarlo de tal manera que también chequee la fecha, comparándola con timezone.now(). Primero necesitamos hacer un import:

polls/views.py
from django.utils import timezone

y luego corregimos el método get_queryset de la siguiente manera:

polls/views.py
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now) devuelve un queryset que contiene las instancias de Question cuyo campo pub_date es menor o igual que - esto es, anterior o igual a - timezone.now.

Testeando nuestra nueva view

Ahora podemos verificar que se comporta como esperamos levantando el servidor de desarrollo, cargando el sitio en el browser, creando Questions con fechas en el pasado y en el futuro, y chequeando que solamente se listan aquellas que han sido publicadas. Uno no quiere repetir estos pasos cada vez que se hace un cambio que podría afectar esto - vamos a crear entonces un test, basándonos en nuestra sesión anterior con el shell.

Agregamos lo siguiente a polls/tests.py:

polls/tests.py
from django.core.urlresolvers import reverse

vamos a crear un método de atajo para crear preguntas, como así también una nueva clase de test:

polls/tests.py
def create_question(question_text, days):
    """
    Creates a question with the given `question_text` published the given
    number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text,
                                   pub_date=time)


class QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.",
                            status_code=200)
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be displayed.
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_index_view_with_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

Vamos a mirarlo más detenidamente.

Primero tenemos una función, create_question, para evitar la repetición en el proceso de crear preguntas.

test_index_view_with_no_questions no crea preguntas, pero chequea el mensaje “No polls are available.” y verifica que latest_question_list es vacío. Notar que la clase django.test.TestCase provee algunos métodos de aserción adicionales. En estos ejemplos usamos assertContains() y assertQuerysetEqual().

En test_index_view_with_a_past_question, creamos una pregunta y verificamos que aparece en el listado.

En test_index_view_with_a_future_question, creamos una pregunta con pub_date en el futuro. La base de datos se resetea para cada método de test, entonces la primera pregunta no está más, y entonces nuevamente no deberíamos tener ninguna entrada en el listado.

Y así sucesivamente. En efecto, estamos usando los tests para contar una historia de entrada de preguntas y la experiencia del usuario en el sitio, y chequeando que en cada estado y para cada cambio en el estado del sistema, se publican los resultados esperados.

Testeando DetailView

Lo que tenemos funciona bien; sin embargo, aunque las encuestas futuras no aparecen en el index, un usuario todavía puede verlas si saben o adivinan la URL correcta. Necesitamos restricciones similares para DetailViews, para lo cual agregamos:

polls/views.py
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

Y por supuesto, agregaremos más tests para chequear que una Question cuya pub_date es en el pasado se puede ver, mientras que una con pub_date en el futuro, no:

polls/tests.py
class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return a 404 not found.
        """
        future_question = create_question(question_text='Future question.',
                                          days=5)
        response = self.client.get(reverse('polls:detail',
                                   args=(future_question.id,)))
        self.assertEqual(response.status_code, 404)

    def test_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display the question's text.
        """
        past_question = create_question(question_text='Past Question.',
                                        days=-5)
        response = self.client.get(reverse('polls:detail',
                                   args=(past_question.id,)))
        self.assertContains(response, past_question.question_text,
                            status_code=200)

Ideas para otros tests

Deberíamos agregar un método get_queryset similar al de ResultsView y crear una nueva clase para los tests de esta view. Sería parecido a lo que hemos hecho ya; de hecho, habría bastante repetición.

Podríamos también mejorar nuestra aplicación de diversas maneras, agregando tests en el camino. Por ejemplo, es tonto permitir que preguntas sin opciones se puedan publicar en el sitio. Entonces, nuestras views podrían chequear esto, y excluir esas preguntas. Los tests crearían una instancia de Question sin Choices relacionados, y luego verificarían que no se publica, como así también que si se crea una instancia de Question con Choices, se verifica que se publica.

Quizás usuarios administradores logueados deberían poder ver preguntas no publicadas, pero los demás usuarios no. Una vez más: cualquier funcionalidad que necesite agregarse debe estar acompañada por tests, ya sea escribiendo el test primero y luego el código que lo hace pasar, o escribir el código de la funcionalidad primero y luego escribir el test para probarla.

En cierto punto uno mira sus tests y se pregunta si el código de los tests no está creciendo demasiado, lo que nos lleva a:

Tratándose de tests, más es mejor

Puede parecer que nuestros tests están creciendo fuera de control. A este ritmo pronto tendremos más código en nuestros tests que en nuestra aplicación, y la repetición no es estética, comparada con lo conciso y elegante del resto de nuestro código.

No importa. Dejémoslos crecer. En gran medida, uno escribe un test una vez y luego se olvida. Va a seguir cumpliendo su función mientras uno continúa desarrollando su programa.

Algunas veces los tests van a necesitar actualizarse. Supongamos que corregimos nuestras views para que solamente se publiquen Questions con Choices. En ese caso, muchos de nuestros tests existentes van a fallar - diciéndonos qué tests actualizar y corregir, así que hasta cierto punto los tests pueden cuidarse ellos mismos.

A lo sumo, mientras uno continúa desarrollando, se puede encontrar que hay algunos tests que se hacen redundantes. Incluso esto no es un problema; en testing, la redundancia es algo bueno.

Mientras que los tests estén organizados de manera razonable, no se van a hacer inmanejables. Algunas buenas prácticas:

  • un TestClass separado para cada modelo o view

  • un método de test separado para cada conjunto de condiciones a probar

  • nombres de método de test que describan su función

Testing adicional

Este tutorial solamente presenta lo básico sobre testing. Hay bastante más que se puede hacer, y existen herramientas muy útiles a disposición para lograr cosas muy interesantes.

Por ejemplo, mientras que nuestros tests han cubierto la lógica interna de un modelo y la forma en que nuestras views publican información, uno podría usar un framework “in-browser” como Selenium para testear la manera en que el HTML se renderiza en un browser. Estas herramientas nos permiten no sólo chequear el comportamiento de nuestro código Django, si no también, por ejemplo, nuestro JavaScript. Es algo muy curioso ver los tests ejecutar un browser y empezar a interactuar con nuestro sitio como si un humano lo estuviera controlando! Django incluye LiveServerTestCase para facilitar la integración con herramientas como Selenium.

Si uno tiene una aplicación compleja, podría querer correr los tests automáticamente con cada commit con el propósito de ‘integración continua’_, de tal manera de automatizar - al menos parcialmente - el control de calidad.

Una buena forma de encontrar partes de nuestra aplicación sin testear es chequar el cubrimiento del código. Esto también ayuda a identificar código frágil o muerto. Si uno no puede testear un fragmento de código, en general significa que ese código debería refactorearse o borrarse. Ver Integración con coverage para más detalles.

Testing en Django tiene información exhaustiva sobre testing.

Qué sigue?

Para un detalle completo sobre testing, ver Testing en Django.

Una vez que estés cómodo con el testing de views en Django, podés leer la parte 6 de este tutorial para aprender sobre el manejo de recursos estáticos.