Skip to content

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%
# Verificar cobertura com threshold
uv run pytest --cov=app --cov-fail-under=75

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}"
    )