Testing profesional de APIs DRF con APITestCase

Avanzado
Django
Django
Actualizado: 19/04/2026

Por qué testear APIs

Sin tests automatizados, cada deploy es una apuesta. Los tests protegen contra:

  • Regresiones: un cambio en el código rompe algo que funcionaba.
  • Bugs ocultos: edge cases que no se descubren en manual testing.
  • Cambios de terceros: upgrade de Django, DRF, dependencias.
  • Refactorizaciones: cambiar el código sabiendo que los tests verifican que sigue funcionando.

Una suite de tests rápida (< 30s) permite ejecutar en cada commit. Una suite lenta se ejecuta "cuando alguien se acuerda", y el valor se pierde.

APITestCase y APIClient

DRF extiende el TestCase de Django con APITestCase, que expone un self.client tipo APIClient. Permite enviar peticiones HTTP sin levantar servidor:

from rest_framework.test import APITestCase
from rest_framework import status
from django.contrib.auth.models import User
from .models import Pedido

class PedidoAPITest(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(username='alan', password='secreto')
        self.client.force_authenticate(user=self.user)

    def test_listar_pedidos_vacio(self):
        response = self.client.get('/api/pedidos/')
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data['count'], 0)

    def test_crear_pedido(self):
        data = {'cliente': 1, 'fecha': '2026-04-18'}
        response = self.client.post('/api/pedidos/', data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Pedido.objects.count(), 1)

    def test_obtener_pedido_no_existe(self):
        response = self.client.get('/api/pedidos/999/')
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

Métodos disponibles en APIClient: get(), post(), put(), patch(), delete(), options(), head(). Todos aceptan data=, format='json' (default), HTTP_AUTHORIZATION=, etc.

Autenticación: force vs credentials

force_authenticate: saltarse el login

self.client.force_authenticate(user=self.user)

El cliente actúa como si el usuario estuviera logueado, sin verificar password ni JWT. Ideal para tests unitarios donde el foco no es la autenticación.

credentials: enviar header real

Para testear el flujo completo incluyendo JWT:

from rest_framework_simplejwt.tokens import RefreshToken

refresh = RefreshToken.for_user(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}')

Cada petición llevará el header. Útil para tests de integración que verifican que el middleware JWT funciona.

login con sesiones

Si tu API también acepta auth por sesión:

self.client.login(username='alan', password='secreto')

setUp vs setUpTestData

class PedidoAPITest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        # Se ejecuta UNA VEZ para toda la clase
        cls.user_admin = User.objects.create_superuser('admin', password='p')
        cls.cliente = Cliente.objects.create(nombre='ACME')

    def setUp(self):
        # Se ejecuta antes de CADA test
        self.client.force_authenticate(user=self.user_admin)

    def test_1(self): ...
    def test_2(self): ...

setUpTestData es mucho más rápido para datos que no cambian. Internamente Django usa transacciones: los datos se crean una vez, y cada test corre en una transacción que se rollea al final.

setUp es para estado que cambia entre tests (ej. autenticación cambiada por test, contadores reseteados).

factory_boy para datos realistas

Crear objetos a mano con muchas dependencias es tedioso y frágil:

# MAL: manual
user = User.objects.create_user(username='alan', email='alan@test.com', password='p')
cliente = Cliente.objects.create(nombre='ACME', direccion='Calle 1')
producto = Producto.objects.create(nombre='Laptop', precio=1000, stock=5)
pedido = Pedido.objects.create(cliente=cliente, fecha='2026-04-18')
PedidoLinea.objects.create(pedido=pedido, producto=producto, cantidad=2, precio_unitario=1000)

Con factory_boy:

pip install factory-boy
# tests/factories.py
import factory
from factory.django import DjangoModelFactory
from myapp.models import Cliente, Producto, Pedido, PedidoLinea
from django.contrib.auth.models import User

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f'user{n}')
    email = factory.LazyAttribute(lambda o: f'{o.username}@test.com')
    password = factory.PostGenerationMethodCall('set_password', 'secreto')


