Testes
Documentação sobre a estratégia de testes do fast_deliv: pytest, testes de integração e coleção Bruno.
Estrutura de Testes
backend/tests/
__init__.py
conftest.py → fixtures compartilhadas
unit/
__init__.py
test_pricing.py → testes da fórmula de pricing
test_schemas.py → validação de schemas Pydantic
integration/
__init__.py
test_deliveries.py → testes de endpoints com Supabase mockado
test_wallets.py → testes de saque e carteira
test_webhooks.py → testes do webhook ASAAS
Configuração pytest
# backend/pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # todos os testes async sem @pytest.mark.asyncio
testpaths = ["tests"]
Executar testes
# Todos os testes
uv run pytest
# Verboso
uv run pytest -v
# Arquivo específico
uv run pytest tests/unit/test_pricing.py
# Teste específico
uv run pytest tests/unit/test_pricing.py::test_earning_above_min_km
# Com cobertura
uv run pytest --cov=app --cov-report=term-missing
# Relatório HTML de cobertura
uv run pytest --cov=app --cov-report=html
# Abrir: htmlcov/index.html
Testes Unitários
tests/unit/test_pricing.py
from decimal import Decimal
import pytest
from app.schemas.pricing import PricingConfigOut, calculate_driver_earning
@pytest.fixture
def default_config() -> PricingConfigOut:
"""Configuração padrão: R$15 base, R$2/km, mínimo 5km."""
return PricingConfigOut(
id="test-pricing-id",
base_fare=Decimal("15.00"),
price_per_km=Decimal("2.00"),
min_km=Decimal("5.00"),
updated_at="2026-01-01T00:00:00Z",
updated_by=None,
)
class TestCalculateDriverEarning:
def test_below_min_km_returns_base_fare(self, default_config: PricingConfigOut) -> None:
"""Distâncias abaixo do mínimo pagam apenas a tarifa base."""
assert calculate_driver_earning(Decimal("1.00"), default_config) == Decimal("15.00")
assert calculate_driver_earning(Decimal("3.00"), default_config) == Decimal("15.00")
assert calculate_driver_earning(Decimal("4.99"), default_config) == Decimal("15.00")
def test_at_min_km_returns_base_fare(self, default_config: PricingConfigOut) -> None:
"""Distância exatamente no mínimo paga apenas a tarifa base."""
assert calculate_driver_earning(Decimal("5.00"), default_config) == Decimal("15.00")
def test_above_min_km_adds_extra(self, default_config: PricingConfigOut) -> None:
"""Distâncias acima do mínimo adicionam price_per_km."""
# 5 + (12 - 5) * 2 = 15 + 14 = 29
assert calculate_driver_earning(Decimal("12.00"), default_config) == Decimal("29.00")
def test_long_route(self, default_config: PricingConfigOut) -> None:
"""Teste com rota muito longa."""
# 15 + (25 - 5) * 2 = 15 + 40 = 55
assert calculate_driver_earning(Decimal("25.00"), default_config) == Decimal("55.00")
def test_uses_decimal_not_float(self, default_config: PricingConfigOut) -> None:
"""Resultado deve ser Decimal, nunca float."""
result = calculate_driver_earning(Decimal("12.00"), default_config)
assert isinstance(result, Decimal)
def test_custom_config(self) -> None:
"""Teste com configuração personalizada."""
config = PricingConfigOut(
id="custom",
base_fare=Decimal("20.00"),
price_per_km=Decimal("3.50"),
min_km=Decimal("3.00"),
updated_at="2026-01-01T00:00:00Z",
)
# 20 + (10 - 3) * 3.50 = 20 + 24.50 = 44.50
assert calculate_driver_earning(Decimal("10.00"), config) == Decimal("44.50")
tests/unit/test_schemas.py
import pytest
from pydantic import ValidationError
from app.schemas.delivery import DeliveryCreate
from app.schemas.wallet import WithdrawalRequest
class TestDeliveryCreate:
def test_valid_delivery(self) -> None:
delivery = DeliveryCreate(
title="Entrega Teste",
origin_address="Origem",
origin_lat="123.456", # aceita string (coerce)
origin_lng="-46.633",
destination_address="Destino",
destination_lat="-23.55",
destination_lng="-46.63",
)
assert delivery.title == "Entrega Teste"
def test_empty_title_rejected(self) -> None:
with pytest.raises(ValidationError):
DeliveryCreate(
title="", # min_length=1
origin_address="Origem",
origin_lat="0",
origin_lng="0",
destination_address="Destino",
destination_lat="0",
destination_lng="0",
)
class TestWithdrawalRequest:
def test_valid_pix_types(self) -> None:
for pix_type in ["CPF", "CNPJ", "EMAIL", "PHONE", "EVP"]:
req = WithdrawalRequest(
amount="50.00",
pix_key="test-key",
pix_key_type=pix_type,
)
assert req.pix_key_type == pix_type
def test_negative_amount_rejected(self) -> None:
with pytest.raises(ValidationError):
WithdrawalRequest(
amount="-10.00", # gt=0
pix_key="11999999999",
pix_key_type="PHONE",
)
def test_invalid_pix_type_rejected(self) -> None:
with pytest.raises(ValidationError):
WithdrawalRequest(
amount="50.00",
pix_key="test",
pix_key_type="INVALID_TYPE",
)
Testes de Integração
tests/conftest.py
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def mock_supabase():
"""Mock do cliente Supabase para evitar chamadas reais."""
with patch("app.core.supabase.get_supabase") as mock:
supabase = MagicMock()
mock.return_value = supabase
yield supabase
@pytest.fixture
def admin_token_payload() -> dict:
return {
"sub": "admin-uuid-123",
"email": "admin@test.com",
"user_metadata": {"role": "admin"},
"aud": "authenticated",
}
@pytest.fixture
def driver_token_payload() -> dict:
return {
"sub": "driver-uuid-456",
"email": "driver@test.com",
"user_metadata": {"role": "driver"},
"aud": "authenticated",
}
tests/integration/test_webhooks.py
import hashlib
import hmac
import json
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch
from app.main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def webhook_secret() -> str:
return "test-webhook-secret"
def make_signature(secret: str, body: bytes) -> str:
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
class TestAsaasWebhook:
def test_transfer_done_updates_withdrawal(
self, client: TestClient, webhook_secret: str
) -> None:
payload = {
"event": "TRANSFER_DONE",
"transfer": {"id": "tra_123456"},
}
body = json.dumps(payload).encode()
signature = make_signature(webhook_secret, body)
with patch("app.core.config.settings") as mock_settings:
mock_settings.asaas_webhook_secret = webhook_secret
with patch("app.api.v1.webhooks.get_supabase") as mock_supa:
supabase = MagicMock()
mock_supa.return_value = supabase
response = client.post(
"/api/v1/webhooks/asaas",
content=body,
headers={
"Content-Type": "application/json",
"asaas-signature": signature,
},
)
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_invalid_signature_returns_401(self, client: TestClient) -> None:
response = client.post(
"/api/v1/webhooks/asaas",
json={"event": "TRANSFER_DONE"},
headers={"asaas-signature": "invalid-signature"},
)
assert response.status_code == 401
Testes Manuais com Bruno
Bruno é uma alternativa ao Postman para testar a API localmente.
Estrutura da coleção Bruno
backend/bruno/
fast_deliv.bru → configuração da coleção
environments/
local.bru → http://localhost:8000
production.bru → https://fast-deliv-backend.vercel.app
requests/
health/
health-check.bru
auth/
login-admin.bru
login-driver.bru
deliveries/
create-delivery.bru
list-deliveries.bru
assign-delivery.bru
start-delivery.bru
complete-delivery.bru
cancel-delivery.bru
wallets/
get-wallet.bru
get-transactions.bru
request-withdrawal.bru
locations/
update-location.bru
list-locations.bru
pricing/
get-pricing.bru
update-pricing.bru
webhooks/
simulate-transfer-done.bru
simulate-transfer-failed.bru
Exemplo de request Bruno
# backend/bruno/requests/deliveries/create-delivery.bru
meta {
name: Create Delivery
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/v1/deliveries
body: json
auth: bearer
}
auth:bearer {
token: {{adminToken}}
}
body:json {
{
"title": "Entrega Teste Bruno",
"description": "Medicamentos urgentes",
"origin_address": "Rua das Flores, 123",
"origin_lat": -23.550520,
"origin_lng": -46.633308,
"destination_address": "Av. Paulista, 1000",
"destination_lat": -23.561684,
"destination_lng": -46.655981,
"client_value": 35.00
}
}
tests {
test("Status 201", function() {
expect(res.getStatus()).to.equal(201);
});
test("Has id", function() {
expect(res.getBody().id).to.not.be.empty;
});
test("Status is pending", function() {
expect(res.getBody().status).to.equal("pending");
});
}
Cobertura de Testes
Metas de cobertura:
| Módulo | Cobertura Alvo |
|---|---|
app/schemas/ |
100% |
app/services/pricing.py |
100% |
app/api/v1/ |
80% |
app/core/ |
90% |
Golden Tests
Para regras de negócio críticas, mantenha golden tests — tests que capturam o output exato esperado:
# tests/unit/test_pricing_golden.py
from decimal import Decimal
import pytest
from app.schemas.pricing import PricingConfigOut, calculate_driver_earning
# Casos com output exato documentado
GOLDEN_CASES = [
# (distance_km, base_fare, min_km, price_per_km, expected_earning)
(Decimal("3.00"), Decimal("15.00"), Decimal("5.00"), Decimal("2.00"), Decimal("15.00")),
(Decimal("5.00"), Decimal("15.00"), Decimal("5.00"), Decimal("2.00"), Decimal("15.00")),
(Decimal("12.00"), Decimal("15.00"), Decimal("5.00"), Decimal("2.00"), Decimal("29.00")),
(Decimal("25.00"), Decimal("15.00"), Decimal("5.00"), Decimal("2.00"), Decimal("55.00")),
(Decimal("10.00"), Decimal("20.00"), Decimal("3.00"), Decimal("3.50"), Decimal("44.50")),
]
@pytest.mark.parametrize("distance,base,min_km,per_km,expected", GOLDEN_CASES)
def test_golden_pricing(
distance: Decimal,
base: Decimal,
min_km: Decimal,
per_km: Decimal,
expected: Decimal,
) -> None:
config = PricingConfigOut(
id="golden",
base_fare=base,
price_per_km=per_km,
min_km=min_km,
updated_at="2026-01-01T00:00:00Z",
)
result = calculate_driver_earning(distance, config)
assert result == expected, (
f"distance={distance}, base={base}, min_km={min_km}, per_km={per_km} "
f"→ expected={expected}, got={result}"
)