Pruebas Unitarias en Django: Lo que debes de conocer

Video thumbnail

Hemos implementado un montón de cosas en Django desde el Hola Mundo hasta filtros en listado, ahora, es momento de implementar las pruebas para estos desarrollos.

¿Por qué hacer pruebas?

Las pruebas ayudan a garantizar que su aplicación funcionará como se espera y a medida que la aplicación vaya creciendo en módulos y complejidad, se puedan implementar nuevas pruebas y adaptar las actuales.

Es importante mencionar que las pruebas no son perfectas, es decir, que, aunque la aplicación pase todas las pruebas no significa que la aplicación está libre de errores, pero sí es un buen indicador inicial de la calidad del software. Además, el código comprobable es generalmente una señal de una buena arquitectura de software.

Las pruebas deben de formar parte del ciclo de desarrollo de la aplicación para garantizar su buen funcionamiento ejecutando las mismas constantemente.

¿Por qué aprender Testing en la era de la IA?

Hoy en día, con el auge de la Inteligencia Artificial, implementar pruebas es más fácil que nunca. La IA puede facilitarnos entre un 80% y 90% del trabajo de escritura de código para tests. Sin embargo, para usar estas herramientas correctamente, primero debes conocer los conceptos básicos.

Te recomiendo no saltarte esta sección; veremos las operaciones esenciales para que entiendas cómo probar tus componentes y por qué es una inversión de tiempo que se paga sola.

¿Para qué sirven las pruebas?

Las pruebas consisten en evaluar componentes individuales para garantizar que, al unirse, todo el conjunto funcione correctamente. Con ellas podemos:

  • Simular peticiones HTTP: Probar envíos de formularios (POST) o carga de páginas (GET).
  • Validar la Base de Datos: Verificar que los registros se crean, editan o eliminan como se espera.
  • Evaluar comportamientos: Confirmar que las redirecciones funcionen, que las validaciones de seguridad detengan a usuarios no autenticados y que los errores se manejen con elegancia.
  • Ahorrar tiempo a largo plazo: Al agregar nuevas funcionalidades, basta con ejecutar un comando para saber si rompimos algo que ya funcionaba (regresión).

Una de las grandes ventajas de Django es su modularidad, todo es un componente aparte, uno para las vistas, otro para los formularios, modelos y así. En las pruebas podemos evaluar de forma aislada:

  • Respuestas de vistas: Códigos de estado (200 OK, 404 Not Found, etc.).
  • Lógica de negocio: Signals, métodos de modelos y utilidades.
  • Formularios: Validaciones de datos y limpieza de campos.

¿Qué probar?

Las pruebas deberían centrarse en probar pequeñas unidades de código de forma aislada o individual.

Por ejemplo, en una aplicación Django o una aplicación web en general:

  • Vistas:
  • Respuestas de las vistas
  • Códigos de estados
  • Condiciones nominales (GET, POST, etc.) para una función de vista
  • Formularios
  • Funciones de ayuda individuales

Puedes obtener más información en la documentación oficial:

https://docs.djangoproject.com/en/dev/topics/testing/

Limitaciones de las pruebas

Es vital entender que las pruebas no son infalibles. Aunque todos tus tests pasen en "verde", esto no es una garantía absoluta de que el proyecto esté 100% libre de errores. Lo que significa es que los escenarios que diseñaste funcionan bien, pero siempre puede haber casos de borde que no fueron cubiertos.

Entendiendo las primeras pruebas

De momento, hemos realizado algunos métodos de prueba y hay dos factores que tenemos que tener en cuenta, si hacemos peticiones mediante el Client(), por ejemplo:

client = Client()
response = client.get("/")

O creando instancias de modelos u otras clases en la prueba:

Comment.objects.create(text='text 1')

Las conexiones se realizan en el contexto de la aplicación en desarrollo según lo que tenemos configurado en la prueba:

import unittest.TestCase

O en memoria:

from django.test import TestCase

Esto es fácilmente probable si hacemos algunas pruebas; si desde una prueba, intentas realizar alguna conexión a la base de datos empleando el cliente o una instancia de un modelo:

import unittest
from django.test import Client
from .models import Comment
class ContactTests(unittest.TestCase):
    def test_index(self):
        print(Comment.objects.all())
        client = Client()
        response = client.get("/")
        print(response)
        ***

Y ejecutamos:

$ python manage.py test

Veremos que devuelve un listado con los datos que tengamos en la base de datos de desarrollo, pero, si lo configuramos con el TestCase de Django:

from django.test import Client, TestCase
from .models import Comment
class ContactTests(TestCase):
    def test_index(self):
        print(Comment.objects.all())
        client = Client()
        response = client.get("/")
        print(response)
        ***

Veremos que devuelve un resultado vacío de comentarios, aunque tengamos comentarios en la base de datos de desarrollo.

En ambos casos, para obtener al menos la estructura de la base de datos, se emplea la que tengamos configurada:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

Para entender el funcionamiento de las pruebas, vamos a configurar la base de datos para el ambiente de pruebas:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
        'TEST': {
            'NAME': BASE_DIR / 'db.sqlite3',
         },
    }
}