class ClienteFactory(DjangoModelFactory):
    class Meta:
        model = Cliente

    nombre = factory.Faker('company', locale='es_ES')
    direccion = factory.Faker('address', locale='es_ES')


class ProductoFactory(DjangoModelFactory):
    class Meta:
        model = Producto

    nombre = factory.Faker('word')
    precio = factory.Faker('pydecimal', left_digits=4, right_digits=2, positive=True)
    stock = factory.Faker('random_int', min=0, max=100)


class PedidoFactory(DjangoModelFactory):
    class Meta:
        model = Pedido

    cliente = factory.SubFactory(ClienteFactory)
    fecha = factory.Faker('date_this_year')


class PedidoLineaFactory(DjangoModelFactory):
    class Meta:
        model = PedidoLinea

    pedido = factory.SubFactory(PedidoFactory)
    producto = factory.SubFactory(ProductoFactory)
    cantidad = factory.Faker('random_int', min=1, max=10)
    precio_unitario = factory.LazyAttribute(lambda o: o.producto.precio)

Uso en tests:

class PedidoAPITest(APITestCase):
    def test_listar_pedidos(self):
        PedidoFactory.create_batch(20)
        response = self.client.get('/api/pedidos/')
        self.assertEqual(response.data['count'], 20)

    def test_crear_pedido_con_lineas(self):
        cliente = ClienteFactory()
        productos = ProductoFactory.create_batch(3)
        data = {
            'cliente': cliente.id,
            'fecha': '2026-04-18',
            'lineas': [
                {'producto': p.id, 'cantidad': 1, 'precio_unitario': str(p.precio)}
                for p in productos
            ],
        }
        response = self.client.post('/api/pedidos/', data, format='json')
        self.assertEqual(response.status_code, 201)

pytest-django: sintaxis más limpia

pytest ofrece sintaxis más concisa que unittest:

pip install pytest pytest-django

pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py

Tests:

# tests/test_pedidos.py
import pytest
from tests.factories import PedidoFactory, UserFactory

@pytest.fixture
def user(db):
    return UserFactory()

@pytest.fixture
def authenticated_client(user, client):
    client.force_login(user)
    return client

def test_listar_pedidos(authenticated_client, db):
    PedidoFactory.create_batch(5)
    response = authenticated_client.get('/api/pedidos/')
    assert response.status_code == 200
    assert response.json()['count'] == 5

@pytest.mark.parametrize('cantidad,esperado', [(1, 201), (0, 400), (-5, 400)])
def test_cantidad_valida(authenticated_client, cantidad, esperado, db):
    data = {'producto': 1, 'cantidad': cantidad, 'precio_unitario': '100'}
    response = authenticated_client.post('/api/lineas/', data)
    assert response.status_code == esperado

Ventajas de pytest:

  • assert simple en lugar de self.assertEqual.
  • Fixtures componibles y más flexibles.
  • @parametrize para testear múltiples inputs con un solo test.
  • Plugins: pytest-xdist (paralelo), pytest-cov (coverage integrado), pytest-mock.

Coverage.py: medir qué no testeas

pip install coverage

coverage run --source='.' manage.py test
coverage report
coverage html  # genera htmlcov/index.html

O con pytest:

pytest --cov=myapp --cov-report=html

El reporte muestra por archivo: líneas cubiertas, no cubiertas, % cobertura, ramas no tomadas. Objetivo razonable: 80-90%. Lo importante no es el número sino qué no está cubierto: suele ser la lógica compleja que precisamente más necesita tests.

Mocking de servicios externos

Si la API llama a un servicio externo (Stripe, SendGrid), no queremos que los tests hagan peticiones reales:

from unittest.mock import patch

