Esta tutoría comienza donde Tutorial 4 dejó. Hemos construido una aplicación de encuesta web y ahora crearemos algunas pruebas automatizadas para ella.
¿Dónde obtener ayuda:
Si tienes problemas al seguir este tutorial, por favor dirígete a la sección Ayuda del FAQ.
Las pruebas son rutinas que verifican el funcionamiento de tu código.
La prueba opera en diferentes niveles. Algunas pruebas pueden aplicarse a detalles pequeños (¿devuelve un método del modelo los valores esperados?) mientras que otras examinan la operación general del software (¿produce una secuencia de entradas de usuario en el sitio el resultado deseado?). Eso no es diferente de lo que hiciste anteriormente en Tutorial 2, utilizando el shell para examinar el comportamiento de un método, o ejecutando la aplicación y entrando datos para comprobar cómo se comporta.
Lo que cambia en las pruebas automatizadas es que el trabajo de prueba se hace por ti por el sistema. Crea un conjunto de pruebas una vez y luego, a medida que hagas cambios en tu aplicación, puedes verificar que tu código sigue funcionando como originalmente pretendías sin tener que realizar pruebas manuales consumidoras de tiempo.
Entonces, ¿por qué crear pruebas y por qué ahora?
Puedes sentir que ya tienes bastante en tu plato solo aprendiendo Python/Django, y tener aún otra cosa que aprender y hacer puede parecer abrumador e innecesario. Después de todo, nuestra aplicación de encuestas funciona muy bien ahora; pasar por el trabajo de crear pruebas automatizadas no la hará funcionar mejor. Si crear la aplicación de encuestas es la última parte del programa Django que nunca más volverás a hacer, entonces sí, no necesitas saber cómo crear pruebas automatizadas. Pero si eso no es el caso, ahora es un momento excelente para aprender.
Hasta cierto punto, “comprobar que parece funcionar” será una prueba satisfactoria. En una aplicación más sofisticada, podrías tener docenas de interacciones complejas entre componentes.
Un cambio en cualquier uno de esos componentes podría tener consecuencias inesperadas en el comportamiento de la aplicación. Comprobar que todavía “parece funcionar” podría significar pasar por tu código con veinte variaciones diferentes de tus datos de prueba para asegurarte de no haber roto nada - no es un buen uso de tu tiempo.
Eso es especialmente cierto cuando las pruebas automatizadas podrían hacer esto por ti en segundos. Si algo ha ido mal, las pruebas también te ayudarán a identificar el código que está causando el comportamiento inesperado.
A veces puede parecer un deber fastidiante alejarte de tu trabajo productivo y creativo para enfrentarte al negocio poco glamuroso e insulso de escribir pruebas, especialmente cuando sabes que tu código funciona correctamente.
Sin embargo, la tarea de escribir pruebas es mucho más gratificante que pasar horas probando manualmente tu aplicación o intentar identificar la causa de un problema recién introducido.
Es un error considerar las pruebas como un aspecto negativo del desarrollo.
Sin pruebas, el propósito o comportamiento intencionado de una aplicación puede ser bastante opaco. Incluso cuando es tu propio código, a veces te encontrarás poniéndote a buscar en él tratando de averiguar qué exactamente está haciendo.
Los textos traducidos son:
Puede que hayas creado una pieza de software brillante, pero encontrarás que muchos otros desarrolladores se negarán a mirarla porque carece de tests; sin tests, no la confiarán. Jacob Kaplan-Moss, uno de los desarrolladores originales de Django, dice «El código sin tests está roto por diseño».
Que otros desarrolladores quieran ver tests en tu software antes de tomarlo en serio es otra razón más para que comiences a escribir tests.
Los puntos anteriores están escritos desde el punto de vista de un desarrollador individual que mantiene una aplicación. Las aplicaciones complejas serán mantenidas por equipos. Los tests garantizan que los colegas no rompan accidentalmente tu código (y tú no rompas el suyo sin saberlo). Si quieres ganarte la vida como programador Django, debes ser bueno escribiendo tests.
Hay muchas formas de abordar la escritura de tests.
Algunos programadores siguen una disciplina llamada «desarrollo dirigido por pruebas»; escriben sus tests antes de escribir su código. Esto puede parecer contraintuitivo, pero en realidad es similar a lo que la mayoría de las personas harán a menudo: describen un problema y luego crean algún código para resolverlo. El desarrollo dirigido por pruebas formaliza el problema en una prueba de Python.
Más a menudo, un principiante en testing creará algún código y más tarde decidirá que debería tener algunos tests. Quizá habría sido mejor escribir algunos tests antes, pero nunca es demasiado tarde para empezar.
A continuación te dejo las traducciones de los textos originales, manteniendo todas sus etiquetas intactas.
Vamos a empezar de inmediato.
Afortunadamente, hay un pequeño error en la aplicación polls para que lo podamos corregir de inmediato: el método Question.was_published_recently() devuelve True si la pregunta fue publicada dentro del último día (lo cual es correcto) pero también si el campo pub_date de la pregunta está en el futuro (lo cual ciertamente no lo está).
Confirmamos el error utilizando el shell para comprobar el método en una pregunta cuya fecha esté en el futuro:
$ python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> # 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 del futuro no son “recientes”, esto es claramente incorrecto.
Lo que hemos hecho recién en el shell para probar el problema es exactamente lo que podemos hacer en un test automatizado, así que vamos a convertirlo en un test automatizado.
Un lugar convencional para los tests de una aplicación es en el archivo tests.py de la aplicación; el sistema de pruebas encontrará automáticamente los tests en cualquier archivo cuyo nombre comience con test.
polls/tests.py
tests.py de la aplicación polls, pon lo siguiente:¶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)
Aquí hemos creado una subclase de django.test.TestCase con un método que crea una instancia de Question con una fecha de publicación en el futuro. Luego comprobamos la salida de was_published_recently() - que debería ser False.
En la terminal, podemos ejecutar nuestra prueba:
$ python manage.py test polls
y verás 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/djangotutorial/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'...
Error diferente?
Si en su lugar estás obteniendo un NameError aquí, es posible que hayas omitido un paso en Parte 2 donde agregamos importaciones de datetime y timezone a polls/models.py. Copia las importaciones desde esa sección y vuelve a ejecutar tus pruebas.
¿Qué pasó es esto:
manage.py test polls buscaba pruebas en la aplicación polls
encontró una subclase de la clase django.test.TestCase
creó una base de datos especial con el fin de probar
buscó métodos de prueba - aquellos cuyos nombres comienzan con test
en test_was_published_recently_with_future_question creó una instancia de Question cuyo campo pub_date es 30 días en el futuro
… y utilizando el método assertIs() descubrió que su was_published_recently() devuelve True, aunque queríamos que devolviera False
La prueba nos informa cuál fue la prueba fallida y hasta qué línea ocurrió el error.
Ya sabemos qué es el problema: Question.was_published_recently() debería devolver False si su campo pub_date está en el futuro. Enmendamos el método en models.py, de modo que solo devuelva True si la fecha también está 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 ejecutamos la prueba nuevamente:
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.
Muchas otras cosas pueden ir mal con nuestra aplicación en el futuro, pero podemos estar seguros de que no reintroduciremos este bug inadvertidamente, porque ejecutar la prueba nos advertirá inmediatamente. Podemos considerar esta pequeña parte de la aplicación como segura para siempre.
Mientras estamos aquí, podemos pinchar aún más el método was_published_recently(); en realidad, sería muy vergonzoso si al solucionar un bug hubiéramos introducido otro.
Agreguemos dos métodos de prueba adicionales a la misma clase, para probar el comportamiento del método de manera más exhaustiva:
tests.py de la aplicación polls, pon lo siguiente:¶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() devuelve valores sensatos para preguntas pasadas, recientes y futuras.
Nuevamente, polls es una aplicación mínima, pero sin importar cuán compleja crezca en el futuro o qué otra código interactúe con ella, ahora tenemos alguna garantía de que el método que hemos escrito pruebas para se comportará de manera esperada.
La aplicación de encuestas es bastante indiscerniente: publica cualquier pregunta, incluidas las cuyos campos pub_date están en el futuro. Debemos mejorar esto. Establecer un campo pub_date en el futuro debería significar que la Pregunta se publica en ese momento, pero invisible hasta entonces.
Cuando solucionamos el bug anterior, escribimos la prueba primero y luego el código para solucionarlo. De hecho, eso fue un ejemplo de desarrollo guiado por pruebas, pero no importa en qué orden hagamos el trabajo.
Nuestro primer test se centró en el comportamiento interno del código. Para este test, queremos comprobar cómo se comportaría si un usuario lo experimentara mediante un navegador web.
Antes de intentar arreglar cualquier cosa, vamos a echar un vistazo a las herramientas que tenemos a nuestra disposición.
Django proporciona un cliente de prueba Client para simular la interacción de un usuario con el código en el nivel de vista. Podemos utilizarlo en tests.py o incluso en la shell.
Volveremos a empezar con la shell, donde necesitamos hacer una pareja de cosas que no serán necesarias en tests.py. La primera es configurar el entorno de prueba en la shell:
$ python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment() instala un renderizador de plantillas que nos permitirá examinar algunas características adicionales en las respuestas, como response.context, que de otra manera no estarían disponibles. Tenga en cuenta que este método no configura una base de datos de prueba, por lo que lo siguiente se ejecutará contra la base de datos existente y el resultado puede diferir ligeramente dependiendo de las preguntas que ya hayas creado. Es posible obtener resultados inesperados si tu TIME_ZONE en settings.py no está configurado correctamente. Si no recuerdas haberlo configurado anteriormente, revisa antes de continuar.
A continuación necesitamos importar la clase del cliente de pruebas (más tarde en tests.py utilizaremos la django.test.TestCase clase, 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 algo por 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's up?</a></li>\n \n </ul>\n\n'
>>> response.context["latest_question_list"]
<QuerySet [<Question: What's up?>]>
La lista de encuestas muestra encuestas que aún no están publicadas (es decir, aquellas con una pub_date en el futuro). Vamos a arreglar eso.
En Tutorial 4 introdujimos 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]
Necesitamos modificar el método get_queryset() y cambiarlo para que también compruebe la fecha comparándola con timezone.now(). Primero necesitamos agregar una importación:
polls/views.py¶from django.utils import timezone
y luego debemos modificar el método get_queryset de esta 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 conjunto de consultas que contienen Questions cuyas fechas de publicación son menores o iguales a - es decir, anteriores o iguales a - timezone.now().
Ahora puedes verificar por ti mismo que esto se comporta como se espera cargando runserver, cargando el sitio en tu navegador, creando algunas entradas de Question con fechas en el pasado y futuro, y comprobando que solo aquellas que han sido publicadas están listadas. No quieres tener que hacer eso cada vez que hagas algún cambio que pueda afectar esto - así que también creamos una prueba basada en nuestra shell sesión anterior.
Agrega lo siguiente a polls/tests.py:
tests.py de la aplicación polls, pon lo siguiente:¶from django.urls import reverse
y crearemos una función de atajo para crear preguntas, así como una nueva clase de pruebas:
tests.py de la aplicación polls, pon lo siguiente:¶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.
"""
question = create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[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.
"""
question = 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],
)
def test_two_past_questions(self):
"""
The questions index page may display multiple questions.
"""
question1 = create_question(question_text="Past question 1.", days=-30)
question2 = create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[question2, question1],
)
Vamos a mirar algunas de estas más de cerca.
La primera es una función de atajo para preguntas, create_question, para eliminar la repetición en el proceso de creación de preguntas.
test_no_questions no crea ninguna pregunta, pero verifica el mensaje: «No hay encuestas disponibles.» y verifica que la lista latest_question_list esté vacía. Ten en cuenta que la clase django.test.TestCase proporciona algunos métodos de aserción adicionales. En estos ejemplos, utilizamos los métodos assertContains() y assertQuerySetEqual().
En test_past_question, creamos una pregunta y verificamos que aparece en la lista.
En test_future_question, creamos una pregunta con un pub_date en el futuro. La base de datos se resetea para cada método de prueba, por lo que la primera pregunta ya no está allí, y así otra vez el índice no debería tener ninguna pregunta.
Y así sucesivamente. En efecto, estamos utilizando las pruebas para contar una historia del input administrativo y la experiencia del usuario en el sitio, y comprobando que en cada estado y para cada nuevo cambio de estado del sistema se publican los resultados esperados.
DetailView¶Lo que tenemos funciona bien; sin embargo, aunque las preguntas futuras no aparecen en el índice, los usuarios aún pueden llegar a ellas si conocen o adivinan la URL correcta. Por lo tanto, necesitamos agregar una restricción similar al 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())
Deberíamos agregar luego algunas pruebas, para comprobar que una pregunta con un pub_date en el pasado se puede mostrar, y que una con un pub_date en el futuro no:
tests.py de la aplicación polls, pon lo siguiente:¶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)
Debemos agregar luego un método get_queryset similar al ResultsView y crear una nueva clase de prueba para ese vista. Será muy similar a lo que hemos creado recientemente; de hecho, habrá mucha repetición.
También podríamos mejorar nuestra aplicación de otras maneras, agregando pruebas por el camino. Por ejemplo, es inútil que una pregunta con ninguna Choice relacionada se pueda publicar en el sitio. Así que nuestras vistas podrían verificar esto y excluir objetos Question sin Choice. Nuestras pruebas crearían una pregunta sin Choice, y luego probarían que no se publica, así como crearían una pregunta similar con al menos un Choice, y probarían que sí se publica.
Perhaps logged-in admin users should be allowed to see unpublished Pregunta entries, but not ordinary visitors. Again: whatever needs to be added to the software to accomplish this should be accompanied by a test, whether you write the test first and then make the code pass the test, or work out the logic in your code first and then write a test to prove it.
En un momento dado, estarás mirando tus pruebas y te preguntarás si tu código está sufriendo de sobrecarga de pruebas, lo que nos lleva a:
Puede parecer que nuestras pruebas están creciendo fuera de control. A este ritmo, pronto habrá más código en nuestras pruebas que en nuestra aplicación, y la repetición es una cosa fea comparada con la elegancia concisa del resto de nuestro código.
No importa nada. Déjalas crecer. En gran medida, puedes escribir una prueba una vez y luego olvidarte de ella. Continuará realizando su función útil mientras continúes desarrollando tu programa.
A veces las pruebas necesitarán ser actualizadas. Supongamos que enmendamos nuestras vistas para que solo se publiquen Pregunta entries con instancias asociadas de Opción. En ese caso, muchas de nuestras pruebas existentes fallarán - indicándonos exactamente cuáles son las pruebas que necesitan ser actualizadas para hacerlas funcionar, por lo tanto las pruebas ayudan a cuidarse ellas mismas.
En el peor de los casos, a medida que continúes desarrollando, podrías encontrar que tienes algunas pruebas que ahora son redundantes. Incluso eso no es un problema; en la prueba la redundancia es una cosa buena.
Mientras tus pruebas estén bien organizadas, no se volverán inmanejables. Buenas reglas de deducciones incluyen tener:
una clase TestClass separada para cada modelo o vista
un método de prueba separado para cada conjunto de condiciones que quieras probar
test nombres de métodos que describen su función
Esta guía solo introduce algunos de los fundamentos básicos de las pruebas. Hay mucho más que puedes hacer y una serie de herramientas muy útiles a tu disposición para lograr cosas muy ingeniosas.
Por ejemplo, si bien nuestras pruebas aquí han cubierto algunas de la lógica interna de un modelo y la forma en que nuestros vistas publican información, puedes utilizar un «framework in-browser» como Selenium para probar cómo se renderiza realmente tu HTML en un navegador. Estas herramientas te permiten comprobar no solo el comportamiento de tu código Django, sino también, por ejemplo, del JavaScript. Es algo bastante impresionante ver las pruebas lanzar un navegador y empezar a interactuar con tu sitio, como si una persona estuviera conduciéndolo. Django incluye LiveServerTestCase para facilitar la integración con herramientas como Selenium.
Si tienes una aplicación compleja, es posible que desees ejecutar pruebas automáticamente con cada commit con fines de integración continua, para que el control de calidad esté automatizado al menos en parte.
Una buena forma de detectar partes no probadas de tu aplicación es verificar la cobertura del código. Esto también ayuda a identificar código frágil o incluso muerto. Si no puedes probar un trozo de código, generalmente significa que ese código debe ser refactorizado o eliminado. La cobertura ayudará a identificar el código muerto. Consulta Integración con coverage.py para obtener más detalles.
Pruebas en Django tiene información completa sobre las pruebas.
Para obtener detalles completos sobre las pruebas, consulta Pruebas en Django.
Cuando estés cómodo con las pruebas de vistas de Django, lee parte 6 de esta guía para aprender a gestionar archivos estáticos.
may 31, 2026