Skip to content

PYTHON FASTAPI · LESSON 7 OF 8

Test success, errors and access control

Run repeatable checks without touching your development database.

  • pytest
  • TestClient
  • fixtures
  • isolation

Your runnable checkpoint

Extract into a new practice folder. Each download includes complete source, requirements.txt and README.md. Stop any earlier server before running this example.

Create and activate a virtual environment as shown in lesson 1. Install with python -m pip install -r requirements.txt, then run fastapi dev main.py. Lessons 6–8 require TASK_API_KEY as explained in lesson 6.

1. Understand the concepts

Manual requests help you explore; automated tests tell you whether a change broke behavior. Save test_main.py beside main.py and run python -m pytest -q from that directory. The server does not need to be running.

TestClient calls the ASGI application in-process. The with block triggers lifespan, creating the test table. tmp_path gives each test its own temporary database. monkeypatch replaces configuration for a test and restores it afterwards.

The lifecycle test creates its own task, captures its id, updates it and deletes it. It checks externally visible behavior rather than relying on a fixed database id. Validation and security tests exercise rejected requests as well as successful ones.

The test-only key is deliberately not a real credential. Do not point test configuration at production data. A test that merely receives 200 does not prove that authorization or persistence works; assert the body and error behavior too.

2. Build and run

These are complete files, not fragments. Save them beside each other in the extracted project folder. Open http://127.0.0.1:8000/docs to send requests.

main.py

main.py
import os
import sqlite3
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, Response
from fastapi.security import APIKeyHeader
import secrets
from pydantic import BaseModel, ConfigDict, Field

DB_PATH = os.getenv("TASK_DB", "tasks.db")

def get_db():
    connection = sqlite3.connect(DB_PATH, check_same_thread=False)
    connection.row_factory = sqlite3.Row
    try:
        yield connection
    finally:
        connection.close()

@asynccontextmanager
async def lifespan(app: FastAPI):
    connection = sqlite3.connect(DB_PATH)
    try:
        connection.execute("CREATE TABLE IF NOT EXISTS tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0)")
        connection.commit()
    finally:
        connection.close()
    yield

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

def require_api_key(key: Annotated[str | None, Depends(api_key_header)]):
    expected = os.getenv("TASK_API_KEY")
    if not expected:
        raise HTTPException(status_code=503, detail="API key is not configured")
    if not key or not secrets.compare_digest(key, expected):
        raise HTTPException(status_code=401, detail="Invalid API key")

app = FastAPI(title="EduPoly Task API", lifespan=lifespan)
Database = Annotated[sqlite3.Connection, Depends(get_db)]

class TaskCreate(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
    title: str = Field(min_length=1, max_length=120)
    done: bool = False

class TaskRead(TaskCreate):
    id: int

def find_task(db, task_id):
    row = db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="Task not found")
    return dict(row)

@app.get("/health")
def health():
    return {"status": "ok"}

@app.post("/tasks", response_model=TaskRead, status_code=201, dependencies=[Depends(require_api_key)])
def create_task(task: TaskCreate, db: Database):
    cursor = db.execute("INSERT INTO tasks (title, done) VALUES (?, ?)", (task.title, task.done))
    db.commit()
    return find_task(db, cursor.lastrowid)

@app.get("/tasks", response_model=list[TaskRead], dependencies=[Depends(require_api_key)])
def list_tasks(db: Database, limit: Annotated[int, Query(ge=1, le=100)] = 20):
    return [dict(row) for row in db.execute("SELECT * FROM tasks ORDER BY id LIMIT ?", (limit,))]

@app.get("/tasks/{task_id}", response_model=TaskRead, dependencies=[Depends(require_api_key)])
def read_task(task_id: int, db: Database):
    return find_task(db, task_id)

@app.put("/tasks/{task_id}", response_model=TaskRead, dependencies=[Depends(require_api_key)])
def replace_task(task_id: int, task: TaskCreate, db: Database):
    find_task(db, task_id)
    db.execute("UPDATE tasks SET title = ?, done = ? WHERE id = ?", (task.title, task.done, task_id))
    db.commit()
    return find_task(db, task_id)

@app.delete("/tasks/{task_id}", status_code=204, dependencies=[Depends(require_api_key)])
def delete_task(task_id: int, db: Database):
    find_task(db, task_id)
    db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
    db.commit()
    return Response(status_code=204)

requirements.txt

requirements.txt
fastapi[standard]>=0.115,<1
pydantic>=2,<3
httpx>=0.27,<1
pytest>=8,<10

test_main.py

test_main.py
import pytest
from fastapi.testclient import TestClient
import main

@pytest.fixture
def client(tmp_path, monkeypatch):
    monkeypatch.setattr(main, "DB_PATH", str(tmp_path / "test.db"))
    monkeypatch.setenv("TASK_API_KEY", "test-only-key")
    with TestClient(main.app) as session:
        session.headers["X-API-Key"] = "test-only-key"
        yield session

def test_task_lifecycle(client):
    created = client.post("/tasks", json={"title": "Learn FastAPI"})
    assert created.status_code == 201
    task_id = created.json()["id"]
    assert created.json()["done"] is False
    assert client.get(f"/tasks/{task_id}").status_code == 200
    updated = client.put(f"/tasks/{task_id}", json={"title": "Finished", "done": True})
    assert updated.json()["done"] is True
    assert len(client.get("/tasks").json()) == 1
    deleted = client.delete(f"/tasks/{task_id}")
    assert deleted.status_code == 204
    assert deleted.content == b""
    assert client.get(f"/tasks/{task_id}").status_code == 404

def test_validation(client):
    assert client.post("/tasks", json={"title": "  "}).status_code == 422
    assert client.post("/tasks", json={"title": "Valid", "typo": 1}).status_code == 422
    assert client.get("/tasks?limit=101").status_code == 422

def test_security(client):
    client.headers.pop("X-API-Key")
    assert client.get("/tasks").status_code == 401
    assert client.get("/health").status_code == 200
    client.headers["X-API-Key"] = "wrong"
    assert client.post("/tasks", json={"title": "Blocked"}).status_code == 401

def test_missing_configuration(client, monkeypatch):
    monkeypatch.delenv("TASK_API_KEY")
    assert client.get("/tasks").status_code == 503

3. Check the result

  • python -m pytest -q reports four passing tests.
  • Your existing tasks.db is unchanged.
  • Change the expected create status to 200 and observe the test fail; restore 201.

4. Practice independently

Test DELETE of an id that does not exist.

Show the explanation

Call client.delete("/tasks/999999") on the isolated database and assert status_code == 404.

When something goes wrong

For import errors, confirm the virtual environment is active and install requirements.txt using that environment’s Python. For 422, read the response detail and compare your input to the model. For 404, check the method, path and resource id. For 401 in later lessons, use Authorize in /docs with the same key configured on the server.

Read the related official FastAPI documentation ↗