class PagoTest(APITestCase):
    @patch('myapp.services.stripe.Charge.create')
    def test_procesar_pago(self, mock_stripe):
        mock_stripe.return_value = {'id': 'ch_123', 'status': 'succeeded'}

        response = self.client.post('/api/pagos/', {'monto': 100, 'token': 'tok_test'})

        self.assertEqual(response.status_code, 201)
        mock_stripe.assert_called_once_with(amount=10000, currency='eur', source='tok_test')

Con pytest-mock es más directo:

def test_procesar_pago(mocker, authenticated_client):
    mock_stripe = mocker.patch('myapp.services.stripe.Charge.create')
    mock_stripe.return_value = {'id': 'ch_123'}
    ...

Fixtures útiles de DRF

Subir archivos

from django.core.files.uploadedfile import SimpleUploadedFile

def test_subir_avatar(authenticated_client, db):
    avatar = SimpleUploadedFile('avatar.png', b'fake png content', content_type='image/png')
    response = authenticated_client.post('/api/perfil/avatar/', {'avatar': avatar})
    assert response.status_code == 200

Enviar JSON anidado

def test_crear_pedido_anidado(authenticated_client, db):
    data = {
        'cliente': 1,
        'lineas': [{'producto': 1, 'cantidad': 2}],
    }
    response = authenticated_client.post('/api/pedidos/', data, content_type='application/json')

O con APIClient, el formato JSON es default:

response = self.client.post('/api/pedidos/', data, format='json')

Tests de permisos exhaustivos

Un bug de permisos en producción es catastrófico. Testea matriz completa:

import pytest

@pytest.mark.parametrize('user_type,endpoint,method,esperado', [
    ('anonymous', '/api/pedidos/', 'get', 401),
    ('anonymous', '/api/pedidos/', 'post', 401),
    ('user',      '/api/pedidos/', 'get', 200),
    ('user',      '/api/pedidos/', 'post', 201),
    ('user',      '/api/pedidos/1/', 'delete', 403),  # no es suyo
    ('owner',     '/api/pedidos/1/', 'delete', 204),
    ('admin',     '/api/pedidos/1/', 'delete', 204),
])
def test_permisos_pedidos(client, user_type, endpoint, method, esperado, pedidos_fixture):
    # pedidos_fixture crea varios usuarios y pedidos
    user = get_user_by_type(user_type)
    if user:
        client.force_login(user)
    response = getattr(client, method)(endpoint)
    assert response.status_code == esperado

Tests rápidos con optimizaciones

Para una suite de 1000 tests que corre en <30 segundos:

  • PASSWORD_HASHERS rápido en tests: MD5PasswordHasher (mucho más rápido que bcrypt).
  • DEBUG=False en tests (DEBUG causa leak de memoria).
  • Fixture pytest de db: @pytest.mark.django_db(transaction=False) rollback por test.
  • pytest-xdist para paralelizar: pytest -n auto.
  • Evitar create_user si no se necesita password: User.objects.create() es 10x más rápido.
  • Ignorar migraciones en tests: MIGRATION_MODULES = {app: None for app in MIGRATION_APPS}.

Checklist

  • [ ] APITestCase o pytest-django configurado.
  • [ ] factory_boy para datos de test con dependencias.
  • [ ] Tests de los 4 casos por endpoint (anónimo, user normal, owner, admin).
  • [ ] Tests parametrizados de validación de inputs.
  • [ ] Mocks para servicios externos.
  • [ ] Cobertura > 80% en código crítico.
  • [ ] Suite < 1 minuto en local, < 5 min en CI.
  • [ ] Ejecutados en cada pull request y antes de merge.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Usar APIClient para simular peticiones HTTP GET/POST/PUT/PATCH/DELETE. Autenticarse con force_authenticate y con login real. Crear objetos con factory_boy con dependencias anidadas (SubFactory) y listas (RelatedFactoryList). Organizar tests en setUpTestData vs setUp. Migrar a pytest-django para sintaxis mas limpia. Medir cobertura con coverage.py y ver que ramas no estan testeadas.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje