Workshop material — Getting out of the testing hell!
A bookstore REST API built with FastAPI + PostgreSQL. It supports:
- Books — CRUD catalog with stock levels
- Users — basic accounts
- Orders — place an order, pay with a card token, get an email confirmation, cancel within 1 hour
Current architecture (app/)
app/
├── main.py # FastAPI app, wires the 3 routers together
├── config.py # module-level constants, part env var / part hardcoded
├── database.py # engine, SessionLocal, Base, get_db() dependency
├── models/ # SQLAlchemy ORM: Book, User, Order, OrderItem
├── schemas/ # Pydantic request/response schemas
├── api/ # FastAPI routers: books, users, orders
├── services/
│ └── order_service.py # OrderService: order placement & cancellation logic
└── clients/
├── payment_client.py # PaymentClient - real HTTP calls (httpx) to a fake payment API
└── email_client.py # EmailClient - real SMTP calls (smtplib)
booksandusersrouters talk directly to the DB via theget_db()FastAPI dependency — thin CRUD, no service layer.ordersrouter delegates toOrderService, but builds a newOrderService()per request rather than receiving one viaDepends().OrderService.__init__builds its ownPaymentClient()andEmailClient(), andcreate_order()/cancel_order()each open their ownSessionLocal()session directly instead of reusing the request's DB session — none of the three collaborators (DB session, payment client, email client) are injectable.PaymentClient.charge/refundandEmailClient.sendmake real outbound calls (httpx.posttoPAYMENT_API_URL,smtplib.SMTPtoEMAIL_SMTP_HOST) — there's no fake/stub seam, so exercisingOrderServicemeans hitting (or mocking) real network calls.- Order total/promo calculation lives inline inside
create_order(and is duplicated, slightly differently, in the otherwise-unusedcalculate_order_total) — pure arithmetic is mixed with DB reads and I/O. - Time is read via
datetime.utcnow()inline increate_order/cancel_order— the 1-hour cancellation window can't be exercised without actually waiting. config.pymixesos.environ.get(...)values with hardcoded constants (PAYMENT_API_URL,EMAIL_FROM) read once at import time.
- uv (required)
- Docker or Podman (recommended, for the database)
docker compose up -duv sync
uv run alembic upgrade head
uv run uvicorn app.main:app --reloadAPI docs available at http://localhost:8000/docs.
uv run ruff format .
uv run ruff check .
uv run ty check .By default, a small set of Ruff rules are activated.
The solution passes Ruff with almost all rules :
uv run ruff check --select ALL --ignore EM,TRY003 solution/app
uv run ruff check --select ALL --ignore EM,TRY003,S,ANN,PLR2004 solution/testuv sync --group test
uv run pytest tests/ -vWarning: most tests require a running app and a running database. Several will fail or silently skip without the right environment.
Requires non-root container: Podman, rootless Docker, or belonging to the 'docker' group
Warning: belonging to the 'docker' group is effectively equivalent to being root, because anyone in the docker group can mount the filesystem root ('/') into a privileged container.
uv sync --group solution
uv run pytest tests/ -vIn case of problems with Podman, try:
# Enable the rootless Podman socket
systemctl --user enable --now podman.socket
# Point testcontainers at it:
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sockand in case of test crash with a docker.error, try:
export TESTCONTAINERS_RYUK_DISABLED=true
export TESTCONTAINERS_HOST_OVERRIDE=localhost- Intro & setup check (10 minutes)
- Exploring the codebase and run the tests (15 minutes)
- Quality culture — what signals do you see in the repo about how testing is valued?
- Architecture — what makes the code hard to test? Where are the seams? what are the core services, the 3-rd party services, and the contract between them?
- Existing tests — list every problem you find in
tests/. How many tests actually test something? - CI — what is wrong with
.github/workflows/ci.yml?
- Guided diagnosis, why the tests are bad (20 minutes)
- Some concepts and vocabulary (5 minutes)
- Defining a test strategy (20 minutes)
- Discussing trade-offs (5 minutes)
- Tooling introduction (10 minutes)
10-minutes break
- Fix existing tests (20 minutes)
- Add new tests (25 minutes)
- Minimal refactoring (15 minutes)
- CI setup (10 minutes)
- Review the end-result (8 minutes)
- Ressources (2 minutes)
Problems in the existing tests (spoilers)
- Tests call a live HTTP server (
requests.get("http://localhost:8000/...")) — not portable, order-dependent - Global mutable state (
created_book_id = None) — tests must run in alphabetical order setup_test_data()is called multiple times without cleanup — leaves garbage in the DB- Direct
psycopg2calls bypass the API — tests the DB, not the app time.sleep(3700)is commented out — the test always passes, never tests the deadline- Silent
returnon failure — tests lie green when the feature is broken - Mocks that only verify call count, not correctness
- No teardown anywhere
Problems in the application code (spoilers)
OrderService.__init__createsPaymentClient()andEmailClient()directly — no way to inject fakesOrderServicecallsSessionLocal()itself — can't inject a test sessiondatetime.utcnow()called inline — time-dependent logic can't be tested without sleepingcalculate_order_totalhits the DB to do arithmetic — pure logic buried in I/O- API router instantiates
OrderService()per request — no injection point
Minimal refactoring needed
- Add constructor parameters:
OrderService(db, payment, email, now=datetime.utcnow) - Extract
compute_total(prices, promo_code)as a pure function - Use FastAPI
Depends()to injectOrderServiceinto the router
See solution/order_service.py for the result.
solution/ contains the refactored service and improved tests.
See solution/README.md for a full explanation of what changed and why.
Test design choices (solution/tests/)
Fixture scoping (conftest.py) — three layers, chosen to pay the expensive setup
once while still keeping tests isolated from each other:
pg_container(session-scoped) — one real PostgreSQL container for the whole test run. Starting a container per test would dominate the run time.db_engine(session-scoped) — one SQLAlchemy engine against that container, with the schema created once viaBase.metadata.create_all().db(function-scoped, the default) — each test gets its own connection wrapped in a transaction that is rolled back on teardown. Tests can freely insert/mutate rows without cleaning up or polluting the next test, without needing a fresh container.
Testcontainers — testcontainers[postgres] spins up a real postgres:18 in
Docker/Podman rather than SQLite or a mocked session. The app relies on Postgres-specific
behavior (Numeric for money, a native Enum for OrderStatus) that an in-memory or
different-engine DB wouldn't faithfully exercise.
Fakes over mocks (fakes.py) — FakePaymentClient and FakeEmailClient are small,
working in-memory implementations, not unittest.mock.Mock:
FakePaymentClientrecords realFakeChargeobjects inself.charges, generates incrementing charge ids, and can be built withfail_on_token=...to simulate a declined card — socancel_order's refund logic, for instance, can be asserted againstpayment.charges[0].refunded is Trueinstead of just checkingrefund.assert_called().FakeEmailClientrecordsSentEmailobjects, so tests assert on the actual recipient/ subject/body rather than only "send was called".
This is deliberately different from _check_connections (see order_service.py), which
is patched with a plain unittest.mock.MagicMock via the autouse no_connection_check
fixture — that method is infrastructure noise (a random sleep unrelated to business
logic), so there's nothing worth faking; it's stubbed out entirely. Fakes are reserved for
collaborators (payment, email) whose behavior the tests actually care about.