Ir para o conteúdo

Fakes: rode o fluxo inteiro sem provedor nenhum

Seu serviço cobra por Pix, manda email de ativação, dispara push, geocodifica endereço e chama um modelo. Para rodar isso na sua máquina você precisaria de cinco credenciais — e para testar, de cinco mocks escritos à mão.

Ou de nada disso.

from tempest_fastapi_sdk.testing.fakes import FakePixProvider

Cada fake implementa uma costura do SDK e não fala com ninguém: sem credencial, sem conta de sandbox, sem rede.

Por que não é um mock

Um mock responde a chamada que você programou. Um fake guarda estado — e estes deixam você mover esse estado:

import asyncio

from tempest_fastapi_sdk.integrations.payment import (
    PaymentStatus,
    PixChargeRequest,
    PixProvider,
)
from tempest_fastapi_sdk.testing.fakes import FakePixProvider


async def main() -> None:
    """Run a checkout from open charge to paid, with nobody scanning a QR."""
    fake = FakePixProvider()
    provider: PixProvider = fake

    charge = await provider.create_pix_charge(
        PixChargeRequest(amount_cents=1990, reference="pedido-1042"),
    )
    print(charge.status.value)

    event = fake.advance(charge.provider_charge_id, PaymentStatus.PAID)
    print(event.type.value)


if __name__ == "__main__":
    asyncio.run(main())

Saída:

pending
charge_paid

advance é a parte que o provedor real não te dá. Chegar a PAID num sandbox exige alguém escaneando um QR code; chegar a CHARGED_BACK exige alguém abrindo uma disputa. Aqui é uma chamada.

O ramo que vale testar é o que falha

Todo fake aceita fail_next(erro): a próxima chamada levanta, e só ela.

from tempest_fastapi_sdk.push.dispatcher import PushDeviceGoneError
from tempest_fastapi_sdk.testing.fakes import FakePushDispatcher

dispatcher = FakePushDispatcher()
dispatcher.fail_next(PushDeviceGoneError("token retired"))

Passe a exceção que o cliente real levanta — assim o caminho exercitado é o caminho que a produção toma. Enfileire várias para falhar várias chamadas, na ordem.

O que existe

Fake Substitui Steering próprio
FakePixProvider PixProvider (OpenPix, Mercado Pago) advance(id, status), charges
FakeCardProvider CardProvider (cartão) decline_next(motivo), advance(id, status), charges
FakeObjectStorageClient SubjectStorageClient (MinIO / S3) fail_delete(chaves), objects, deleted
FakePayoutProvider PayoutProvider (saque Pix) transfers, status= no construtor
FakeTextBackend TextBackend (modelo local) queue(...), prompts
FakeModerationBackend ModerationBackend flag(substring), checked
FakePushDispatcher PushDispatcher (FCM/APNs/WebPush) sent, sent_to(token)
FakeEmailUtils EmailUtils (SMTP) outbox, sent_to(address)
FakeGeocodingBackend GeocodingBackend (Nominatim) add_place(...), queries
FakeRoutingBackend RoutingBackend (OSRM) add_route(...), routes
FakeWebSearchBackend WebSearchBackend (Searxng) add_results(...), queries

Todos expõem fail_next(erro). Todos menos o FakeEmailUtils expõem também calls — a lista de métodos que rodaram, em ordem; no FakeEmailUtils o registro é o próprio outbox, porque send é o único método que ele substitui.

Cartão: recusa é resultado, não exceção

FakeCardProvider segue o contrato CardProvider. A cobrança é aprovada, a não ser que decline_next(motivo) tenha enfileirado uma recusa — que volta como cobrança FAILED, do jeito que o adapter real devolve o 402 do provedor:

import asyncio

from tempest_fastapi_sdk.integrations.payment import (
    CardChargeRequest,
    PaymentStatus,
)
from tempest_fastapi_sdk.testing.fakes import FakeCardProvider


