Escribiendo su primera aplicación en Django, parte 5

Este tutorial comienza donde quedó el Tutorial 4. Hemos creado una aplicación Web de encuestas y ahora vamos a crear algunas pruebas automatizadas para ella.

Dónde obtener ayuda:

If you’re having trouble going through this tutorial, please head over to the Getting Help section of the FAQ.

Presentando las pruebas automatizadas

¿Qué son las pruebas automatizadas?

Tests are routines that check the operation of your code.

Las pruebas se ejecutan en diferentes niveles. Algunas pruebas se pueden aplicar a un pequeño detalle (¿Un método de modelo particular retorna valores como se espera?) mientras que otras examinan el total funcionamiento del software (¿Una secuencia de entradas de usuarios en el sitio produce el resultado deseado?). No se diferencia del tipo de pruebas que ejecutó anteriormente en el Tutorial 2 utilizando el comando shell para examinar el comportamiento de un método, o ejecutar la aplicación e ingresar datos para comprobar cómo se comporta.

Lo que es diferente en las pruebas automatizadas es que el trabajo de pruebas por usted lo hace el sistema. Usted crea un conjunto de pruebas una vez, después, a medida que modifica su aplicación, puede comprobar que el código todavía funciona como originalmente lo concibió, sin necesidad de pruebas manuales engorrosas.

El por qué necesita crear pruebas

Entonces, ¿por qué crear pruebas y por qué ahora?

Usted podría pensar que ya con aprender Python y Django tiene bastante con qué ocuparse, y que tener que aprender y hacer aún más cosas podría ser abrumador y hasta innecesario. Después de todo, nuestra aplicación encuestas está funcionando ahora bastante bien, tomarse la molestia de crear pruebas automatizadas no va a hacer que funcione mejor. Si crear la aplicación encuestas es el último fragmento de programa de Django que hará, entonces es verdad, usted no necesita saber cómo crear pruebas automatizadas. Sin embargo, si ese no es el caso, ahora es un momento excelente para aprender.

Las pruebas le ahorrarán tiempo

Hasta cierto punto, “comprobar de que parece funcionar” será una prueba satisfactoria. En una aplicación más sofisticada, es posible que tenga decenas de interacciones complejas entre los componentes.

A change in any of those components could have unexpected consequences on the application’s behavior. Checking that it still “seems to work” could mean running through your code’s functionality with twenty different variations of your test data to make sure you haven’t broken something - not a good use of your time.

Eso es especialmente cierto cuando las pruebas automatizadas pueden hacer esto por usted en cuestión de segundos. Si algo salió mal, las pruebas también ayudarán a identificar el código que está causando el comportamiento inesperado.

A veces puede parecer un fastidio apartarse de su trabajo creativo y productivo de programación para afrontar la obligación poca atractiva y aburrida de escribir pruebas, particularmente cuando sabe que su código funciona de forma adecuada.

Sin embargo, la tarea de escribir pruebas es mucho más gratificante que pasar horas probando su aplicación de forma manual o intentando identificar la causa de un problema recién introducido.

Las pruebas no solo identifican los problemas, los previenen

Es un error pensar en las pruebas sólo como un aspecto negativo del desarrollo.

Sin las pruebas, el propósito o el comportamiento previsto de una aplicación podría ser bastante incomprensible. Aún cuando es su propio código, usted a veces se encontrará buscando en él tratando de averiguar qué es exactamente lo que hace.

Las pruebas cambian eso, ellas iluminan su código desde adentro, y cuando algo sale mal, ellas se centran en la parte que salió mal; incluso si usted no se ha dado cuenta de que salió mal.

Las pruebas hacen más atractivo su código

You might have created a brilliant piece of software, but you will find that many other developers will refuse to look at it because it lacks tests; without tests, they won’t trust it. Jacob Kaplan-Moss, one of Django’s original developers, says «Code without tests is broken by design.»

Que otros desarrolladores quieran ver pruebas en su software antes de que se lo tomen en serio es una razón más para que usted comience a escribir las pruebas.

Las pruebas ayudan a los equipos a trabajar en conjunto

Los puntos anteriores están escritos desde el punto de vista de un único desarrollador que mantiene una aplicación. Las aplicaciones complejas serán mantenidas por equipos. Las pruebas garantizan que los colegas no rompan inadvertidamente su código (y que usted no rompa el de ellos sin saber). Si quiere ganarse la vida como un programador de Django, ¡Usted debe ser bueno escribiendo pruebas!

Estrategias básicas de pruebas

Hay muchas maneras de abordar la escritura de pruebas.

