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:
assertsimple en lugar deself.assertEqual.- Fixtures componibles y más flexibles.
@parametrizepara 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_usersi 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
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