Saltar al contenido
🐍 Curso de Python
/

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.

⏱ 18 min de lectura

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/:

contactos/tests.py
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')
bash
python manage.py test
Salida en pantalla
Creating 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.

python
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ónQué 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:

Salida en pantalla
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:

python
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)
Salida en pantalla
anónimo       -> 302 /cuentas/login/?next=/contactos/privada/
identificado  -> 200

Tres 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:

python
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:

Salida en pantalla
backend en pruebas: django.core.mail.backends.locmem

Los mensajes se quedan en una lista, y con eso la lección 38 se puede comprobar entera:

python
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)
Salida en pantalla
correos: 1

Nada 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#

ClaseBase de datosCuándo
SimpleTestCaseProhibidaFormularios sin modelo, filtros de plantilla, utilidades
TestCaseSí, con vuelta atrásCasi siempre
TransactionTestCaseSí, borrando tablasCuando pruebas transacciones o select_for_update

SimpleTestCase no es que no tenga base de datos: es que la prohíbe.

Salida en pantalla
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:

python
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:

python
@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:

Salida en pantalla
con setUp:          0.096s
con setUpTestData:  0.022s

Cuatro 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?

python
def test_a_lo_cambia(self):
        self.contacto.nombre = 'Cambiado en memoria'

def test_b_lo_ve(self):
        self.assertEqual(self.contacto.nombre, 'Original')
Salida en pantalla
en el segundo test: Original

No. 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.