async def main() -> None:
    """Decline one card, approve the next, refund it in two steps."""
    card = FakeCardProvider()
    request = CardChargeRequest(
        amount_cents=10000,
        reference="pedido-1",
        card_token="tok",
        payment_method_id="visa",
    )

    card.decline_next("rejected_by_issuer")
    declined = await card.create_card_charge(request)
    paid = await card.create_card_charge(request)
    partial = await card.refund_card_charge(paid.provider_charge_id, amount_cents=3000)
    full = await card.refund_card_charge(paid.provider_charge_id)

    assert declined.status is PaymentStatus.FAILED
    print(declined.status_detail, partial.status.value, full.status.value)


asyncio.run(main())
rejected_by_issuer paid refunded

capture=False devolve AUTHORIZED, à espera de capture_card_charge ou cancel_card_charge. As transições que o provedor recusa — capturar o que não está autorizado, estornar o que não foi pago, estornar mais do que sobrou — levantam ValueError: o ramo de recusa é alcançável, mas o tipo da exceção é do fake (o adapter real levanta httpx.HTTPStatusError). advance(id, PaymentStatus.CHARGED_BACK) chega ao chargeback, que nenhum método do contrato alcança.

Email: o outbox no lugar do SMTP

FakeEmailUtils é subclasse de EmailUtils, não implementação de um protocolo — porque UserAuthService é tipado contra a classe concreta, e é a herança que faz o fake passar por ali com o type-checker satisfeito:

import asyncio

from tempest_fastapi_sdk.testing.fakes import FakeEmailUtils


async def main() -> None:
    """Send nothing, and assert on what would have been sent."""
    mailer = FakeEmailUtils()

    await mailer.send("ana@example.test", "Ativação", "Seu link: ...")

    print(len(mailer.outbox))
    print(mailer.outbox[0].subject)
    print(mailer.sent_to("ana@example.test")[0].body)


if __name__ == "__main__":
    asyncio.run(main())

Saída:

1
Ativação
Seu link: ...

Só o send é substituído. render_template continua sendo o do EmailUtils, então um teste pode afirmar sobre o mesmo HTML que a produção renderiza — passe template_dir= como você passa em produção.

Modelo de texto: microssegundos no lugar de VRAM

import asyncio

from tempest_fastapi_sdk.testing.fakes import FakeTextBackend


async def main() -> None:
    """Answer from a queue, then from the echo default."""
    backend = FakeTextBackend()
    backend.queue("Olá! Como posso ajudar?")

    print(await backend.generate("Cumprimente o cliente"))
    print(await backend.generate("Cumprimente o cliente"))

    chunks = [chunk async for chunk in backend.stream("conte até tres")]
    print(chunks)


if __name__ == "__main__":
    asyncio.run(main())

Saída:

Olá! Como posso ajudar?
[fake] Cumprimente o cliente
['[fake]', ' conte', ' até', ' tres']

Esgotada a fila, a resposta default ecoa o prompt — assim uma asserção que falha diz qual prompt a produziu, em vez de mostrar um texto genérico. O stream entrega pedaço por pedaço, para o seu render incremental ser de fato exercitado.

Geo: sem Nominatim, sem OSRM

import asyncio

from tempest_fastapi_sdk.geo import Coordinate
from tempest_fastapi_sdk.testing.fakes import (
    FakeGeocodingBackend,
    FakeRoutingBackend,
)


async def main() -> None:
    """Resolve a place from a table, and estimate a trip offline."""
    geocoder = FakeGeocodingBackend()
    geocoder.add_place("Recife", Coordinate(latitude=-8.05, longitude=-34.9))

    print(await geocoder.geocode("recife") is not None)
    print(await geocoder.geocode("rua que nao existe"))

    router = FakeRoutingBackend()
    estimate = await router.route(
        Coordinate(latitude=-8.05, longitude=-34.9),
        Coordinate(latitude=-7.99, longitude=-34.85),
    )
    print(estimate.source, round(estimate.distance_km, 1))


