Saltar a contenido

Pruebas unitarias#

iThome Ironman 2024

Este es el artículo número 29 de la serie de tutoriales de Django Ninja.

«¿Tienen pruebas unitarias en su proyecto?»

Si haces esta pregunta durante una entrevista, es posible que el entrevistador ponga una cara complicada.

La mayoría de los desarrolladores son muy conscientes de la importancia de las pruebas. Simplemente no hay muchos dispuestos a tomárselas en serio.

Pero si realmente deseas mejorar la calidad del código, reducir errores y hacer que el proyecto sea más fácil de mantener, las pruebas unitarias siguen siendo una herramienta indispensable.

Unas buenas pruebas no solo nos ayudan a detectar problemas tempranamente, sino que también garantizan que las funciones existentes no se rompan al refactorizar o agregar nuevas funcionalidades al proyecto.

Aunque escribir pruebas aumenta el tiempo de desarrollo inicial y mantenerlas requiere esfuerzo (nunca ha sido una tarea fácil), a largo plazo brinda una salud y estabilidad continuas al proyecto.

Así que, ¡escribamos pruebas como es debido!

Proyecto de ejemplo en GitHub#

👉 Django-Ninja-Tutorial


Esquema de este artículo#

Este es el único tutorial de toda la serie que incluye un esquema completo.

La razón es que hay muchos asuntos que mencionar en este artículo; al fin y al cabo, un tema tan amplio como las pruebas unitarias no se puede cubrir del todo en un artículo de 2500 palabras. Debido a la limitación de espacio no podemos detallar cada aspecto, pero tampoco podemos simplemente omitirlo.

Por lo tanto, se necesita un esquema panorámico para que al lector le resulte más fácil comprenderlo y absorberlo. El esquema es el siguiente:

  1. La idealización y la realidad de las pruebas unitarias.
  2. Explicación de conceptos clave para las pruebas de APIs en Django.
    • Significado y uso de Test Client.
    • Introducción a pytest y pytest-django.
    • Fixtures de pytest y funciones de prueba.
  3. Implementación y explicación del código de prueba.
  4. Conclusión.

En pocas palabras, este artículo no explicará todas las modificaciones de código, sino que las mencionará cuando sea necesario. El resto lo he implementado directamente y está incluido en el proyecto de ejemplo para que los lectores lo consulten por su cuenta.

En un espacio limitado, ayudarte a comprender los conceptos generales es más importante que centrarse en los detalles. Cuando domines los conceptos básicos, revisar el código te resultará mucho más sencillo.

Para más discusiones sobre pruebas unitarias, te invitamos a consultar este artículo de reflexión: «Por qué deberías escribir pruebas unitarias — «El artesano de Python»». Es un libro con argumentos sólidos y creemos que le sacarás mucho provecho.

Todos los cambios de código de este artículo se pueden consultar en este PR.


1. La idealización y la realidad de las pruebas unitarias#

Creo que, al hablar de pruebas unitarias, primero debemos enfrentar la realidad.

En el campo del desarrollo de software abunda el fanatismo y el dogmatismo sobre las pruebas, lo que a veces hace que la gente retroceda.

La idealización de las pruebas unitarias#

En teoría, escribir pruebas unitarias debería ser algo que todos los desarrolladores hicieran (y verdaderamente lo creo así).

Además, existe la filosofía del «Desarrollo Guiado por Pruebas (TDD)», un modelo de desarrollo guiado por pruebas que exige escribir las pruebas antes de escribir el código de la funcionalidad.

Incluso una minoría cree que la cobertura de pruebas debe ser del 100%. Porque si no es el 100%, como por ejemplo el 70%, podríamos preguntar: «¿Por qué no otro número?».

La realidad: a la mayoría de la gente no le importan esas idealizaciones#

Sin embargo, en la práctica raras veces vemos pruebas ideales, y a menudo ni siquiera hay pruebas.

Debido a restricciones de tiempo y recursos, los proyectos reales con frecuencia no están dispuestos a dedicar esfuerzo a escribir pruebas.

En algunos proyectos heredados u obsoletos, la falta de una base de pruebas inicial hace que agregar pruebas más adelante sea aún más difícil (después de todo, ya es un desastre 💩), lo que comúnmente llamamos «deuda técnica».

Además, el «idealismo excesivo sobre las pruebas» a veces hace dudar a los principiantes. Al entrar en contacto con las pruebas, muchos novatos temen no poder alcanzar el 100% de cobertura, desarrollando resistencia o escepticismo hacia ellas.