Antes de ejecutar, se recomienda al lector que haga una copia de la base de datos SQLite (<your-project>\db.sqlite3) para que luego la puedas restaurar, ya que, la misma al terminar la prueba será eliminada, finalmente, ejecutamos:

$ python manage.py test

Sin importar que clase TestCase estés empleando, ya que, es la misma base de datos que se está empleando para desarrollo, te preguntará si realmente la quieres eliminar:

Creating test database for alias 'default'...
Destroying old test database for alias 'default'...
Type 'yes' if you would like to try deleting the test database '/Users/andrescruz/Desktop/proyects/django/curso/django-base/db.sqlite3', or 'no' to cancel: 

Damos que sí, ya que tenemos la copia:

yes

Y veremos que la base de datos es destruida (si intentas ingresar a la aplicación http://127.0.0.1:8000, veras que devuelve un error de que no encuentra la tabla para la sesión, que es empleada internamente por Django antes de resolver las peticiones implementadas por nosotros en las vistas):

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

Como resultado de:

print(Comment.objects.all())

Veremos que, aunque tengamos datos en la base de datos de desarrollo, el listado aparece vacío (recordar, solamente si empleamos from django.test import TestCase que es el que siempre deberíamos de emplear):

<QuerySet []>

Las pruebas que requieren una base de datos, no utilizarán la base de datos "real" (de producción o desarrollo) del proyecto. Para las pruebas se crean bases de datos separadas y en blanco.

A partir de ahora, para hacer las pruebas, debes de emplear:

from django.test import TestCase

Y no la otra, que es provista por Python:

import unittest.TestCase

Ya que, la clase provista por Django (from django.test import TestCase) no confirma (commit) los datos que estemos manipulando en las pruebas unitarias y por defecto, tampoco emplea los registros almacenados en la base de datos, a diferencia del paquete de Python (import unittest.TestCase) que si confirma las operaciones.

En resumen, si realizas conexiones from django.test import TestCase se emplea el contexto de la aplicación configurada (que en nuestro caso sería el ambiente de desarrollo), pero si utilizas import unittest.TestCase, se emplean un entorno independiente provisto por Django para hacer pruebas.

Esto es imprescindible ya que, también con esto podemos conocer la esencia de las pruebas en el cual, nos permite crear nuestro entorno o ambiente controlado y es el mismo cada vez que ejecutamos las pruebas, suceden los siguientes pasos:

  1. Se construye el ambiente
  2. Nosotros como desarrollador, creamos los datos de prueba y demas instancia a emplear, para eso, podemos emplear el método de setUp():
from django.test import Client, TestCase
class ContactTests(TestCase):
    def setUp(self):
        Comment.objects.create(text='text 1')
        Comment.objects.create(text='text 2')
        self.comments = Comment.objects.all()
        self.client = Client()
  1. Crear las pruebas (el resto de los métodos en la clase).
  2. Automáticamente Django elimina el ambiente anterior, por lo tanto, para el ejemplo que definimos en el ejemplo anterior, que creamos 2 comentarios, los mismos son destruidos de manera automática al momento de terminar la prueba, lo cual es excelente ya que, siempre podemos tener un ambiente ideal y controlado totalmente por nosotros (incluyendo la cantidad de registros y su estructura), lo cual no podria ser asi si el ambiente no se destruyera).

Con lo comentado en este apartado, puedes tener una buena idea de cómo funcionan las pruebas ya aprovechar este conocimiento para crear las pruebas unitarias a medida.

Crud de Comentarios

En este apartado, veremos cómo realizar pruebas para el CRUD de los comentarios, los desarrollos realizados en este apartado, las puedes tomar como experimentos que nos servirán para entender las pruebas Django en base a lo experimentado en el apartado anterior.

Index

Ya que conocemos que, al momento de ejecutar las pruebas, no podemos tener acceso a los datos que tengamos definidos en el modo desarrollo, si no, los debemos de crear nosotros al momento de ejecutar la prueba en el ambiente de pruebas, vamos a configurar los datos iniciales de la siguiente manera:

comments/tests.py
# import unittest
from django.test import Client, TestCase
from .models import Comment
class ContactTests(TestCase):
    def setUp(self):
        Comment.objects.create(text='text 1')
        Comment.objects.create(text='text 2')
        self.comments = Comment.objects.all()
        print(self.comments)
        self.client = Client()

Como comentamos anteriormente, el método de setUp() se emplea para inicializar datos, para nuestra prueba sería el cliente y los datos de prueba que podremos emplear a lo largo de TODAS las pruebas para el CRUD de comentarios, es importante acotar que tenemos una paginación de dos niveles:

comments/views.py

 

def index(request):
    comments = Comment.objects.all()
    paginator = Paginator(comments,2)
    ***

Y es por eso que solamente creamos 2 comentarios, ya que, aparte de comprobar por el código de estado, es imprescindible que comprobemos sobre el contenido devuelto que en este ejemplo, serían los comentarios paginados listados en la tabla:

