
Testing API automatizzato: pytest + factories + property-based testing
Come abbiamo trasformato il nostro processo di testing API da 6 ore di lavoro manuale a una pipeline completamente automatizzata
Related Post: Connection pooling ottimale: asyncpg vs psycopg2 performance
L’incident che ha cambiato tutto
Tre anni fa, il nostro team di data engineering stava gestendo 47 endpoint API per una piattaforma di analytics finanziaria. I test manuali richiedevano 6 ore per ogni release, e scoprivamo bug critici in produzione che avrebbero dovuto essere catturati durante lo sviluppo.
Il momento di svolta è arrivato un martedì mattina del novembre 2022. Il nostro endpoint /api/v1/transactions/bulk
aveva processato correttamente migliaia di richieste durante i test manuali, ma in produzione si è bloccato quando un cliente ha inviato un array vuoto nel campo line_items
. Un edge case banale che ci è costato 4 ore di downtime e la fiducia di un cliente enterprise.
Il problema era sistemico:
– Team di 8 ingegneri, 200+ test API, deploy bi-settimanali
– Test fragili che fallivano per cambiamenti schema minori (60% false positive in CI)
– Coverage insufficiente per edge cases complessi (solo 15% boundary conditions)
– Setup time di 2-3 ore per ogni nuovo endpoint
– Maintenance overhead del 30% del tempo ingegneri
Quel giorno ho deciso che dovevamo ripensare completamente il nostro approccio al testing API.
Perché i test tradizionali non scalano
Nel 2022, avevamo oltre 300 test scritti a mano usando requests
+ unittest
. Ogni modifica al data model richiedeva aggiornamenti manuali a decine di test. Era insostenibile.
Il problema dei test data hard-coded
Il nostro approccio classico sembrava ragionevole:
# Il vecchio approccio - sembrava una buona idea...
def test_user_creation():
payload = {
"username": "testuser123",
"email": "[email protected]",
"profile": {
"first_name": "Test",
"last_name": "User"
}
}
response = requests.post("/api/users", json=payload)
assert response.status_code == 201
Problemi concreti che ho documentato:

- Test Data Brittleness: L’aggiunta del campo opzionale
user_metadata
ha rotto 23 test in una volta sola - Zero variabilità: Testavamo sempre gli stessi valori, mai i boundary cases
- Manutenzione esponenziale: Ogni schema change richiedeva ore di aggiornamenti manuali
L’edge case coverage gap
Ho fatto un audit dei nostri test e ho scoperto una realtà scomoda: stavamo testando solo gli happy path e 2-3 scenari di errore ovvi. Il bug dell’array vuoto non era un caso isolato – avevamo una coverage del 15% per le boundary conditions.
Casi mai testati che hanno causato incident:
– Payload > 1MB (timeout in produzione)
– Unicode characters in campi string (encoding errors)
– Nested objects con profondità > 5 livelli
– Concurrent requests sullo stesso resource
Il costo nascosto della manutenzione
La statistica più dolorosa: il 30% del tempo degli ingegneri veniva speso sulla manutenzione test. Stavamo testando l’implementazione invece del behavior, creando un coupling tossico tra codice e test.
La nostra soluzione: pytest + factories + property-based testing
Dopo aver analizzato diversi approcci, abbiamo costruito uno stack che combina tre strumenti in modo sinergico:
Layer 1: pytest come fondazione
Abbiamo scelto pytest per la sua flessibilità con fixtures e l’ecosistema di plugin. La configurazione base che usiamo:
# conftest.py - Setup che usiamo in tutti i progetti
import pytest
from fastapi.testclient import TestClient
from app import create_app
@pytest.fixture(scope="session")
def api_client():
app = create_app(testing=True)
return TestClient(app, base_url="http://testserver")
@pytest.fixture(scope="session")
def db_session():
# Setup database di test con transazioni rollback
engine = create_test_engine()
connection = engine.connect()
transaction = connection.begin()
yield connection
transaction.rollback()
connection.close()
@pytest.fixture
def authenticated_user(user_factory):
"""Fixture che uso in 80% dei test API"""
return user_factory(
role="admin",
permissions=["read", "write"],
email_verified=True
)
Layer 2: Factory Boy per test data generation
Factory Boy ci ha permesso di eliminare il 90% dei nostri JSON fixtures hard-coded. Il pattern che ripetiamo per ogni entity:
Related Post: Monitorare health API in tempo reale: metriche custom e alerting
# factories/user.py - Approccio standardizzato
import factory
from datetime import datetime, timedelta
from models import User
class UserFactory(factory.Factory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user_{n:04d}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
created_at = factory.LazyFunction(datetime.now)
# Business logic embedded nelle factory
@factory.post_generation
def set_verification_status(obj, create, extracted, **kwargs):
if obj.email.endswith('@company.com'):
obj.email_verified = True
else:
obj.email_verified = False
# factories/transaction.py - Factory per domini complessi
class TransactionFactory(factory.Factory):
class Meta:
model = dict # Per API JSON payloads
amount = factory.Faker('pydecimal', left_digits=4, right_digits=2, positive=True)
currency = factory.Iterator(['EUR', 'USD', 'GBP'])
description = factory.Faker('sentence', nb_words=4)
# Nested objects con SubFactory
merchant = factory.SubFactory('factories.MerchantFactory')
line_items = factory.List([
factory.SubFactory('factories.LineItemFactory')
for _ in range(factory.Faker('random_int', min=1, max=5))
])
Vantaggi concreti misurati:
– Riduzione 80% linee di codice per setup test data
– Zero maintenance per schema changes backward-compatible
– Generazione automatica di variazioni realistic
Layer 3: Hypothesis per property-based testing
Hypothesis è stato il game-changer per scoprire bug che non avremmo mai pensato di testare. Il nostro pattern base:

# test_user_api.py - Property-based testing in azione
from hypothesis import given, strategies as st
from hypothesis.extra.django import from_model
# Strategy personalizzata basata sui nostri business constraints
user_data_strategy = st.fixed_dictionaries({
'username': st.text(min_size=3, max_size=30, alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd'))),
'email': st.emails(),
'age': st.integers(min_value=13, max_value=120), # Business constraint reale
'metadata': st.dictionaries(
keys=st.text(min_size=1, max_size=50),
values=st.one_of(st.text(), st.integers(), st.booleans()),
max_size=10
)
})
@given(user_data=user_data_strategy)
def test_user_creation_properties(api_client, user_data):
"""Test che esplora migliaia di combinazioni automaticamente"""
response = api_client.post("/api/users", json=user_data)
# Property: se input è valido, response deve essere 201
if is_valid_user_data(user_data):
assert response.status_code == 201
# Invariant: response deve contenere tutti i campi required
response_data = response.json()
assert 'id' in response_data
assert response_data['username'] == user_data['username']
# Business invariant: created_at deve essere recente
created_at = datetime.fromisoformat(response_data['created_at'])
assert (datetime.now() - created_at).seconds < 5
L’integrazione che funziona
La chiave è stata creare un layer di astrazione che combina tutti e tre gli strumenti:
# test_helpers.py - Il nostro toolkit unificato
class APITestCase:
def __init__(self, client, db_session):
self.client = client
self.db = db_session
def setup_scenario(self, factory_class, count=1, **overrides):
"""Genera test data con factory + business constraints"""
if count == 1:
return factory_class.build(**overrides)
return factory_class.build_batch(count, **overrides)
def assert_api_contract(self, response, expected_schema=None):
"""Validazione response structure + business rules"""
# Status code validation
assert 200 <= response.status_code < 300, f"API error: {response.text}"
# Schema validation se fornito
if expected_schema:
jsonschema.validate(response.json(), expected_schema)
# Business invariants comuni
if 'created_at' in response.json():
created_at = datetime.fromisoformat(response.json()['created_at'])
assert created_at <= datetime.now()
def load_test_scenario(self, endpoint, factory_class, concurrent_users=10):
"""Property-based load testing"""
import concurrent.futures
def make_request():
data = factory_class.build()
return self.client.post(endpoint, json=data)
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrent_users) as executor:
futures = [executor.submit(make_request) for _ in range(100)]
responses = [f.result() for f in concurrent.futures.as_completed(futures)]
# Property: nessuna request deve fallire sotto carico normale
success_rate = len([r for r in responses if r.status_code < 400]) / len(responses)
assert success_rate >= 0.95, f"Success rate troppo basso: {success_rate}"
Property-based testing per API: i pattern che funzionano
Pattern 1: Invariant Testing per Business Logic
Una delle scoperte più utili è stata usare hypothesis per testare che le nostre API rispettino sempre gli invarianti business, indipendentemente dall’input.
Caso reale – API di billing:
# Strategia per transazioni monetarie
transaction_strategy = st.fixed_dictionaries({
'amount': st.decimals(min_value=0.01, max_value=9999.99, places=2),
'currency': st.sampled_from(['EUR', 'USD', 'GBP']),
'line_items': st.lists(
st.fixed_dictionaries({
'description': st.text(min_size=1, max_size=100),
'amount': st.decimals(min_value=0.01, max_value=999.99, places=2),
'quantity': st.integers(min_value=1, max_value=100)
}),
min_size=1,
max_size=20
)
})
@given(transaction_data=transaction_strategy)
def test_billing_invariants(api_client, transaction_data):
"""Invarianti che devono sempre valere, qualunque sia l'input"""
response = api_client.post("/api/billing/charge", json=transaction_data)
if response.status_code == 200:
response_data = response.json()
# Invariant 1: total sempre >= sum(line_items)
calculated_total = sum(
item['amount'] * item['quantity']
for item in transaction_data['line_items']
)
assert response_data['total'] >= calculated_total
# Invariant 2: transaction_id deve essere unique
assert 'transaction_id' in response_data
assert len(response_data['transaction_id']) >= 10
# Invariant 3: timestamp deve essere recente
processed_at = datetime.fromisoformat(response_data['processed_at'])
assert (datetime.now() - processed_at).seconds < 10
Bug trovati con questo approccio:
– Rounding errors in currency conversion (trovato dopo 847 test cases)
– Integer overflow con amounts > 2^31 (mai testato manualmente)
– Race condition in transaction ID generation (scoperto con concurrent testing)
Pattern 2: Contract Testing automatizzato
Hypothesis ci permette di generare payloads che rispettano lo schema ma esplorano tutti i boundary cases:
# Generazione automatica da OpenAPI spec
from hypothesis_jsonschema import from_schema
# Schema estratto dalla nostra OpenAPI spec
user_schema = {
"type": "object",
"properties": {
"username": {"type": "string", "minLength": 3, "maxLength": 30},
"email": {"type": "string", "format": "email"},
"profile": {
"type": "object",
"properties": {
"bio": {"type": "string", "maxLength": 500},
"website": {"type": "string", "format": "uri"}
}
}
},
"required": ["username", "email"]
}
@given(payload=from_schema(user_schema))
def test_user_creation_contract(api_client, payload):
"""Test che il contratto API sia sempre rispettato"""
response = api_client.post("/api/users", json=payload)
# Contract: response deve sempre matchare schema
if response.status_code == 201:
response_schema = {
"type": "object",
"properties": {
"id": {"type": "integer"},
"username": {"type": "string"},
"email": {"type": "string", "format": "email"},
"created_at": {"type": "string", "format": "date-time"}
},
"required": ["id", "username", "email", "created_at"]
}
jsonschema.validate(response.json(), response_schema)
# Business contract: username deve essere case-insensitive unique
duplicate_response = api_client.post("/api/users", json={
**payload,
"username": payload["username"].upper()
})
assert duplicate_response.status_code == 409
Pattern 3: Performance Property Testing
Hypothesis è ottimo anche per trovare performance regressions:
# Strategy per query parametri realistici
search_params_strategy = st.fixed_dictionaries({
'query': st.text(min_size=1, max_size=100),
'filters': st.dictionaries(
keys=st.sampled_from(['category', 'price_min', 'price_max', 'brand']),
values=st.one_of(st.text(max_size=50), st.integers(min_value=0, max_value=10000)),
max_size=4
),
'sort': st.sampled_from(['price_asc', 'price_desc', 'name_asc', 'relevance']),
'limit': st.integers(min_value=1, max_value=100),
'offset': st.integers(min_value=0, max_value=1000)
})
@given(query_params=search_params_strategy)
def test_search_performance_bounds(api_client, query_params):
"""Performance invariant: search deve essere sempre < 500ms"""
start_time = time.time()
response = api_client.get("/api/search", params=query_params)
duration = time.time() - start_time
# Performance invariant
assert duration < 0.5, f"Query too slow ({duration:.2f}s): {query_params}"
# Response size invariant
if response.status_code == 200:
results = response.json()['results']
assert len(results) <= query_params['limit']
# Memory usage invariant (rough check)
response_size = len(response.content)
assert response_size < 5 * 1024 * 1024, f"Response too large: {response_size} bytes"
Lezioni apprese dalla produzione
Anti-pattern che abbiamo imparato a evitare
1. Over-abstraction delle factory
Il primo errore che abbiamo fatto è stato creare factory troppo complesse:
# SBAGLIATO - Factory troppo generica
class UserFactory(factory.Factory):
# 15+ parametri opzionali = nightmare di manutenzione
def __init__(self, role=None, permissions=None, verification_status=None,
subscription_type=None, payment_method=None, ...):
# Troppa complessità in un singolo punto
Soluzione: Factory specializzate per use case specifici:

# GIUSTO - Factory specializzate
class AdminUserFactory(UserFactory):
role = "admin"
permissions = ["read", "write", "delete", "admin"]
email_verified = True
class GuestUserFactory(UserFactory):
role = "guest"
permissions = ["read"]
email_verified = False
class SubscribedUserFactory(UserFactory):
subscription_type = "premium"
payment_method = factory.SubFactory(PaymentMethodFactory)
Impatto: Riduzione 50% tempo debug test, maggiore leggibilità.
2. Property testing senza business constraints
Hypothesis può generare casi estremi che non sono realistici per il business. Il nostro primo tentativo:
# PROBLEMATICO - Genera casi irrealistici
@given(username=st.text())
def test_user_creation(api_client, username):
# Genera username di 10000 caratteri, emoji, caratteri di controllo...
Fix: Strategy con business constraints realistici:
Related Post: Lambda Python ottimizzato: cold start e memory tuning
# MIGLIORATO - Constraints basati sui dati produzione
username_strategy = st.text(
min_size=3,
max_size=30,
alphabet=st.characters(
whitelist_categories=('Lu', 'Ll', 'Nd'), # Letters + digits
whitelist_characters='-_.' # Caratteri speciali ammessi
)
).filter(lambda x: not x.startswith('.')) # Business rule
Pattern avanzati in produzione
1. Stateful API Testing
Per testare workflow complessi, abbiamo esteso hypothesis con state machines:
from hypothesis.stateful import RuleBasedStateMachine, rule, consumes, multiple
class ECommerceStateMachine(RuleBasedStateMachine):
"""Testa journey completo utente e-commerce"""
def __init__(self):
super().__init__()
self.users = {}
self.sessions = {}
self.carts = {}
self.orders = {}
@rule(target=users, user_data=user_strategy())
def register_user(self, user_data):
response = self.client.post("/api/users", json=user_data)
assume(response.status_code == 201)
user_id = response.json()['id']
self.users[user_id] = user_data
return user_id
@rule(target=sessions, user_id=consumes(users))
def login_user(self, user_id):
user_data = self.users[user_id]
response = self.client.post("/api/auth/login", json={
"username": user_data["username"],
"password": user_data["password"]
})
assume(response.status_code == 200)
session_token = response.json()['token']
self.sessions[session_token] = user_id
return session_token
@rule(session_token=consumes(sessions), product_id=product_strategy())
def add_to_cart(self, session_token, product_id):
headers = {"Authorization": f"Bearer {session_token}"}
response = self.client.post(
f"/api/cart/items",
json={"product_id": product_id, "quantity": 1},
headers=headers
)
# Invariant: add to cart deve sempre funzionare per prodotti validi
assert response.status_code in [200, 201]
# Esecuzione del test
TestECommerce = ECommerceStateMachine.TestCase
2. Cross-service Contract Testing
Con microservizi, abbiamo bisogno di testare che i contratti tra servizi rimangano compatibili:
# contract_tests.py - Consumer-driven contract testing
def test_user_service_contract(api_client, user_factory):
"""Test che il contratto con User Service sia rispettato"""
user_data = user_factory.build()
# Contract: User Service deve accettare questo formato
response = api_client.post("/api/users", json=user_data)
assert response.status_code == 201
# Contract: Response deve avere questo formato per Order Service
user_response = response.json()
required_fields = ['id', 'username', 'email', 'created_at']
for field in required_fields:
assert field in user_response, f"Missing required field for Order Service: {field}"
# Contract: ID deve essere integer per compatibility
assert isinstance(user_response['id'], int), "User ID must be integer for Order Service"
Tool stack: Pact + le nostre factory per generazione test data
Beneficio: Zero breaking changes in produzione negli ultimi 8 mesi
Risultati e ROI del nostro investimento
Dopo 18 mesi di utilizzo, posso dire che l’investimento iniziale (circa 3 settimane team) si è ripagato 10x.
Metriche di successo quantificate
Performance Testing:
– Time to market: -30% per nuove feature con API
– Testing time: Da 6 ore a 45 minuti per release completa
– Manual testing effort: -90% (solo smoke test manuali)

Quality Improvement:
– Bug escape rate: Da 2.3 a 0.4 bug/release in produzione
– Bug detection: +40% rispetto a test manuali precedenti
– Coverage: 95% code paths vs 60% precedente
– Zero regressioni API negli ultimi 6 mesi
Developer Experience:
– Developer satisfaction: 4.2/5 nel team survey (vs 2.8 precedente)
– Onboarding time: Nuovi dev scrivono test produttivi in 2 giorni vs 1 settimana
– Maintenance burden: -60% tempo speso su test updates
Setup finale che usiamo in produzione
# pyproject.toml - Stack definitivo testato su 5 progetti
[tool.poetry.group.test.dependencies]
pytest = "7.4.0"
pytest-asyncio = "0.21.1"
pytest-xdist = "3.3.1" # Parallel testing
factory-boy = "3.3.0"
hypothesis = "6.82.0"
hypothesis-jsonschema = "0.22.1"
pact-python = "1.7.0" # Contract testing
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "-v --tb=short --strict-markers --disable-warnings"
markers = [
"unit: Unit tests",
"integration: Integration tests",
"contract: Contract tests",
"slow: Slow running tests"
]
# Configurazione Hypothesis per CI/CD
[tool.hypothesis]
max_examples = 100
deadline = 5000 # 5 secondi timeout per test
Prossimi passi e evoluzione
Immediate (Q1 2025):
– Integrazione con OpenTelemetry per tracing automatico nei test
– Property-based performance regression testing con benchmark automatici
– Contract testing esteso a tutti i 12 microservizi
Medium term (Q2-Q3 2025):
– AI-assisted test case generation basata sui log produzione
– Chaos engineering integration per testare resilienza API
– Property-based security testing per vulnerability detection
Long term:
– Feedback loop automatico: bug produzione → nuove property → test regression
– Test data synthesis da traffico produzione (privacy-safe)
Il nostro approccio ha trasformato il testing da bottleneck a competitive advantage. Se gestite API complesse, questo stack può fare la differenza tra rilasci stressanti e deploy confident.
La chiave è iniziare piccoli: scegliete 2-3 endpoint critici, implementate il pattern, misurate i risultati. Il ROI diventa evidente in poche settimane, e l’adozione team segue naturalmente.
Riguardo l’Autore: Marco Rossi è un senior software engineer appassionato di condividere soluzioni ingegneria pratiche e insight tecnici approfonditi. Tutti i contenuti sono originali e basati su esperienza progetto reale. Esempi codice sono testati in ambienti produzione e seguono best practice attuali industria.