Este perfeccionismo suele hacer más daño que bien; necesitamos encontrar un compromiso entre la idealización y la realidad.

Compromiso: una estrategia pragmática de pruebas#

En el desarrollo real debemos mantener una estrategia de pruebas práctica y viable, enfocándonos en probar las funciones más centrales del proyecto, como las llamadas a las APIs y las respuestas 200.

En la mayoría de los casos, siempre que se cubra entre un 60% y un 70% de las funciones, ya se habrá mejorado notablemente la calidad del código del proyecto, brindando cierta sensación de seguridad para el desarrollo posterior (lo cual es realmente importante).

No hay necesidad de buscar una cobertura de pruebas perfecta; mientras estés dispuesto a comenzar, las pruebas aportarán su valor correspondiente.


2. Explicación de conceptos clave para las pruebas de APIs en Django#

Volviendo al proyecto en sí.

Aunque este artículo no puede profundizar en demasiados detalles específicos de pruebas unitarias para APIs, los conceptos importantes no deben pasarse por alto. Se explican uno a uno a continuación.

Significado y uso de Test Client#

El Test client es de vital importancia para las pruebas de APIs, ya que puede simular solicitudes HTTP reales (ojo, solo simular).

Las pruebas de APIs son ligeramente diferentes de las pruebas de código convencionales. En las pruebas comunes solo basta con escribir las funciones de prueba y la lógica correspondiente y ejecutarlas. Pero en las pruebas de APIs se necesita además un «cliente falso» para simular el envío de solicitudes.

Al probar APIs manualmente, solemos usar clientes de API como Postman. En cambio, en las pruebas unitarias automatizadas es necesario escribir este «cliente falso» directamente en el código de prueba: el test client.

Equivale a un «cliente de API interno del proyecto», y además se puede ejecutar automáticamente.

Django Ninja proporciona su propio test client, pero te sugiero que no lo uses por ahora, ya que aún no es lo suficientemente robusto.

En el proyecto de ejemplo utilizo el test client integrado de Django: con una larga trayectoria, estable y confiable.

Introducción a pytest#

pytest (sí, su 'p' es minúscula, al igual que pyenv) es un marco de pruebas de Python muy popular que cuenta con su propio ecosistema, incluyendo una gran cantidad de complementos prácticos.

En comparación con el módulo unittest integrado de Python, la sintaxis de pytest es más intuitiva y ofrece mejor flexibilidad de uso. Especialmente sus funciones de fixtures y pruebas parametrizadas hacen que escribir pruebas sea más simple y eficiente.

pytest-django#

pytest-django es un paquete de integración de pytest diseñado específicamente para Django. Ofrece ricas funciones de integración con Django, incluyendo muchos fixtures integrados y decoradores muy prácticos.

Entre ellos, el decorador @pytest.mark.django_db es el más utilizado, ya que puede gestionar automáticamente el estado de la base de datos durante el proceso de prueba.

Permite que pytest cree automáticamente una base de datos completamente nueva antes de cada ejecución de prueba y la elimine al finalizar. Esto garantiza que el entorno de cada prueba sea consistente, evitando que el residuo de datos provoque resultados de prueba inexactos.

Fixtures de pytest y funciones de prueba#

Los Fixtures son un mecanismo proporcionado por pytest para configurar el entorno inicial requerido para las pruebas. En esencia son funciones, pero su uso no es como el de las funciones comunes. Una vez definidos previamente, se pueden referenciar como parámetros en las funciones de prueba.

Los fixtures se pueden definir en el tests.py de una app de Django, pero normalmente los colocamos en el módulo conftest.py, el cual puede compartirse en todo el proyecto.

Al probar APIs a menudo necesitamos datos iniciales, como usuarios, productos, etc. Estos datos se pueden generar automáticamente a través de fixtures, sin necesidad de reconstruirlos manualmente cada vez.

De esta manera se incrementa la eficiencia al escribir pruebas y se evita la configuración repetitiva de estados.


3. Implementación y explicación del código de prueba#

En este artículo implemento 3 fixtures y 3 funciones de prueba relacionadas con los usuarios; permíteme explicar los detalles más relevantes.

Fixtures de pytest potentes y flexibles#

Estos son los 3 fixtures del proyecto, definidos en conftest.py: (para reducir espacio he omitido las docstrings)

import pytest
from django.test import Client
...

@pytest.fixture(scope='session')
def client() -> Client:
    return Client()

@pytest.fixture
def user() -> User:
    return User.objects.create_user(
        username='testuser',
        email='[email protected]',
        password='testpassword123'
    )

@pytest.fixture
def authenticated_client(client: Client, user: User) -> Client:
    response = client.post(
        '/users/login/',
        {'username': 'testuser', 'password': 'testpassword123'},
        content_type='application/json',
    )
    assert response.status_code == 200
    # Configurar cookies tras iniciar sesión
    client.cookies.update(response.cookies)
    return client

En este código:

  1. client: Proporciona un Django test client que se puede utilizar para simular el envío de solicitudes (sin autenticar).
  2. user: Crea automáticamente un usuario de prueba para ser referenciado por funciones de prueba o incluso por otros fixtures.
  3. authenticated_client: Referencia los 2 fixtures anteriores para combinar y simular un client con sesión iniciada, lo que permite probar las APIs con «protección de autenticación».

La definición, combinación y uso de fixtures es una gran característica de pytest.

No solo simplifica la configuración del entorno de prueba, sino que también mejora la legibilidad del código de prueba, separando el estado de prueba de la lógica de prueba, lo cual es otra forma de «separación de responsabilidades».

En las funciones de prueba reales solo necesitamos pasar como parámetros los fixtures necesarios; pytest manejará automáticamente su inicialización y limpieza.

Este diseño reduce enormemente el código duplicado, permitiendo que las pruebas se enfoquen más en la validación de la lógica de la API que en la configuración del entorno.

Funciones de prueba#

Por último tenemos las funciones de prueba; veamos solo dos de ellas: (he omitido los type hints de los parámetros para que te concentres en los fixtures mismos)

def test_get_users(authenticated_client) -> None:
    """
    Probar obtener todos los usuarios
    """
    response = authenticated_client.get('/users/')
    assert response.status_code == 200

def test_login_user(client, user) -> None:
    """
    Probar inicio de sesión de usuario
    """
    response = client.post(
        '/users/login/',
        data={'username': 'testuser',
              'password': 'testpassword123'},
        content_type='application/json',
    )
    assert response.status_code == 200

La elección de estas dos funciones tiene un propósito pedagógico:

  1. La función test_login_user prueba la API «Inicio de sesión de usuario», la cual está destinada al acceso de usuarios sin sesión iniciada, por lo que basta con referenciar el client general (sin autenticar).
    • La función también referencia el fixture user, ya que la premisa para un inicio de sesión exitoso es que dicho usuario ya «exista».
    • Y la función del fixture user es precisamente crear primero a dicho usuario antes de comenzar la prueba.
  2. test_get_users prueba una API con protección de autenticación que requiere iniciar sesión para acceder, por lo que referenciamos authenticated_client.
    • Esta función de prueba solo referencia authenticated_client, pero el resultado de prueba real será: existe un usuario en la lista. (El código no incluye esta parte).
    • Debido a que authenticated_client referencia los dos fixtures user y client, con solo referenciar authenticated_client equivale a referenciar a ambos.

Qué fixtures referenciar dependerá del estado de prueba y las condiciones requeridas por cada función.

Los fixtures en sí pueden reutilizarse; este diseño hace que las pruebas mismas sean extremadamente modularizadas, siendo una de las razones por las cuales pytest es tan popular.

Ejecutar pruebas unitarias#

Por último, ¡ejecutemos las pruebas!

Puedes usar el comando pytest directamente en la raíz del proyecto o ejecutar las pruebas unitarias a través de la UI de Testing de VS Code:

VS Code - Testing

¡Beautiful!


4. Conclusión#

Siempre hay una brecha entre la idealización y la realidad. A través de una estrategia pragmática de pruebas, podemos brindar una garantía de calidad suficiente para el proyecto sin caer en un perfeccionismo excesivo.

Herramientas como Test client y pytest hacen que probar APIs sea simple y estructurado. La cobertura de pruebas no necesita ser del cien por cien; siempre que alcance un nivel adecuado, puede brindar un enorme impulso al proceso de desarrollo.

Esta serie de tutoriales ha llegado casi a su fin. Hemos explorado las funciones centrales y las características avanzadas de Django Ninja, desde el diseño de rutas hasta las pruebas unitarias. Ha sido un proceso arduo pero enriquecedor, tanto para ti como para mí.

En el próximo artículo, que será el último, haremos un breve repaso de toda la serie y compartiremos reflexiones sobre la creación y culminación de esta iThome Ironman.