#Una prueba debe proteger una decisión
Una prueba útil demuestra algo que podría romperse mientras la página sigue pareciendo correcta: un borrador no es público, una sala ajena no se puede reservar, una lista no hace una consulta por fila, una transacción revierte si falla una parte. Comprobar únicamente que una función devuelve algo o que una ruta responde 200 protege muy poco.
Empieza por invariantes y fronteras. Los detalles internos pueden cambiar si conservas el contrato. No escribas una prueba que vuelva a copiar el algoritmo y compare su salida consigo mismo; diseña entradas pequeñas cuya respuesta puedas razonar independientemente.
#La pirámide útil para esta aplicación
| Nivel | Qué verifica | Qué deja fuera |
|---|---|---|
| Función pura | Formatos, límites, cálculo de intervalos | Base, permisos de sesión y red |
| Modelo/servicio con base | Restricciones, transacciones y reglas | Navegación real del navegador |
| Cliente de Django | Rutas, middleware, sesión, view y HTML generado | TCP, proxy y ejecución de JavaScript |
| Dos conexiones reales | Coordinación de escrituras y commits | Capacidad bajo carga sostenida |
| Navegador/servicio desplegado | Formularios, cookies, HTTPS y recursos | No sustituye probar cada regla de negocio |
No necesitas probar cada caso en todos los niveles. Un error de duración puede probarse rápidamente como función; una carrera de PostgreSQL exige ese motor y conexiones independientes; un certificado mal configurado solo aparece en la cadena HTTP real.
#Un conjunto pequeño para la web de artículos
Sustituye articulos/tests.py por este ejemplo, compatible con los modelos, listado y detalle desarrollados. Crea dos artículos con resultados conocidos, evitando depender de datos que existan en tu base de desarrollo:
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from .models import Articulo, Categoria
class LecturaTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.categoria = Categoria.objects.create(nombre="Python", slug="python")
cls.publico = Articulo.objects.create(
categoria=cls.categoria, titulo="Artículo visible", slug="visible",
contenido="Contenido público", publicado=True,
)
cls.borrador = Articulo.objects.create(
categoria=cls.categoria, titulo="Borrador interno", slug="interno",
contenido="Aún no publicado", publicado=False,
)
def test_listado_no_revela_borradores(self):
respuesta = self.client.get(reverse("articulos:listado"))
self.assertEqual(respuesta.status_code, 200)
self.assertContains(respuesta, "Artículo visible")
self.assertNotContains(respuesta, "Borrador interno")
def test_detalle_tambien_filtra(self):
url = reverse("articulos:detalle", args=[self.borrador.pk])
self.assertEqual(self.client.get(url).status_code, 404)
def test_relacion_cargada_en_una_consulta(self):
with CaptureQueriesContext(connection) as consultas:
nombres = [a.categoria.nombre for a in Articulo.objects.select_related("categoria")]
self.assertEqual(nombres, ["Python", "Python"])
self.assertEqual(len(consultas), 1)
TestCase crea un entorno de base aislado y limpia cambios entre pruebas. setUpTestData prepara datos para la clase; @classmethod recibe la clase como cls en vez de una instancia individual. Django proporciona copias de esos atributos para cada prueba. self.client hace peticiones internas a la aplicación; reverse evita que renombrar una ruta obligue a reescribir todas las URLs del test.
python manage.py test articulos --verbosity 2
Esperas tres pruebas correctas y la eliminación de la base de pruebas al terminar. Un fallo de template por una ruta inexistente es un fallo real de integración, aunque la función de la view aislada devolviese un objeto. El cliente deja que excepciones inesperadas se propaguen para que el test enseñe su origen.
#Medir consultas sin hacer frágil cada prueba
Contar consultas sirve para detectar N+1 si el conjunto de datos incluye varios elementos. Con una sola fila, una implementación mala puede parecer aceptable. En el ejemplo medimos la consulta de negocio aislada, no el número total de queries de una petición con sesiones, permisos y paginación que podría cambiar legítimamente.
El criterio de crecimiento también importa: añadir diez elementos no debería sumar diez consultas a una lista que muestra la misma relación. Utiliza assertNumQueries o CaptureQueriesContext en fronteras relevantes, inspecciona SQL si falla y confirma que la optimización conserva los resultados. Reducir a cero consultas devolviendo información obsoleta no es una mejora si el producto requiere datos actuales.
No actives logging de todas las consultas permanentemente en una aplicación con datos sensibles o gran tráfico. Las trazas pueden contener parámetros; muestrea y limita la retención. Un profiler local y un conjunto representativo suelen bastar para localizar un JOIN o N+1 antes de añadir una plataforma de observabilidad.
#Permisos: probar también el intento mal formado
Una prueba de autorización necesita al menos dos identidades y dos conjuntos de recursos. Comprueba usuario sin sesión, miembro permitido, miembro ajeno y revocado. Cambia tanto el identificador de URL como los identificadores en el cuerpo. Después del rechazo, comprueba que el estado no cambió.
force_login(usuario) evita repetir el hash de contraseña y el flujo de login en todas las pruebas; sirve para aislar permisos, pero no verifica las credenciales. Mantén algunas pruebas del login real. El cliente de Django omite CSRF por defecto para facilitar tests; usa Client(enforce_csrf_checks=True) cuando precisamente estás probando esa protección.
La interfaz puede ocultar un botón correctamente y el endpoint permitir la acción de todos modos. Una comprobación de servidor no necesita encontrar ni pulsar ese botón: envía el POST directamente. El proyecto final incluye ese caso de cancelación y un intento de seleccionar una sala de otra organización.
#Transacciones y carreras necesitan otro tipo de prueba
TestCase envuelve pruebas en transacciones, lo que hace que algunas llamadas de bloqueo parezcan funcionar incluso si tu código no abrió explícitamente una transacción. Para demostrar commits, rollback observable entre conexiones o select_for_update, usa TransactionTestCase con PostgreSQL real.
No coordines una carrera únicamente con sleep(1): depende de la velocidad de la máquina y puede no crear el intercalado que pretendías. Una barrera o evento permite decir «ambos ya llegaron hasta aquí». Cada hilo necesita su conexión; no compartas un cursor o una conexión creada en otro hilo. Pon límites de espera y cierra conexiones en finally para que un fallo no deje bloqueada la suite.
Los callbacks on_commit no se ejecutan normalmente al final de un TestCase, porque la transacción exterior se revierte. Para verificar su registro puedes usar self.captureOnCommitCallbacks(execute=True); si necesitas comprobar commits de verdad, cambia de tipo de prueba. No añadas un commit manual dentro de un bloque de prueba que no lo permite.
#Simular una dependencia sin simular la garantía
Para una API externa, un doble de prueba puede devolver respuesta, timeout o error. Sustitúyelo en el punto donde tu módulo lo usa y comprueba cómo cambia el estado local. Eso permite reproducir fallos sin depender de un proveedor ni gastar dinero, pero no valida sus credenciales, red o formato real. Conserva una prueba de contrato contra un entorno controlado cuando integres ese proveedor.
En cambio, sustituir PostgreSQL por una lista Python no demuestra una restricción de exclusión. Las herramientas se eligen según la garantía: mocks para efectos externos y control de tiempo; motor real para bloqueos, tipos y SQL específicos; navegador para la interacción que realmente depende de él.
#Diagnosticar con una secuencia corta
Empieza por una petición reproducible: método, ruta, identidad de prueba, entrada mínima, resultado esperado y observado. Repite primero en local con datos pequeños. Sigue después el recorrido: ¿coincidió la ruta?, ¿se autenticó?, ¿pasó autorización?, ¿validó?, ¿qué SQL ejecutó?, ¿confirmó?, ¿falló el renderizado o la dependencia?
Un log útil registra la operación y sus identificadores seguros:
import logging
logger = logging.getLogger(__name__)
# Dentro de una operación, con variables verificadas:
# logger.info("reserva_cancelada", extra={"reserva_id": reserva.pk, "actor_id": usuario.pk})
extra añade atributos al registro; el formato configurado decide cuáles aparecen. No presupongas que un formatter por defecto mostrará el diccionario. Para fallos inesperados usa logger.exception dentro de except y conserva el traceback en un destino privado; devuelve un mensaje genérico al usuario.
Distingue métricas de diagnóstico: latencia por ruta, proporción de errores, tiempo esperando base, número de consultas y saturación de workers. Un 503 por dependencia indisponible necesita una acción diferente a un 500 por programación o un 403 esperado de permisos. Una media rápida puede esconder una cola de peticiones lentas; observa percentiles y muestra suficiente contexto para reproducir sin exponer secretos.
Comprobación: rompe deliberadamente el filtro publicado=True del detalle y confirma que falla su prueba; restáuralo. Quita select_related y observa que la prueba de consultas detecta el cambio. Estas pruebas aportan valor porque fallan ante regresiones plausibles, no porque aumenten un número de cobertura.