API Testing

pytest + httpx, test database, fixture

রোডম্যাপের মাস ৪পড়তে প্রায় ১৬ মিনিট

Testing অধ্যায়ে pytest এর মূল কথা শিখেছো। API test এ বাড়তি দুইটা জিনিস লাগে: একটা নকল HTTP client যেটা server না চালিয়েই request পাঠায়, আর একটা আলাদা test database যেটা প্রতিটা test এর পর পরিষ্কার হয়। এই অধ্যায়ে এমন setup বানাবো যেটা দিয়ে প্রতিটা endpoint, প্রতিটা permission, প্রতিটা error এর test লেখা সহজ।

#কোন স্তরে কী test

E2E API / integration test Unit test (service, pure function) ধীর, ভঙ্গুর, অল্প কয়েকটা HTTP + database, মূল flow দ্রুত, অনেকগুলো
নিচের দিকে বেশি, উপরের দিকে কম test। API test হলো মাঝের স্তর: আসল HTTP আর আসল database, কিন্তু real server বা phone ছাড়া।

#Setup

Terminal
uv add --dev pytest pytest-asyncio httpx
TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
testpaths = ["tests"]

Test এর জন্য আলাদা database (কখনো development বা production database এ test না!):

Terminal
docker exec -it notes-db psql -U app -c "CREATE DATABASE notes_test;"

#conftest.py: সব test এর ভিত্তি

Python
# tests/conftest.py
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from app.main import create_app
from app.core.db import get_db
from app.models import Base
from app.core.security import hash_password, create_token
from app.models.user import User

TEST_DB_URL = "postgresql+asyncpg://app:secret@localhost:5432/notes_test"


@pytest.fixture(scope="session")
async def engine():
    eng = create_async_engine(TEST_DB_URL)
    async with eng.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)   # test এর শুরুতে নতুন table
    yield eng
    await eng.dispose()


@pytest.fixture
async def db(engine):
    # প্রতিটা test একটা transaction এর ভেতরে, শেষে rollback → database পরিষ্কার
    async with engine.connect() as conn:
        trans = await conn.begin()
        session = AsyncSession(bind=conn, join_transaction_mode="create_savepoint", expire_on_commit=False)
        yield session
        await session.close()
        await trans.rollback()


@pytest.fixture
async def client(db):
    app = create_app()
    app.dependency_overrides[get_db] = lambda: db       # app কে test session দাও
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


async def _make_user(db, email: str) -> User:
    user = User(email=email, name=email.split("@")[0], password_hash=hash_password("secret123"))
    db.add(user)
    await db.flush()
    return user


@pytest.fixture
async def alice(db) -> User:
    return await _make_user(db, "alice@test.com")


@pytest.fixture
async def bob(db) -> User:
    return await _make_user(db, "bob@test.com")


def auth(user: User) -> dict:
    return {"Authorization": f"Bearer {create_token(user.id, 'access', 15)}"}

তিনটা মূল কৌশল:

  1. ASGITransport: httpx সরাসরি FastAPI app কে call করে, কোনো real server বা port লাগে না। দ্রুত।
  2. dependency_overrides: app এর get_db এর জায়গায় test session বসানো। একইভাবে payment gateway, email sender এর নকল বসানো যায়।
  3. Transaction rollback: প্রতিটা test শেষে সব বদল বাতিল, পরের test পরিষ্কার database পায়। Table মুছে আবার বানানোর চেয়ে অনেক দ্রুত।

#Test লেখা

Python
# tests/test_notes_api.py
from tests.conftest import auth


async def test_create_note(client, alice):
    r = await client.post("/v1/notes", json={"title": "বাজারের list"}, headers=auth(alice))
    assert r.status_code == 201
    body = r.json()
    assert body["title"] == "বাজারের list"
    assert body["is_pinned"] is False
    assert "owner_id" not in body              # ভেতরের field বাইরে যায় না


async def test_create_note_requires_login(client):
    r = await client.post("/v1/notes", json={"title": "x"})
    assert r.status_code == 401


async def test_create_note_validation(client, alice):
    r = await client.post("/v1/notes", json={"title": ""}, headers=auth(alice))
    assert r.status_code == 422
    assert "title" in r.json()["error"]["fields"]