if __name__ == "__main__":
    asyncio.run(main())

Saída:

True
None
fake 11.2

Duas decisões que valem explicar:

  • Endereço não cadastrado devolve None, que é o que o backend real responde para um endereço que ninguém acha — e é o ramo que o serviço esquece de tratar.
  • A rota não inventa aritmética: o FakeRoutingBackend delega para estimate_travel, o estimador offline que o SDK já ships. Os números que seu teste afirma são os do próprio SDK, não uma conta paralela que pode divergir dele.

reverse devolve o mais próximo dos lugares cadastrados, não o de coordenada exata: um serviço reverte uma leitura de GPS, e leitura de GPS não cai em cima do decimal que você guardou.

Ligando no serviço

O fake entra onde o real entrava — no Depends:

from fastapi import Depends, FastAPI

from tempest_fastapi_sdk.integrations.payment import PixChargeRequest, PixProvider
from tempest_fastapi_sdk.testing.fakes import FakePixProvider

app = FastAPI()
_FAKE_PROVIDER = FakePixProvider()


def get_pix_provider() -> PixProvider:
    """Provide the Pix provider this deployment charges with.

    Returns:
        PixProvider: The provider, seen through the contract.
    """
    return _FAKE_PROVIDER


@app.post("/charges")
async def create_charge(
    provider: PixProvider = Depends(get_pix_provider),
) -> dict[str, str]:
    """Open a charge.

    Args:
        provider (PixProvider): Injected provider.

    Returns:
        dict[str, str]: The copy-and-paste code, for the payer.
    """
    charge = await provider.create_pix_charge(
        PixChargeRequest(amount_cents=1990, reference="pedido-1042"),
    )
    return {"br_code": charge.br_code or ""}

Em teste, use app.dependency_overrides[get_pix_provider] para injetar um fake novo por teste — instância nova é estado limpo.

Fake é para desenvolvimento e teste, não para produção

Nada aqui persiste: o estado vive no processo e morre com ele. E nada aqui cobra, entrega ou notifica de verdade. Ligar um fake em produção significa um checkout que aceita pagamento que nunca entrou.

A defesa é a de sempre — o provedor vem de configuração, e o ambiente decide. Se quiser um alarme explícito, faça o build_provider do seu serviço recusar fake quando o ambiente for produção — com um campo que o seu Settings declara (ENVIRONMENT, por exemplo): nenhum mixin de settings do SDK traz esse campo.

Garantia: o fake não pode divergir da costura

Um fake com assinatura diferente da costura é pior que fake nenhum — o serviço passa contra ele e falha contra o provedor real, que é exatamente o defeito que o fake existe para evitar.

Por isso tests/testing/test_fakes_contract.py compara, para cada fake:

  • que todo callable da costura existe no fake;
  • que os nomes de parâmetro e a anotação de retorno batem, por inspect.signature;
  • que é async exatamente onde a costura é async — um substituto sync passaria numa checagem por nome e bloquearia o event loop;
  • e que todo fake exportado está coberto: fake novo sem entrada na tabela falha o guard, em vez de shippar sem checagem.

Medido: com um parâmetro renomeado de max_results para limit, o guard falha com ['self', 'query', 'limit'] == ['self', 'query', 'max_results'].

Recap

  • from tempest_fastapi_sdk.testing.fakes import Fake... — onze costuras, sem credencial e sem rede.
  • Fake não é mock: guarda estado, e você move esse estado (advance, flag, add_place, queue).
  • fail_next(erro) alcança o ramo que falha, com a exceção que o cliente real levanta.
  • calls, outbox, sent, prompts, charges, queries: o que aconteceu fica inspecionável.
  • Resolução é lazy: pedir um fake de Pix não importa genai nem geo — nem carrega nada além do que o import tempest_fastapi_sdk já carregou (o push entra por ali, não pelo fake).
  • Guard de assinatura impede o fake de divergir da costura.