comments/tests.py

class ContactTests(TestCase):
    def test_index(self):
        response = self.client.get("/tasks/")
        self.assertEqual(response.status_code, 200)
        self.assertContains(response,self.comments[0].text)
        self.assertContains(response,self.comments[1].text)

Puedes emplear un for, para iterar los comentarios, pero, para legibilidad del ejercicio, en el libro preferimos usarlo de la forma presentada. Con esto, estamos probando tanto el código de estado, que es lo primero que siempre deberíamos de comprobar y también parte del contenido mediante los métodos de tipo aserción.

Como comentamos antes, como quieras implementar las pruebas y que probar depende totalmente del desarrollador y el ejercicio anterior lo puedes adaptar a tus necesidades, también, por sencillez del ejercicio, no probamos la paginación en sí, o los registros que están en la paginación y es por eso que creamos solamente dos registros, ya que, si creas un tercero:

comments/tests.py

class ContactTests(TestCase):
    def setUp(self):
        Comment.objects.create(text='text 1')
        Comment.objects.create(text='text 2')
        Comment.objects.create(text='text 3')
        self.comments = Comment.objects.all()
        print(self.comments)
        self.client = Client()
    def test_index(self):
        response = self.client.get("/tasks/")
        self.assertEqual(response.status_code, 200)
        self.assertContains(response,self.comments[0].text)
        self.assertContains(response,self.comments[1].text)
        self.assertContains(response,self.comments[2].text)

Verás que al ejecutar la prueba, devuelve un error al no encontrar el tercer registro al tener en este ejemplo una paginación de dos niveles.

Finalmente ejecuta la prueba y evalúa el resultado:

$ python manage.py test

Generar pruebas con la IA - Gemini Agent

Hacer las pruebas de elementos desde cero es tedioso porque es una relación más pesada. Hace años habría que hacerlo manualmente, pero hoy tenemos la IA.

  • Mi filosofía es clara: la IA es excelente cuando ya tenemos un "pool" de datos y queremos que replique una estructura o extraiga información en otro formato. En este caso, la usaremos para que tome nuestras pruebas de comentarios como referencia y genere el resto por nosotros.
  • La IA es una herramienta. Para usarla al 100%, debes conocer los conceptos de Django y de pruebas unitarias que hemos visto. Si le pides pruebas sin saber cómo funciona tu entorno, el resultado no será bueno.

Definiendo el Prompt para Gemini

Para que la IA nos ayude, podemos usar dos enfoques. Yo utilicé a Gemini con un prompt específico:

"Actúa como un desarrollador experto en Django. Quiero que generes pruebas unitarias para el módulo de elementos (específicamente la ViewSet y el Serializer), siguiendo exactamente el estilo del archivo api_test.py que ya tengo."

Es vital ser específico. Si solo le pides "haz pruebas", la IA puede divagar. Al darle un contexto y una referencia (nuestras pruebas anteriores), el código generado mantendrá la misma sintonía y estructura.

La IA generó pruebas para el ViewSet de elementos, incluyendo:

  • Pruebas de serialización.
  • Verificación de códigos de estado (200 OK, 201 Created).
  • Pruebas de paginación.

Generar pruebas para las vistas y formularios

Para el siguiente módulo, vamos a generar pruebas para el resto de los módulos, el de las vistas y comentarios, para esto, usé el siguiente prompt:

Actúa como un desarrollador experto en Django. Quiero que generes pruebas unitarias para el modulo de elements (test_form, test_views), la de siguiendo exactamente el estilo del modulo comments/tests

Hubo algunos detalles técnicos, como errores de autenticación o problemas con formularios que no eran ModelForm. Por ejemplo, en un test de formulario, la IA intentó usar un método .save() en un formulario normal de Django. Al detectar el error 500, le pedí corregirlo: 

"Adapta la prueba porque es un formulario de Django normal, no un ModelForm".

La IA corrigió la lógica, validando el campo manualmente y verificando la persistencia en la base de datos. Es un proceso iterativo: pruebas, detectas el error, informas a la IA y corriges.

Recomendaciones Finales

Si quieres profundizar, te recomiendo herramientas más integradas en el IDE como Cursor o Google Anti-gravity, que permiten una planificación más sólida antes de escupir código. El volumen de código que pidas influye en la precisión: a mayor volumen, más riesgo de errores que es lo que seguramente te pasará al momento de ejecutar estos prompt, como siempre, antes de solicitar que Gemini genere las pruebas, haz un respaldo del código, verifica, vuelves a realizar el respaldo y así hasta que termines el desarrollo.

Siguiente paso, siguiendo con los aspectos de desarrollo, aprende a usar los .env, para realizar configuraciones distintas por ambiente y variables de entorno en  Django.

Acepto recibir anuncios de interes sobre este Blog.

Hablaremos de los distintas configuraciones que se pueden emplear al momento de realizar pruebas en Django, como configuración de la base de datos de prueba, tipos de clases TestCase entre otros.

| 👤 Andrés Cruz

🇺🇸 In english