async def test_list_shows_only_own_notes(client, alice, bob):
    await client.post("/v1/notes", json={"title": "Alice এর"}, headers=auth(alice))
    await client.post("/v1/notes", json={"title": "Bob এর"}, headers=auth(bob))

    r = await client.get("/v1/notes", headers=auth(alice))
    titles = [n["title"] for n in r.json()["items"]]
    assert titles == ["Alice এর"]

#Security test: সবচেয়ে গুরুত্বপূর্ণ

Auth অধ্যায়ের IDOR মনে আছে? প্রতিটা resource এর জন্য এই test গুলো থাকা উচিত:

Python
import pytest


@pytest.fixture
async def alice_note(client, alice) -> int:
    r = await client.post("/v1/notes", json={"title": "গোপন"}, headers=auth(alice))
    return r.json()["id"]


@pytest.mark.parametrize(
    ("method", "path_tpl", "body"),
    [
        ("GET", "/v1/notes/{id}", None),
        ("PATCH", "/v1/notes/{id}", {"title": "হ্যাক"}),
        ("DELETE", "/v1/notes/{id}", None),
    ],
)
async def test_other_user_cannot_access(client, bob, alice_note, method, path_tpl, body):
    r = await client.request(method, path_tpl.format(id=alice_note), json=body, headers=auth(bob))
    assert r.status_code in (403, 404)


async def test_expired_token_rejected(client, alice):
    from app.core.security import create_token
    expired = create_token(alice.id, "access", minutes=-1)
    r = await client.get("/v1/notes", headers={"Authorization": f"Bearer {expired}"})
    assert r.status_code == 401
    assert r.json()["error"]["code"] == "TOKEN_EXPIRED"


async def test_refresh_token_cannot_be_used_as_access(client, alice):
    from app.core.security import create_token
    refresh = create_token(alice.id, "refresh", minutes=60)
    r = await client.get("/v1/notes", headers={"Authorization": f"Bearer {refresh}"})
    assert r.status_code == 401

#বাইরের service নকল করা

Python
class FakeStorage:
    def __init__(self):
        self.files: dict[str, bytes] = {}

    async def put(self, key: str, data: bytes, content_type: str) -> None:
        self.files[key] = data


@pytest.fixture
def fake_storage(client):
    fake = FakeStorage()
    client._transport.app.dependency_overrides[get_storage] = lambda: fake
    return fake


async def test_upload_image(client, alice, alice_note, fake_storage):
    r = await client.post(
        f"/v1/notes/{alice_note}/image",
        files={"file": ("a.png", PNG_BYTES, "image/png")},
        headers=auth(alice),
    )
    assert r.status_code == 200
    assert len(fake_storage.files) == 1

#CI তে test

প্রতিটা push এ GitHub Actions এ test চলবে, PostgreSQL service সহ। বিস্তারিত Deploy ও CI/CD অধ্যায়ে, কিন্তু মূল অংশ:

YAML
services:
  postgres:
    image: postgres:17
    env:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: notes_test
    ports: ["5432:5432"]

#অনুশীলন

#নিজেকে যাচাই করো

`ASGITransport` ব্যবহারের সুবিধা কী?

আসল server চালানো বা port খোলা ছাড়াই httpx সরাসরি FastAPI app কে request পাঠায়। Test দ্রুত হয়, আর network এর ঝামেলা থাকে না।

প্রতিটা test শেষে transaction rollback করার কারণ কী?

এক test এর বানানো data যেন পরের test এ না থাকে, প্রতিটা test পরিষ্কার database পায়। আর table মুছে আবার বানানোর চেয়ে rollback অনেক দ্রুত।

`dependency_overrides` দিয়ে কী করা যায়?

App এর যেকোনো dependency কে test এর সময় অন্য কিছু দিয়ে বদলানো যায়: test database session, নকল storage, নকল payment gateway, নির্দিষ্ট user। Production code না বদলেই।

একটা test আসলে কাজের কিনা কীভাবে যাচাই করবে?

Code এ ইচ্ছা করে bug ঢুকিয়ে দেখবে test টা ব্যর্থ হয় কিনা। Bug ঢুকিয়েও test pass করলে বুঝবে test টা দুর্বল।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।