Some programmers follow a discipline called «test-driven development»; they actually write their tests before they write their code. This might seem counter-intuitive, but in fact it’s similar to what most people will often do anyway: they describe a problem, then create some code to solve it. Test-driven development formalizes the problem in a Python test case.

La mayoría de las veces, un principiante en pruebas va a crear código y luego decidirá que este debe tener algunas pruebas. Tal vez hubiera sido mejor escribir algunas pruebas antes, pero nunca es demasiado tarde para empezar.

A veces es difícil saber por dónde empezar con la escritura de pruebas. Si usted ha escrito varias miles de líneas de Python, elegir algo para probar podría no ser fácil. En tal caso, es provechoso escribir su primera prueba la próxima vez que realice una modificación, ya sea cuando agregue una nueva funcionalidad o solucione un error.

Así que vamos a hacer eso de inmediato.

Escribiendo nuestra primera prueba

Identificamos un bug

Afortunadamente, hay un pequeño bug en la aplicación polls para que lo solucionemos de inmediato: el método Question.was_published_recently() retorna True si la Question fue publicada en el último día (que es correcto), pero también si el campo de pub_date de Question está en el futuro (que de hecho no lo está).

Confirm the bug by using the shell to check the method on a question whose date lies in the future:

$ python manage.py shell
...\> py manage.py 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

Puesto que las cosas en el futuro no son “recientes”, esto es claramente erróneo.

Cree una prueba para mostrar el bug

Lo que acabamos de hacer en el comando shell para detectar el problema es exactamente lo que podemos hacer en una prueba automatizada, así que vamos a convertir eso en una prueba automatizada.

Un lugar convencional para las pruebas de una aplicación se encuentra en el archivo tests.py de la aplicación; el sistema de pruebas encontrará automáticamente las pruebas en cualquier archivo cuyo nombre comience con test.

Ponga lo siguiente en el archivo tests.py de la aplicación polls:

polls/tests.py
import datetime

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

from .models import Question


class QuestionModelTests(TestCase):

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

Here we have created a django.test.TestCase subclass with a method that creates a Question instance with a pub_date in the future. We then check the output of was_published_recently() - which ought to be False.

Ejecutando las pruebas

In the terminal, we can run our test:

$ python manage.py test polls
...\> py manage.py test polls

y usted verá algo como:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

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

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

Different error?

If instead you’re getting a NameError here, you may have missed a step in Part 2 where we added imports of datetime and timezone to polls/models.py. Copy the imports from that section, and try running your tests again.

Esto es lo que ocurrió:

  • manage.py test polls looked for tests in the polls application
  • encontró una subclase de la: clase: clase django.test.TestCase
  • creó una base de datos especial para las pruebas
  • buscó métodos de prueba; aquellos cuyos nombres comienzan con test
  • en test_was_published_recently_with_future_question creó una instancia Question cuyo campo de pub_date está 30 días en el futuro
  • … y utilizando el método assertIs() descubrió que was_published_recently() retorna True, aunque queríamos que retornara False

La prueba nos informa que prueba falló e incluso la línea en la que se produjo el error.

Solucionando el bug

Ya sabemos cuál es el problema: Question.was_published_recently() debe devolver False si su pub_date está en el futuro. Modifique el método models.py, de modo que este sólo retornará True si la fecha está también 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 ejecute la prueba de nuevo:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

Después de identificar un bug, escribimos una prueba que lo expone y corregimos el bug en el código para que nuestra prueba pase.

Many other things might go wrong with our application in the future, but we can be sure that we won’t inadvertently reintroduce this bug, because running the test will warn us immediately. We can consider this little portion of the application pinned down safely forever.

Pruebas más exhaustivas

Ya que estamos aquí, podemos además precisar el método was_published_recently(); de hecho, sería verdaderamente vergonzoso si al corregir un bug hubiéramos introducido otro.

Agregue dos métodos de pruebas más a la misma clase, para probar el comportamiento del método de forma más completa:

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

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

Y ahora tenemos tres pruebas que confirman que Question.was_published_recently() retorna valores razonables para preguntas pasadas, recientes y futuras.

Again, polls is a minimal application, but however complex it grows in the future and whatever other code it interacts with, we now have some guarantee that the method we have written tests for will behave in expected ways.

Pruebe una vista

La aplicación encuestas no discrimina en lo absoluto: publicará cualquier pregunta, incluyendo aquellas cuyo campo de pub_date esté en el futuro. Deberíamos actualizar esto. Establecer un pub_date en el futuro debería significar que la pregunta se publica en ese momento, pero que será invisible hasta entonces.

