Fakes: run the whole flow with no provider at all¶
Your service charges over Pix, emails an activation link, fires a push, geocodes an address and calls a model. To run that on your machine you would need five credentials — and to test it, five hand-written mocks.
Or none of that.
Each fake implements one of the SDK's seams and talks to nobody: no credential, no sandbox account, no network.
Why this is not a mock¶
A mock answers the call you set up. A fake holds state — and these let you move that state:
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="order-1042"),
)
print(charge.status.value)
event = fake.advance(charge.provider_charge_id, PaymentStatus.PAID)
print(event.type.value)
if __name__ == "__main__":
asyncio.run(main())
Output:
advance is the part the real provider does not give you. Reaching PAID in
a sandbox takes someone scanning a QR code; reaching CHARGED_BACK takes
someone opening a dispute. Here it is one call.
The branch worth testing is the failing one
Every fake takes fail_next(error): the next call raises, and only it.
from tempest_fastapi_sdk.push.dispatcher import PushDeviceGoneError
from tempest_fastapi_sdk.testing.fakes import FakePushDispatcher
dispatcher = FakePushDispatcher()
dispatcher.fail_next(PushDeviceGoneError("token retired"))
Pass the exception the real client raises — that way the branch you exercise is the branch production takes. Queue several to fail several calls, in order.
What exists¶
| Fake | Stands in for | Its own steering |
|---|---|---|
FakePixProvider |
PixProvider (OpenPix) |
advance(id, status), charges |
FakeTextBackend |
TextBackend (local model) |
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 |
All of them expose fail_next(error) and calls — the list of methods that
ran, in order.
Email: an outbox instead of SMTP¶
FakeEmailUtils subclasses EmailUtils rather than implementing a protocol —
because UserAuthService is typed against the concrete class, and inheritance
is what lets the fake pass through there with the type-checker satisfied:
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", "Activation", "Your 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())
Output:
Only send is replaced. render_template is still EmailUtils', so a test
can assert on the same HTML production renders — pass template_dir= the
way you pass it in production.
Text model: microseconds instead of 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("Hi! How can I help?")
print(await backend.generate("Greet the customer"))
print(await backend.generate("Greet the customer"))
chunks = [chunk async for chunk in backend.stream("count to three")]
print(chunks)
if __name__ == "__main__":
asyncio.run(main())
Output:
Once the queue is empty, the default reply echoes the prompt — so a failed
assertion tells you which prompt produced it, instead of showing generic text.
stream hands the reply over piece by piece, so your incremental rendering is
actually exercised.
Geo: no Nominatim, no 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("a street that does not exist"))
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())
Output:
Two decisions worth explaining:
- An unregistered address resolves to
None, which is what the real backend answers for an address nobody can find — and it is the branch a service forgets to handle. - The route invents no arithmetic:
FakeRoutingBackenddelegates toestimate_travel, the offline estimator the SDK already ships. The numbers your test asserts on are the SDK's own, not a parallel calculation that can drift from it.
reverse returns the nearest registered place, not an exact-coordinate
match: a service reverse-geocodes a GPS reading, and a GPS reading never lands
on the decimal you stored.
Wiring it into the service¶
The fake goes where the real one went — into the 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="order-1042"),
)
return {"br_code": charge.br_code or ""}
In tests, use app.dependency_overrides[get_pix_provider] to inject a fresh
fake per test — a new instance is clean state.
Fakes are for development and tests, not production
Nothing here persists: the state lives in the process and dies with it. And nothing here really charges, delivers or notifies. Wiring a fake into production means a checkout that accepts a payment which never arrived.
The defence is the usual one — the provider comes from configuration, and
the environment decides. If you want an explicit alarm, make your
service's build_provider refuse a fake when
settings.ENVIRONMENT == "production".
The guarantee: a fake cannot drift from its seam¶
A fake whose signature differs from the seam is worse than no fake — the service passes against it and fails against the real provider, which is exactly the defect the fake exists to prevent.
So tests/testing/test_fakes_contract.py compares, for every fake:
- that every callable on the seam exists on the fake;
- that the parameter names and the return annotation match, via
inspect.signature; - that it is
asyncexactly where the seam isasync— a sync stand-in would pass a name-only check and block the event loop; - and that every exported fake is covered: a new fake with no entry in the table fails the guard instead of shipping unchecked.
Measured: with one parameter renamed from max_results to limit, the guard
fails with ['self', 'query', 'limit'] == ['self', 'query', 'max_results'].
Recap¶
from tempest_fastapi_sdk.testing.fakes import Fake...— eight seams, no credentials and no network.- A fake is not a mock: it holds state, and you move that state
(
advance,flag,add_place,queue). fail_next(error)reaches the failing branch, with the exception the real client raises.calls,outbox,sent,prompts,charges,queries: what happened stays inspectable.- Resolution is lazy: asking for a Pix fake imports neither genai, push nor geo.
- A signature guard keeps the fake from drifting away from its seam.