Módulo 4 · Lección 39 de 45
Pruebas
Probar que una vista cerrada no responde 200, que la lista no tiene el problema N+1, y que el correo de recuperación lleva el enlace dentro.
La lección 23 del módulo 2 ya enseñó a escribir pruebas con unittest y con pytest, así que lo de aquí no es empezar de cero: es ver qué añade Django encima. Y añade bastante, porque probar una aplicación web tiene dos problemas que un programa normal no tiene: hace falta una base de datos, y hace falta hablar con las vistas por HTTP.
Django resuelve los dos, y de paso deja probar tres cosas que en este módulo se han medido a mano y que conviene tener vigiladas: que las vistas cerradas siguen cerradas, que las consultas no se disparan, y que el correo sale.
La primera#
Django busca las pruebas en cualquier fichero que empiece por test, así que el tests.py que startapp dejó vacío en la lección 6 es el sitio, y cuando crece se parte en un paquete tests/:
from django.test import TestCase
from .models import Contacto
class ContactoTest(TestCase):
def test_str(self):
contacto = Contacto.objects.create(nombre='Ana', correo='ana@t.es')
self.assertEqual(str(contacto), 'Ana')python manage.py testCreating test database for alias 'default'...
Operations to perform:
Apply all migrations: ...
Applying contactos.0005_contacto_busqueda... OK
.
Ran 1 test in 0.014s
OK
Destroying test database for alias 'default'...Fíjate en la primera línea y en la última, que es donde está lo importante: Django crea una base de datos nueva, aplica todas las migraciones, ejecuta las pruebas y la borra. No se toca la base de datos de desarrollo, y no hace falta preparar nada.
Con SQLite esa base se crea en memoria, lo que la hace muy rápida. Es la razón de que un proyecto pequeño pueda tener cientos de pruebas y tardar un segundo.
Y el paso de las migraciones no es gratis: es, de hecho, una prueba de que tus migraciones aplican limpias desde cero. Más de un proyecto se ha enterado ahí de que tenía una migración rota.
El cliente de pruebas#
Es lo que Django añade y lo que hace que probar vistas sea razonable: un navegador de mentira que llega hasta las vistas sin red y sin servidor.
class VistaTest(TestCase):
def test_lista_responde(self):
respuesta = self.client.get(reverse('contactos:lista'))
self.assertEqual(respuesta.status_code, 200)
self.assertTemplateUsed(respuesta, 'contactos/lista.html')
def test_detalle_inexistente(self):
respuesta = self.client.get('/contactos/99999/')
self.assertEqual(respuesta.status_code, 404)self.client está puesto solo en cualquier TestCase. Y el reverse de la lección 21 es importante aquí: una prueba que escribe la dirección a mano se rompe al cambiarla, que es justo lo contrario de lo que quieres.
Las comprobaciones que Django añade son las que hacen falta:
| Comprobación | Qué mira |
|---|---|
assertContains(r, texto) | Que el texto está en la respuesta, y que el estado es 200 |
assertNotContains(r, texto) | Que no está |
assertRedirects(r, url) | Que redirige ahí, y que el destino existe |
assertTemplateUsed(r, nombre) | Qué plantilla se usó |
assertFormError(f, campo, error) | Que el formulario tiene ese error en ese campo |
assertNumQueries(n) | Cuántas consultas se hicieron |
Y cuando fallan, dicen por qué con todo el detalle:
AssertionError: False is not true : Couldn't find 'Un texto que no esta'
in the following response
b'\n<!doctype html>\n<html lang="es">\n<head>\n ... <h1>Contactos (0)</h1>\n ...'Te da la respuesta entera. Ese Contactos (0) de ahí dentro es la pista de la mitad de los fallos: la prueba no ve el texto porque no hay datos, y no porque la plantilla esté mal.
La deuda de la lección 33#
La lección 33 cerró vistas con decoradores y mixins, y avisó de que un mixin colocado en el orden equivocado deja la página abierta sin decir nada. Esto es lo que lo detecta:
class AccesoTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.usuario = U.objects.create_user('ana', 'ana@t.es', 'clave-larga-123')
def test_anonimo_no_entra(self):
respuesta = self.client.get(reverse('contactos:privada'))
self.assertEqual(respuesta.status_code, 302)
def test_anonimo_redirige_al_login_con_next(self):
url = reverse('contactos:privada')
self.assertRedirects(self.client.get(url), f'/cuentas/login/?next={url}')
def test_identificado_entra(self):
self.client.force_login(self.usuario)
self.assertEqual(self.client.get(reverse('contactos:privada')).status_code, 200)anónimo -> 302 /cuentas/login/?next=/contactos/privada/
identificado -> 200Tres pruebas cortas, y entre las tres queda sujeto lo que importa: que sin identificar no se entra, que se redirige al sitio correcto y con el next puesto, y que identificado sí se entra. Si alguien mueve un mixin de sitio, la primera falla.
force_login identifica sin pasar por el formulario, que es lo que quieres en casi todas las pruebas. login(username=..., password=...) existe y hace el ciclo entero, y se reserva para probar el propio inicio de sesión, porque va más lento: comprueba la contraseña de verdad, con su hasheo.
assertNumQueries#
Esta es la que convierte el problema N+1 del bloque B en algo que se vigila solo:
def test_n_mas_1(self):
with self.assertNumQueries(11):
for c in Contacto.objects.all():
_ = c.grupo.nombre
def test_resuelto(self):
with self.assertNumQueries(1):
for c in Contacto.objects.select_related('grupo'):
_ = c.grupo.nombre
def test_la_vista_no_tiene_n_mas_1(self):
with self.assertNumQueries(2):
self.client.get(reverse('contactos:cbv-lista'))Las tres pasan, con diez contactos: once consultas sin select_related, una con él, y dos en la vista paginada, que son el recuento y los datos de la lección 36.
Lo valioso es la tercera. El N+1 no rompe nada: la página sale bien, solo que lenta, y lenta de una forma que depende de cuántas filas haya. En desarrollo hay diez filas y nadie lo nota; en producción hay diez mil.
Con assertNumQueries puesto, el día que alguien quite un select_related de la vista la prueba falla, y falla diciendo el número exacto. Es de las pruebas con mejor relación entre lo que cuesta escribirla y lo que atrapa.
Una advertencia de uso: no la pongas en todas partes, porque el número cambia con cualquier cosa y acabas con pruebas que fallan por nada. Va en las vistas de lista, que son donde el N+1 aparece.
El correo#
En pruebas, Django cambia el backend de correo solo:
backend en pruebas: django.core.mail.backends.locmemLos mensajes se quedan en una lista, y con eso la lección 38 se puede comprobar entera:
def test_correo_de_recuperacion(self):
self.client.post('/cuentas/password_reset/', {'email': 'ana@t.es'})
self.assertEqual(len(mail.outbox), 1)
self.assertIn('/cuentas/reset/', mail.outbox[0].body)correos: 1Nada de esto toca un servidor de correo. Y se puede ir más lejos: sacar el enlace del cuerpo con una expresión regular, seguirlo con el cliente, cambiar la contraseña y comprobar que la nueva sirve para entrar. Esa prueba cubre las cinco vistas encadenadas de la lección 32 de una sentada, y es el tipo de prueba que más tranquilidad da, porque es un recorrido que nadie repite a mano cada semana.
Las tres clases#
| Clase | Base de datos | Cuándo |
|---|---|---|
SimpleTestCase | Prohibida | Formularios sin modelo, filtros de plantilla, utilidades |
TestCase | Sí, con vuelta atrás | Casi siempre |
TransactionTestCase | Sí, borrando tablas | Cuando pruebas transacciones o select_for_update |
SimpleTestCase no es que no tenga base de datos: es que la prohíbe.
DatabaseOperationForbidden: Database queries to 'default' are not allowed
in SimpleTestCase subclasses. Either subclass TestCase or ...Eso está bien pensado. Si crees que algo no toca la base de datos, esta clase te lo confirma, y es bastante más rápida.
TestCase es la de siempre. Mete cada prueba en una transacción y la deshace al acabar, que es lo que da el aislamiento:
def test_aislamiento_a(self):
Contacto.objects.create(nombre='X', correo='x@t.es')
self.assertEqual(Contacto.objects.count(), 1)
def test_aislamiento_b(self):
self.assertEqual(Contacto.objects.count(), 0)Las dos pasan. Y esto es más importante de lo que parece: cada prueba empieza limpia, así que el orden en que se ejecuten no importa, y una prueba no puede depender de lo que hizo otra. Una suite donde eso no se cumple es una suite que falla los martes.
setUpTestData#
El método que Django añade y que hay que usar en lugar de setUp para los datos:
@classmethod
def setUpTestData(cls):
cls.grupo = Grupo.objects.create(nombre='Prueba')La diferencia es cuántas veces se ejecuta. setUp corre antes de cada prueba; setUpTestData corre una vez por clase. Con cinco pruebas que necesitan doscientas filas:
con setUp: 0.096s
con setUpTestData: 0.022sCuatro veces más rápido, y la diferencia crece con el número de pruebas. En una suite grande es la diferencia entre lanzarlas al guardar y lanzarlas cuando te acuerdas.
Y la pregunta obvia: si los objetos se comparten entre pruebas, ¿no se pisan?
def test_a_lo_cambia(self):
self.contacto.nombre = 'Cambiado en memoria'
def test_b_lo_ve(self):
self.assertEqual(self.contacto.nombre, 'Original')en el segundo test: OriginalNo. Django hace una copia profunda de esos atributos para cada prueba, precisamente para que esto no pase. Fue un problema real en versiones antiguas y está resuelto.