Una prueba para una vista

When we fixed the bug above, we wrote the test first and then the code to fix it. In fact that was an example of test-driven development, but it doesn’t really matter in which order we do the work.

En nuestra primera prueba, nos centramos detalladamente en el comportamiento interno del código. Para esta prueba, queremos comprobar su comportamiento como lo experimentaría un usuario a través de un navegador web.

Antes de tratar de corregir algo, echemos un vistazo a las herramientas a nuestra disposición.

El cliente de prueba de Django

Django proporciona una prueba Client para simular un usuario interactúando con el código al nivel de la vista. Podemos utilizarlo en tests.py o incluso en el shell.

We will start again with the shell, where we need to do a couple of things that won’t be necessary in tests.py. The first is to set up the test environment in the shell:

$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() instala un procesador de plantillas que nos permite examinar algunos atributos adicionales sobre respuestas como response.context que de otra forma no estarían disponibles. Tenga en cuenta que este método no crea una base de datos de pruebas, por lo que lo siguiente se ejecutará contra la base de datos existente y la salida puede variar levemente dependiendo de cuáles preguntas usted ya creó. Puede obtener resultados inesperados si su TIME_ZONE en el archivo settings.py no es correcto. Si no recuerda haberlo configurado anteriormente, verifíquelo antes de continuar.

A continuación tenemos que importar la clase de cliente de prueba (más tarde en tests.py usaremos la clase django.test.TestCase que viene con su propio cliente, por lo que esto no será necesario):

>>> 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 un trabajo para nosotros:

>>> # get a response from '/'
>>> response = client.get('/')
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> 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.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#x27;s up?</a></li>\n    \n    </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Mejorando nuestra vista

La lista de encuestas muestra las encuestas que aún no están publicadas (es decir, aquellas que tienen una pub_date en el futuro). Vamos a solucionar eso.

En el Tutorial 4 presentamos una vista basada en clases, basada en 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]

Tenemos que modificar el método get_queryset() y cambiarlo para que también compruebe la fecha comparándolo con timezone.now(). En primer lugar tenemos que añadir un import:

polls/views.py
from django.utils import timezone

y luego hay que modificar el método get_queryset como sigue:

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()) retorna un queryset que contiene Questions cuya pub_date es menor o igual a, es decir, anterior o igual a timezone.now.

Probando nuestra nueva vista

Now you can satisfy yourself that this behaves as expected by firing up runserver, loading the site in your browser, creating Questions with dates in the past and future, and checking that only those that have been published are listed. You don’t want to have to do that every single time you make any change that might affect this - so let’s also create a test, based on our shell session above.

Agregue lo siguiente a polls/tests.py:

polls/tests.py
from django.urls import reverse

y vamos a crear una función de atajo para crear preguntas, así como una nueva clase de pruebas:

polls/tests.py
def create_question(question_text, days):
    """
    Create a question with the given `question_text` and 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 QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is 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_past_question(self):
        """
        Questions with a pub_date in the past are 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_future_question(self):
        """
        Questions with a pub_date in the future aren't 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.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are 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_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.>']
        )

Veamos algunas de estas más de cerca.

En primer lugar es una función de atajo a las preguntas, create_question, para eliminar parte de la repetición del proceso de creación de preguntas.

test_no_questions no crea ninguna pregunta, pero comprueba el mensaje: «No hay encuestas disponibles.» y verifica que latest_question_list esté vacía. Tenga en cuenta que la clase django.test.TestCase ofrece algunos métodos de aserción adicionales. En estos ejemplos utilizamos :meth:~django.test.SimpleTestCase.assertContains()` y assertQuerysetEqual().

En test_past_question creamos una pregunta y verificamos que aparezca en la lista.

En test_future_question creamos una pregunta con una pub_date en el futuro. La base de datos se reinicia para cada método de prueba, por lo que la primera pregunta ya no está allí, de modo que de nuevo el índice no debería tener ninguna pregunta.

Y así sucesivamente. En efecto, estamos utilizando las pruebas para contar una historia del ingreso de datos en el sitio administrativo y experiencia de usuario en el sitio, y comprobar que los resultados esperados se publican en cada estado y para cada nuevo cambio en el estado del sistema.

Probando la DetailView

Lo que tenemos funciona bien; sin embargo, a pesar de que las preguntas futuras no aparecen en el índice, los usuarios pueden todavía llegar a ellas si saben o adivinan la dirección URL correcta. Así que tenemos que agregar una restricción similar a la DetailView:

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, vamos a agregar algunas pruebas para comprobar que una Question cuya pub_date que está en el pasado se puede mostrar y que una con una pub_date en el futuro no se pueda:

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

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

Ideas para más pruebas

Debemos agregar un método get_queryset similar a ResultsView y crear una nueva clase de pruebas para esa vista. Va a ser muy similar a lo que acabamos de crear; de hecho, habrá mucha repetición.

También podríamos mejorar nuestra aplicación de otras maneras, agregando pruebas a lo largo del camino. Por ejemplo, es una tontería que se puedan publicar Questions en el sitio que no tienen Choices. Por lo tanto, nuestras vistas podrían comprobar esto y excluir dichas Questions. Nuestras pruebas crearían una Question sin Choices y luego probarían que no está publicada, así como crearían una Question similar con Choices y probarían que está publicada.

Quizás se les debería permitir a los usuarios registrados en el sitio administrativo ver las Questions que no han sido publicadas, pero no a los visitantes ordinarios. Una vez más: lo que se tenga que agregar al software para lograr esto debería ir acompañado de una prueba, ya sea que escriba la prueba primero y luego haga que el código pase la prueba, o primero solucione la lógica en el código y luego escriba una prueba para probarlo.

En un momento dado usted está obligado a examinar sus pruebas y a preguntarse si su código sufre de exceso de pruebas, lo que nos lleva a:

Cuando se trata de pruebas, más es mejor

Podría parecer que nuestras pruebas están creciendo fuera de control. A este paso pronto habrá más código en nuestras pruebas que en nuestra aplicación y la repetición es poco estética en comparación con la refinada concisión del resto de nuestro código.

No importa, deje que crezcan. Por lo general, usted puede escribir una prueba una vez y luego olvidarse de ella. Esta seguirá cumpliendo su función útil mientras usted continúe desarrollando su programa.

A veces las pruebas tendrán que ser actualizadas. Supongamos que modificamos nuestras vistas de modo que sólo las Questions con Choices se publiquen. En ese caso, muchas de nuestras pruebas existentes fallarán, indicándonos exactamente qué pruebas necesitan ser modificadas para actualizarlas, de modo que hasta ese punto las pruebas ayudan a ocuparse de sí mismas.

En el peor de los casos, a medida que continúe desarrollando, usted podría encontrar que tiene algunas pruebas que ahora son redundantes. Incluso eso no es un problema, cuando se trata de pruebas, la redundancia es algo bueno.

Siempre y cuando las pruebas se organicen de forma razonable, no van a ser difíciles de manejar. Las reglas básicas de uso incluyen el hecho de tener:

  • una TestClass independiente para cada modelo o vista
  • un método de prueba independiente para cada conjunto de condiciones que usted quiere probar
  • nombres de los métodos de prueba que describan su función

Pruebas adicionales

Este tutorial sólo presenta algunos de los temas fundamentales de las pruebas. Hay mucho más que usted puede hacer y una serie de herramientas muy útiles a su disposición para lograr algunas cosas muy interesantes.

Por ejemplo, aunque nuestras pruebas han tratado aquí algo de la lógica interna de un modelo y la forma en que nuestras vistas publican información, usted puede utilizar un framework «en el navegador» como Selenium para probar la forma en que su HTML en realidad se renderiza en un navegador. Estas herramientas le permiten comprobar no sólo el comportamiento de su código Django, sino también, por ejemplo, el de su JavaScript. ¡Es impresionante ver cómo las pruebas ejecutan un navegador y comienzan a interactuar con su sitio como si estuviese siendo operado por un humano! Django incluye LiveServerTestCase para facilitar la integración con herramientas como Selenium.

Si usted tiene una aplicación compleja, es posible que desee ejecutar las pruebas de forma automática con cada commit a efectos de la integración continua_, por lo que el control de calidad está en sí, al menos parcialmente, automatizado.

Una buena manera de detectar las partes de su aplicación que no han sido probadas es comprobar la cobertura de código. Esto también ayuda a identificar el código frágil o incluso muerto. Si no puede probar un trozo de código, por lo general significa que el código debe ser reestructurado o eliminado. La cobertura ayudará a identificar el código muerto. Consulte Integration with coverage.py para más detalles.

Las pruebas en Django tiene información completa acerca de las pruebas.

¿Qué sigue?

Para más detalles sobre las pruebas, consulte Las pruebas en Django.

Cuando esté familiarizado con las pruebas de las vistas de Django, lea la parte 6 de este tutorial para aprender sobre la gestión de los archivos estáticos.

Back to Top