অধ্যায় 3 · প্রোডাকশন FastAPI
পেশাদার API টেস্ট করা
- পৃষ্ঠা 13 / 22
- 25 মিনিট পড়া
pytest দিয়ে আপনার API টেস্ট পাতায় আপনি এমন এন্ডপয়েন্ট টেস্ট করেছিলেন যাদের বাইরের জগৎ ছিল না। আসল AI সার্ভিস বেশিরভাগই বাইরের জগৎ: ডেটাবেস, আপস্ট্রিম API, প্রতি কলে টাকা নেয় আর কখনো একই উত্তর দেয় না এমন LLM প্রোভাইডার, রেট লিমিট, রিট্রাই, স্ট্রিম ও ওয়েবহুক। পেশাদার টেস্ট স্যুট এই সবকিছু কয়েক সেকেন্ডে যাচাই করে, নেটওয়ার্ক ছাড়া, একটা টোকেনও খরচ না করে, আর প্রতিবার একই ফল দিয়ে।
কৌশলটা হলো অ্যাপটা বানানো সিম (seam) রেখে, মানে বদলে দেওয়া যায় এমন ডিপেন্ডেন্সি, আর সেগুলো বদলায় এমন ছোট একটা ফিক্সচার-টুলকিট তৈরি করা। এই পাতা একটা বাস্তবসম্মত সার্ভিসকে ঘিরে সেই টুলকিট বানায় এবং তার প্রতিটা ঝুঁকিপূর্ণ অংশ টেস্ট করে।
যা শিখবেন
- টেস্টিং পিরামিড, ইউনিট বনাম ইন্টিগ্রেশন টেস্ট, আর আলাদাভাবে চালানোর জন্য মার্কার
- স্কোপসহ ফিক্সচার, ফ্যাক্টরি ফিক্সচার,
parametrizeআরapp.dependency_overrides - বাইরের HTTP (
httpx.MockTransportও respx) এবং LLM প্রোভাইডার মক করা; রোলব্যাক হওয়া ডেটাবেস টেস্ট - অথ, রেট লিমিট, রিট্রাই, টাইমআউট, স্ট্রিমিং রেসপন্স ও ওয়েবহুক সিগনেচার টেস্ট করা
- OpenAPI স্ন্যাপশট দিয়ে কন্ট্রাক্ট টেস্ট, কভারেজ, ফ্লেকি টেস্ট, লোড টেস্টিং ও নিরাপত্তা রিগ্রেশন টেস্ট
টেস্টিং পিরামিড
▲ slow, brittle, expensive
╱ ╲
╱E2E╲ a few: the deployed system, real browser or client
╱─────╲
╱ integ-╲ some: app + real DB / real HTTP / real provider sandbox
╱ ration ╲
╱───────────╲
╱ API tests ╲ many: the whole app through TestClient, outside world faked
╱───────────────╲
╱ unit tests ╲ most: pure functions and classes, no I/O at all
╱───────────────────╲
▼ fast, stable, cheapইউনিট টেস্ট একটা ফাংশন বা ক্লাসকে I/O ছাড়া যাচাই করে: সিগনেচার চেক, রেট লিমিটার, প্রম্পট বিল্ডার। এক সেকেন্ডে হাজারটা চলে। API টেস্ট FastAPI-র TestClient দিয়ে পুরো অ্যাপে (রাউটিং, ভ্যালিডেশন, ডিপেন্ডেন্সি, এরর হ্যান্ডলার) আসল HTTP রিকোয়েস্ট পাঠায়, বাইরের জগৎ নকল দিয়ে বদলানো অবস্থায়। ইন্টিগ্রেশন টেস্ট একটা আসল ডিপেন্ডেন্সি ঢুকতে দেয় (আসল ডেটাবেস, আসল আপস্ট্রিম), তাই ধীর এবং আপনার কোডের বাইরের কারণেও ব্যর্থ হতে পারে; এগুলো কম চালান। এন্ড-টু-এন্ড টেস্ট ডিপ্লয় করা সিস্টেম চালায়। বেশিরভাগ বাগ নিচের স্তরে সস্তায় ধরা পড়ে; ওপরের স্তরগুলো প্রমাণ করে অংশগুলো একসঙ্গে মেলে।
যে সার্ভিস টেস্ট হচ্ছে
একটা কোর্স-রিভিউ API: ব্যবহারকারীরা রিভিউ দেয় (কোর্স সার্ভিসের বিরুদ্ধে যাচাই করে), নিজের রিভিউ মোছে, LLM-কে দিয়ে সারসংক্ষেপ করায় (সাধারণ বা স্ট্রিম করা), আর পেমেন্ট প্রোভাইডার একটা সাইন করা ওয়েবহুক কল করে। প্রজেক্ট সেট আপ করুন:
mkdir -p ~/projects/course-reviews && cd ~/projects/course-reviews
uv init -q --bare --name course-reviews
uv add -q "fastapi[standard]" sqlalchemy
uv add -q --dev pytest httpx2 respx
source .venv/bin/activate
mkdir -p app tests && touch app/__init__.py tests/__init__.py
# pip instead of uv:
# python -m venv .venv && source .venv/bin/activate
# pip install -q "fastapi[standard]" sqlalchemy pytest httpx2 respxhttpx2 আছে TestClient-এর জন্য (Starlette এখন এটাকেই পছন্দ করে, যা বিগিনার টিউটোরিয়ালে ব্যাখ্যা করা হয়েছিল); অ্যাপ নিজে অন্য সার্ভিস কল করে httpx দিয়ে, যে API-র কথা বেশিরভাগ ডকুমেন্টেশনে আছে এবং respx মকিং লাইব্রেরিও যেটা ব্যবহার করে। অ্যাপটা পড়ুন একটা প্রশ্ন মাথায় রেখে: টেস্ট কোথায় ঢুকতে পারে? বাইরের প্রতিটা জিনিস আসে একটা ডিপেন্ডেন্সির মাধ্যমে (get_session, get_http, get_llm, get_limiter), আর সময়-নির্ভর যেকোনো কিছু নেয় একটা clock বা now।
# file: app/main.py
"""Course Reviews API: the service this page tests."""
import hashlib
import hmac
import json
import os
import time
from collections.abc import Iterator
from contextlib import asynccontextmanager
from typing import Annotated, Protocol
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
PRACTICE_API_URL = os.environ.get("PRACTICE_API_URL", "http://localhost:8800")
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///reviews.db")
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "dev-only-secret").encode()
TOKENS = {"token-nadia": "nadia", "token-tanvir": "tanvir", "token-admin": "admin"} # demo only
ADMINS = {"admin"}
class Base(DeclarativeBase):
pass
class Review(Base):
__tablename__ = "reviews"
id: Mapped[int] = mapped_column(primary_key=True)
author: Mapped[str] = mapped_column(String(50))
course_id: Mapped[int]
rating: Mapped[int]
comment: Mapped[str] = mapped_column(String(500))
class ReviewIn(BaseModel):
course_id: int = Field(ge=1)
rating: int = Field(ge=1, le=5)
comment: str = Field(min_length=3, max_length=500)
class ReviewOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
author: str
course_id: int
rating: int
comment: str
engine = create_engine(DATABASE_URL)
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
app.state.http = httpx.Client(base_url=PRACTICE_API_URL, timeout=httpx.Timeout(3.0, connect=1.0))
yield
app.state.http.close()
app = FastAPI(title="Course Reviews", lifespan=lifespan)
# ---------- dependencies: each one is a seam that tests can replace
def get_session() -> Iterator[Session]:
with Session(engine) as session:
yield session
def get_http(request: Request) -> httpx.Client:
return request.app.state.http
class LLM(Protocol):
def complete(self, prompt: str) -> str: ...
def stream(self, prompt: str) -> Iterator[str]: ...
class PracticeLLM:
"""Calls the Practice API's fake LLM; a real provider adapter would sit here."""
def __init__(self, http: httpx.Client):
self.http = http
def _request(self, prompt: str, stream: bool) -> dict:
return {"headers": {"Authorization": "Bearer token-nadia-001"}, "timeout": 30,
"json": {"messages": [{"role": "user", "content": prompt}], "stream": stream}}
def complete(self, prompt: str) -> str:
resp = self.http.post("/v1/chat", **self._request(prompt, stream=False))
resp.raise_for_status()
return resp.json()["message"]["content"]
def stream(self, prompt: str) -> Iterator[str]:
with self.http.stream("POST", "/v1/chat", **self._request(prompt, stream=True)) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if line.startswith("data: ") and "delta" in (event := json.loads(line[6:])):
yield event["delta"]
def get_llm(http: Annotated[httpx.Client, Depends(get_http)]) -> LLM:
return PracticeLLM(http)
class RateLimiter:
"""Sliding-window limiter kept in memory (use Redis in production, as on the previous page)."""
def __init__(self, limit: int, window: float, clock=time.monotonic):
self.limit, self.window, self.clock = limit, window, clock
self.hits: dict[str, list[float]] = {}
def allow(self, key: str) -> bool:
now = self.clock()
recent = [t for t in self.hits.get(key, []) if now - t < self.window]
allowed = len(recent) < self.limit
if allowed:
recent.append(now)
self.hits[key] = recent
return allowed
review_limiter = RateLimiter(limit=3, window=60)
def get_limiter() -> RateLimiter:
return review_limiter
def current_user(authorization: Annotated[str | None, Header()] = None) -> str:
scheme, _, token = (authorization or "").partition(" ")
user = TOKENS.get(token) if scheme.lower() == "bearer" else None
if user is None:
raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"})
return user
SessionDep = Annotated[Session, Depends(get_session)]
HttpDep = Annotated[httpx.Client, Depends(get_http)]
LLMDep = Annotated[LLM, Depends(get_llm)]
LimiterDep = Annotated[RateLimiter, Depends(get_limiter)]
UserDep = Annotated[str, Depends(current_user)]
# ---------- business logic
def fetch_course(http: httpx.Client, course_id: int, attempts: int = 3) -> dict | None:
"""Look a course up in the course service, retrying 503s."""
for attempt in range(1, attempts + 1):
try:
resp = http.get(f"/v1/courses/{course_id}")
except httpx.TimeoutException:
raise HTTPException(504, "Course service timed out")
if resp.status_code == 404:
return None
if resp.status_code == 503 and attempt < attempts:
continue # a real client also backs off here
if resp.status_code != 200:
raise HTTPException(502, "Course service error")
return resp.json()
def verify_signature(secret: bytes, timestamp: str, body: bytes, signature: str,
now: float, tolerance: int = 300) -> bool:
if not timestamp.isdigit() or abs(now - int(timestamp)) > tolerance:
return False # missing, malformed or too old: replay protection
expected = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# ---------- routes
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/reviews", status_code=201)
def create_review(review: ReviewIn, user: UserDep, session: SessionDep,
http: HttpDep, limiter: LimiterDep) -> ReviewOut:
if not limiter.allow(user):
raise HTTPException(429, "Too many reviews, slow down", headers={"Retry-After": "60"})
if fetch_course(http, review.course_id) is None:
raise HTTPException(422, f"Course {review.course_id} does not exist")
row = Review(author=user, **review.model_dump())
session.add(row)
session.commit()
return ReviewOut.model_validate(row)
@app.get("/reviews/{review_id}")
def get_review(review_id: int, session: SessionDep) -> ReviewOut:
row = session.get(Review, review_id)
if row is None:
raise HTTPException(404, "Review not found")
return ReviewOut.model_validate(row)
@app.delete("/reviews/{review_id}", status_code=204)
def delete_review(review_id: int, user: UserDep, session: SessionDep) -> None:
row = session.get(Review, review_id)
if row is None or (row.author != user and user not in ADMINS):
raise HTTPException(404, "Review not found") # don't reveal other people's reviews
session.delete(row)
session.commit()
def course_prompt(course_id: int, session: SessionDep) -> tuple[str, int]:
"""A dependency, so a 404 is raised before any streaming starts."""
comments = session.scalars(select(Review.comment).where(Review.course_id == course_id)).all()
if not comments:
raise HTTPException(404, "No reviews for this course yet")
return "Summarise these course reviews in one sentence:\n" + "\n".join(comments), len(comments)
PromptDep = Annotated[tuple[str, int], Depends(course_prompt)]
@app.post("/courses/{course_id}/summary")
def summarise(course_id: int, prompt: PromptDep, llm: LLMDep) -> dict:
text, count = prompt
return {"course_id": course_id, "reviews": count, "summary": llm.complete(text)}
@app.get("/courses/{course_id}/summary/stream", response_class=EventSourceResponse)
def summarise_stream(prompt: PromptDep, llm: LLMDep) -> Iterator[ServerSentEvent]:
text, count = prompt
for delta in llm.stream(text):
yield ServerSentEvent(data={"delta": delta})
yield ServerSentEvent(event="done", data={"reviews": count})
@app.post("/webhooks/payments")
async def payment_webhook(request: Request, x_timestamp: Annotated[str, Header()],
x_signature: Annotated[str, Header()]) -> dict:
body = await request.body()
if not verify_signature(WEBHOOK_SECRET, x_timestamp, body, x_signature, time.time()):
raise HTTPException(401, "Invalid signature")
return {"received": json.loads(body)["event"]}pytest-কে বলুন টেস্ট কোথায়, আর স্যুট যে একটা কাস্টম মার্কার ব্যবহার করে সেটা ঘোষণা করুন (--strict-markers থাকলে মার্কারের নামে ভুল বানান নীরবে উপেক্ষিত টেস্ট না হয়ে একটা এরর হয়):
cat >> pyproject.toml <<'EOF'
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers"
markers = ["integration: talks to real services (deselect with -m 'not integration')"]
EOF
export PRACTICE_API_URL=http://localhost:8800ফিক্সচার: টেস্টের টুলকিট
সব শেয়ার করা সেটআপ থাকে tests/conftest.py-তে, যা pytest নিজে থেকেই লোড করে। প্রতিটা ফিক্সচার একটা সিম বদলে দেয়:
# file: tests/conftest.py
"""Shared fixtures: a rolled-back database, fake HTTP, a fake LLM and a fresh rate limiter."""
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app import main
from app.main import Base, RateLimiter, Review, app
COURSES = {1: {"id": 1, "title": "How AI Works"}, 8: {"id": 8, "title": "RAG Systems"}}
class FakeLLM:
"""Stands in for the LLM provider: instant, free, predictable, and it records prompts."""
def __init__(self):
self.prompts = []
def complete(self, prompt):
self.prompts.append(prompt)
return "Learners find it clear."
def stream(self, prompt):
self.prompts.append(prompt)
yield from ["Learners ", "find ", "it ", "clear."]
def fake_course_service(request: httpx.Request) -> httpx.Response:
course_id = int(request.url.path.rsplit("/", 1)[-1])
if course_id in COURSES:
return httpx.Response(200, json=COURSES[course_id])
return httpx.Response(404, json={"detail": "Course not found"})
@pytest.fixture(scope="session")
def engine():
"""One in-memory database for the whole test run."""
engine = create_engine("sqlite://", poolclass=StaticPool,
connect_args={"check_same_thread": False})
@event.listens_for(engine, "connect")
def _no_driver_transactions(dbapi_connection, record):
dbapi_connection.isolation_level = None # let SQLAlchemy emit BEGIN/SAVEPOINT itself
@event.listens_for(engine, "begin")
def _begin(connection):
connection.exec_driver_sql("BEGIN")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(engine):
"""Each test runs inside a transaction that is rolled back afterwards."""
with engine.connect() as connection:
transaction = connection.begin()
session = Session(bind=connection, join_transaction_mode="create_savepoint")
yield session
session.close()
transaction.rollback()
@pytest.fixture
def llm():
return FakeLLM()
@pytest.fixture
def course_service():
"""What the fake course service does; a test may swap the handler."""
return {"handler": fake_course_service, "calls": 0}
@pytest.fixture
def client(db_session, llm, course_service):
def transport_handler(request):
course_service["calls"] += 1
return course_service["handler"](request)
http = httpx.Client(base_url="http://courses.test", transport=httpx.MockTransport(transport_handler))
limiter = RateLimiter(limit=3, window=60) # a fresh limiter per test
app.dependency_overrides[main.get_session] = lambda: db_session
app.dependency_overrides[main.get_http] = lambda: http
app.dependency_overrides[main.get_llm] = lambda: llm
app.dependency_overrides[main.get_limiter] = lambda: limiter
yield TestClient(app)
app.dependency_overrides.clear()
http.close()
@pytest.fixture
def make_review(db_session):
"""A factory fixture: call it to create reviews with sensible defaults."""
def make(author="nadia", course_id=1, rating=5, comment="Very clear examples"):
review = Review(author=author, course_id=course_id, rating=rating, comment=comment)
db_session.add(review)
db_session.commit()
return review
return make
NADIA = {"Authorization": "Bearer token-nadia"}
TANVIR = {"Authorization": "Bearer token-tanvir"}
ADMIN = {"Authorization": "Bearer token-admin"}এর ধারণাগুলো একে একে:
- স্কোপ।
engine-এরscope="session": ডেটাবেস ও টেবিল প্রতি রানে একবার বানানো দ্রুত। বাকি সবকিছু ফাংশন-স্কোপের (ডিফল্ট): প্রতিটা টেস্টের জন্য নতুন নকল LLM, নকল কোর্স সার্ভিস ও লিমিটার, তাই কোনো টেস্ট অন্যটার ফেলে যাওয়া জিনিস দেখে না। অন্য স্কোপগুলোmodule,classওpackage। - প্রতি টেস্টে রোলব্যাক।
db_sessionএকটা ট্রানজ্যাকশন খোলে, আর তার ভেতরের সেশন প্রতিটাcommit()-কে একটা সেভপয়েন্টে বদলে দেয় (join_transaction_mode="create_savepoint")। শেষে বাইরের ট্রানজ্যাকশন রোলব্যাক হয়, তাই পরের টেস্ট শুরু করে খালি টেবিল নিয়ে, আর টেবিল আবার বানাতে সময় যায় না। SQLite-এর Python ড্রাইভার ট্রানজ্যাকশন সামলায় নিজের মতো করে, তাই দুটো ইভেন্ট হুক সেই কাজ SQLAlchemy-কে দিয়ে দেয়; PostgreSQL-এ কোনো হুক লাগে না। app.dependency_overridesএকটা ডিপেন্ডেন্সি ফাংশনকে একটা বদলির সঙ্গে জোড়ে। FastAPI আসলটা যেখানে যেখানে ব্যবহার হয়েছিল, অন্য ডিপেন্ডেন্সির ভেতরেও, সেখানে বদলিটা কল করে। প্রতিটা টেস্টের পর ওভাররাইড মুছে দিন।httpx.MockTransportএকটা নকল নেটওয়ার্ক: ক্লায়েন্ট ঠিক আগের মতোই কাজ করে, কিন্তু প্রতিটা রিকোয়েস্টের উত্তর দেয় সার্ভারের বদলে আপনার ফাংশন। কোনো সকেট নেই, কোনো ফ্লেকিনেস নেই।- নকল LLM-এ আসল অ্যাডাপ্টারের মতো একই মেথড (
LLMপ্রোটোকল)। সে সঙ্গে সঙ্গে উত্তর দেয়, খরচ নেই, আর প্রম্পটগুলো রেকর্ড করে, তাই টেস্ট দেখতে পারে কী পাঠানো হতো। - ফ্যাক্টরি ফিক্সচার (
make_review) একটা ফাংশন ফেরত দেয়, তাই টেস্ট শুধু যে ফিল্ডগুলো তার জন্য জরুরি সেগুলো দিয়ে যত খুশি রো বানাতে পারে।
API টেস্ট ও parametrize
# file: tests/test_reviews.py
import pytest
from tests.conftest import NADIA
GOOD = {"course_id": 1, "rating": 5, "comment": "Very clear examples"}
def test_create_review(client):
resp = client.post("/reviews", json=GOOD, headers=NADIA)
assert resp.status_code == 201
assert resp.json() == {"id": 1, "author": "nadia", **GOOD}
def test_database_is_clean_for_every_test(client):
assert client.get("/reviews/1").status_code == 404
@pytest.mark.parametrize("field, value", [
("rating", 0), ("rating", 6), ("comment", "ok"), ("course_id", 0), ("comment", "x" * 501),
])
def test_invalid_reviews_are_rejected(client, field, value):
resp = client.post("/reviews", json={**GOOD, field: value}, headers=NADIA)
assert resp.status_code == 422
assert resp.json()["detail"][0]["loc"] == ["body", field]
def test_unknown_course_is_rejected(client):
resp = client.post("/reviews", json={**GOOD, "course_id": 99}, headers=NADIA)
assert resp.status_code == 422
assert resp.json() == {"detail": "Course 99 does not exist"}pytest -q........ [100%]
8 passed in 0.04s@pytest.mark.parametrize একটা টেস্ট ফাংশনকে পাঁচটা টেস্টে বদলে দিয়েছে, প্রতিটা খারাপ ইনপুটের জন্য একটা, আর ব্যর্থ হলে প্রতিটা আলাদাভাবে রিপোর্ট হয়। দ্বিতীয় টেস্টটা প্রমাণ করে রোলব্যাক কাজ করছে: প্রথম টেস্টে বানানো রিভিউটা নেই। --setup-show একটা টেস্টের চারপাশে ফিক্সচার কীভাবে বানানো ও ভাঙা হয় তা দেখায়, যা স্কোপ বোঝার সবচেয়ে দ্রুত উপায়:
pytest -q --setup-show tests/test_reviews.py::test_create_review
SETUP S engine
SETUP F db_session (fixtures used: engine)
SETUP F llm
SETUP F course_service
SETUP F client (fixtures used: course_service, db_session, llm)
tests/test_reviews.py::test_create_review (fixtures used: client, course_service, db_session, engine, llm) .
TEARDOWN F client
TEARDOWN F course_service
TEARDOWN F llm
TEARDOWN F db_session
TEARDOWN S engine
1 passed in 0.02sসেশন-স্কোপের engine (S) সবকিছুকে ঘিরে থাকে; ফাংশন-স্কোপের ফিক্সচারগুলো (F) শুধু এই টেস্টের জন্য তৈরি হয় আর উল্টো ক্রমে ভাঙে।
অথ ও পারমিশন টেস্ট করা
প্রতিটা রুট তিন ধরনের কলারের জন্য টেস্ট করুন: কেউ না, ভুল ব্যবহারকারী আর সঠিক ব্যবহারকারী। ভুল-ব্যবহারকারীর কেসটাই BOLA (ব্রোকেন অবজেক্ট লেভেল অথরাইজেশন, OWASP API Top 10-এ এক নম্বর) ধরে: এমন এন্ডপয়েন্ট, যা দেখে আপনি লগইন করা আছেন কি না, কিন্তু জিনিসটা যে আপনার সেটা দেখে না। ঠিক করা নিরাপত্তা বাগের জন্য লেখা টেস্টকে বলে নিরাপত্তা রিগ্রেশন টেস্ট; এটা নিশ্চিত করে ফাঁকটা আর ফিরে আসবে না।
# file: tests/test_auth.py
import pytest
from tests.conftest import ADMIN, NADIA, TANVIR
@pytest.mark.parametrize("headers", [{}, {"Authorization": "Bearer wrong"}, {"Authorization": "token-nadia"}])
def test_writing_needs_a_valid_token(client, headers):
resp = client.post("/reviews", json={"course_id": 1, "rating": 5, "comment": "Nice"}, headers=headers)
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == "Bearer"
def test_owner_can_delete(client, make_review):
review = make_review(author="nadia")
assert client.delete(f"/reviews/{review.id}", headers=NADIA).status_code == 204
assert client.get(f"/reviews/{review.id}").status_code == 404
def test_other_users_cannot_delete(client, make_review):
"""Security regression test for BOLA: Tanvir must not delete Nadia's review."""
review = make_review(author="nadia")
assert client.delete(f"/reviews/{review.id}", headers=TANVIR).status_code == 404
assert client.get(f"/reviews/{review.id}").status_code == 200
def test_admin_can_delete_any_review(client, make_review):
review = make_review(author="tanvir")
assert client.delete(f"/reviews/{review.id}", headers=ADMIN).status_code == 204JWT-ভিত্তিক অথের জন্য (অথেন্টিকেশন ও অথরাইজেশন থেকে) মেয়াদ-উত্তীর্ণ টোকেন, অন্য কি-তে সাইন করা টোকেন, আর alg none দেওয়া টোকেনের কেস যোগ করুন; টোকেন বানান একটা ফিক্সচারে PyJWT ও নির্দিষ্ট exp দিয়ে, অথবা যে টেস্ট অথ নিয়ে নয় সেগুলোতে current_user ডিপেন্ডেন্সি ওভাররাইড করুন।
রেট লিমিট, রিট্রাই ও টাইমআউট
এগুলো সময় আর ব্যর্থতার ওপর নির্ভর করে, যা বাস্তবে ধীর আর এলোমেলো। টেস্ট দুটোকেই নিয়ন্ত্রণ করে: লিমিটার নেয় নকল ঘড়ি, আর নকল কোর্স সার্ভিস টেস্ট যে ক্রমের উত্তর বা এক্সেপশন চায় সেটাই দেয়।
# file: tests/test_limits_and_upstream.py
import httpx
from app.main import RateLimiter
from tests.conftest import NADIA, TANVIR
GOOD = {"course_id": 1, "rating": 4, "comment": "Good pace"}
def test_fourth_review_in_a_minute_is_limited(client):
codes = [client.post("/reviews", json=GOOD, headers=NADIA).status_code for _ in range(4)]
assert codes == [201, 201, 201, 429]
assert client.post("/reviews", json=GOOD, headers=TANVIR).status_code == 201
def test_limiter_window_slides():
now = [0.0]
limiter = RateLimiter(limit=2, window=60, clock=lambda: now[0])
assert [limiter.allow("nadia") for _ in range(3)] == [True, True, False]
now[0] = 60.0 # time passes without sleeping
assert limiter.allow("nadia") is True
def test_course_service_503s_are_retried(client, course_service):
replies = iter([httpx.Response(503), httpx.Response(503), httpx.Response(200, json={"id": 1})])
course_service["handler"] = lambda request: next(replies)
assert client.post("/reviews", json=GOOD, headers=NADIA).status_code == 201
assert course_service["calls"] == 3
def test_course_service_keeps_failing(client, course_service):
course_service["handler"] = lambda request: httpx.Response(503)
resp = client.post("/reviews", json=GOOD, headers=NADIA)
assert (resp.status_code, course_service["calls"]) == (502, 3)
def test_course_service_timeout_becomes_504(client, course_service):
def hang(request):
raise httpx.ReadTimeout("timed out", request=request)
course_service["handler"] = hang
resp = client.post("/reviews", json=GOOD, headers=NADIA)
assert resp.status_code == 504
assert course_service["calls"] == 1 # timeouts are not retried hereলক্ষ করুন কী অ্যাসার্ট করা হয়েছে: শুধু চূড়ান্ত স্ট্যাটাস কোড নয়, আপস্ট্রিমে কতগুলো কল পৌঁছেছে। এটাই প্রমাণ করে রিট্রাই লজিক ঠিক যতবার চাওয়া হয়েছে ততবারই 503 রিট্রাই করে আর টাইমআউট রিট্রাই করে না। কোনো টেস্ট ঘুমায় না; নকল ঘড়ি ৬০ সেকেন্ড এগিয়ে দিতে সময় লাগে না। প্রোডাকশন কোড রিট্রাইয়ের মাঝে ঘুমালে স্লিপ ফাংশনটাও প্যারামিটার করুন, বা monkeypatch.setattr দিয়ে প্যাচ করুন।
LLM প্রোভাইডার মক করা, এবং স্ট্রিমিং
# file: tests/test_llm.py
from tests.conftest import NADIA
def test_summary_uses_the_reviews(client, llm, make_review):
make_review(comment="Very clear examples")
make_review(comment="Too fast at the end", rating=3)
resp = client.post("/courses/1/summary")
assert resp.json() == {"course_id": 1, "reviews": 2, "summary": "Learners find it clear."}
assert "Too fast at the end" in llm.prompts[0]
def test_no_reviews_means_404_and_no_llm_call(client, llm):
assert client.post("/courses/8/summary").status_code == 404
assert llm.prompts == [] # no tokens spent
def test_summary_stream(client, make_review):
make_review()
with client.stream("GET", "/courses/1/summary/stream") as resp:
assert resp.headers["content-type"].startswith("text/event-stream")
lines = [line for line in resp.iter_lines() if line]
assert lines == [
'data: {"delta": "Learners "}', 'data: {"delta": "find "}',
'data: {"delta": "it "}', 'data: {"delta": "clear."}',
"event: done", 'data: {"reviews": 1}',
]নকল LLM-এর বিরুদ্ধে টেস্ট করলে আপনার কোড যাচাই হয়: প্রম্পটে রিভিউগুলো আছে কি না, সারসংক্ষেপ করার কিছু না থাকলে টোকেন খরচ হয় না, আর স্ট্রিমের গঠন ঠিক আছে। client.stream(...) স্ট্রিমিং রেসপন্স লাইন ধরে ধরে পড়ে, যা Server-Sent Events-এর জন্য কাজ করে। এই টেস্টগুলো যা যাচাই করতে পারে না তা হলো আসল মডেল ভালো সারসংক্ষেপ লেখে কি না; সেটা ইভ্যালুয়েশন, নিজস্ব ডেটাসেট ও স্কোর নিয়ে আলাদা একটা কাজ (দেখুন স্ট্রিমিংসহ LLM ব্যাকএন্ড বানানো)। দুটোকে আলাদা রাখুন: ইউনিট ও API টেস্ট ডিটারমিনিস্টিক (প্রতিবার একই ফল) ও বিনামূল্যে হতে হবে; ইভ্যাল পরিসংখ্যানভিত্তিক আর টাকা খরচ করে।
ওয়েবহুক সিগনেচার
একটা ওয়েবহুক এন্ডপয়েন্টকে এমন সবকিছু প্রত্যাখ্যান করতে হবে যা শেয়ার করা সিক্রেট দিয়ে প্রেরক সাইন করেনি, এবং আক্রমণকারীর পুনরায় পাঠানো পুরোনো মেসেজও। গ্রহণযোগ্য কেস এবং ভুল হওয়ার প্রতিটা উপায় টেস্ট করুন, সঙ্গে নির্দিষ্ট now দিয়ে সিগনেচার ফাংশনটা আলাদাভাবে:
# file: tests/test_webhooks.py
import hashlib
import hmac
import json
import time
import pytest
from app.main import WEBHOOK_SECRET, verify_signature
def signed(body: dict, secret: bytes = WEBHOOK_SECRET, timestamp: int | None = None):
raw = json.dumps(body).encode()
ts = str(timestamp if timestamp is not None else int(time.time()))
sig = hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return raw, {"X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json"}
def test_valid_webhook_is_accepted(client):
raw, headers = signed({"event": "payment.succeeded"})
resp = client.post("/webhooks/payments", content=raw, headers=headers)
assert resp.json() == {"received": "payment.succeeded"}
@pytest.mark.parametrize("case", ["wrong secret", "tampered body", "replayed"])
def test_bad_webhooks_are_rejected(client, case):
if case == "wrong secret":
raw, headers = signed({"event": "payment.succeeded"}, secret=b"guess")
elif case == "tampered body":
_, headers = signed({"event": "payment.succeeded", "amount": 500})
raw = json.dumps({"event": "payment.succeeded", "amount": 5}).encode()
else:
raw, headers = signed({"event": "payment.succeeded"}, timestamp=int(time.time()) - 3600)
assert client.post("/webhooks/payments", content=raw, headers=headers).status_code == 401
def test_signature_check_is_a_pure_function():
"""A unit test: no app, no HTTP, a fixed clock."""
raw = b'{"event": "x"}'
sig = hmac.new(b"k", b"1000." + raw, hashlib.sha256).hexdigest()
assert verify_signature(b"k", "1000", raw, sig, now=1100)
assert not verify_signature(b"k", "1000", raw, sig, now=1400) # 400 s later: too old
assert not verify_signature(b"k", "abc", raw, sig, now=1000)pytest -q........................... [100%]
27 passed in 0.11sকন্ট্রাক্ট টেস্ট: OpenAPI স্ন্যাপশট
আপনার OpenAPI স্কিমা প্রতিটা ক্লায়েন্টের কাছে একটা প্রতিশ্রুতি: ফ্রন্টএন্ড, মোবাইল অ্যাপ, অন্য টিম, বা আপনার API-কে টুল হিসেবে কল করা AI এজেন্ট। কন্ট্রাক্ট টেস্ট ব্যর্থ হয় যখন সেই প্রতিশ্রুতি ভুল করে বদলে যায়। সবচেয়ে সরল রূপ স্কিমাটা একবার সেভ করে পরের রানগুলোকে তার সঙ্গে মেলায়:
# file: tests/test_contract.py
"""Contract test: the OpenAPI schema is the promise you made to clients."""
import json
import os
from pathlib import Path
from app.main import app
SNAPSHOT = Path(__file__).parent / "openapi.snapshot.json"
def test_openapi_matches_snapshot():
current = app.openapi()
if os.environ.get("UPDATE_SNAPSHOT") == "1" or not SNAPSHOT.exists():
SNAPSHOT.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n")
saved = json.loads(SNAPSHOT.read_text())
assert sorted(current["paths"]) == sorted(saved["paths"]), "endpoints were added or removed"
for name, schema in saved["components"]["schemas"].items():
now = current["components"]["schemas"].get(name, {})
assert now.get("properties") == schema.get("properties"), f"schema {name} changed"
assert now.get("required") == schema.get("required"), f"required fields of {name} changed"pytest -q tests/test_contract.py
ls tests/openapi.snapshot.json. [100%]
1 passed in 0.01s
tests/openapi.snapshot.jsonএখন একজন সতীর্থ রিকোয়েস্ট মডেলে comment-কে বদলে text করল, যা comment পাঠায় এমন প্রতিটা ক্লায়েন্টের জন্য একটা ব্রেকিং চেঞ্জ:
sed -i 's/ comment: str = Field(min_length=3, max_length=500)/ text: str = Field(min_length=3, max_length=500)/' app/main.py
pytest -q --tb=short tests/test_contract.pyF [100%]
============================================= FAILURES =============================================
__________________________________ test_openapi_matches_snapshot ___________________________________
tests/test_contract.py:20: in test_openapi_matches_snapshot
assert now.get("properties") == schema.get("properties"), f"schema {name} changed"
E AssertionError: schema ReviewIn changed
E assert {'course_id':...tle': 'Text'}} == {'comment': {...': 'integer'}}
E
E Omitting 2 identical items, use -vv to show
E Left contains 1 more item:
E {'text': {'maxLength': 500, 'minLength': 3, 'title': 'Text', 'type': 'string'}}
E Right contains 1 more item:
E {'comment': {'maxLength': 500,
E 'minLength': 3,...
E
E ...Full output truncated (3 lines hidden), use '-vv' to show
===================================== short test summary info ======================================
FAILED tests/test_contract.py::test_openapi_matches_snapshot - AssertionError: schema ReviewIn ch...
1 failed in 0.04sটেস্ট সিদ্ধান্ত নেয় না যে পরিবর্তনটা অনুমোদিত কি না; সে একজন মানুষকে সিদ্ধান্ত নিতে বাধ্য করে। ইচ্ছাকৃত হলে API-র ভার্সন বাড়ান বা ঘোষণা দিন, তারপর UPDATE_SNAPSHOT=1 pytest tests/test_contract.py দিয়ে স্ন্যাপশট নতুন করুন আর নতুন ফাইলটা কমিট করুন, যাতে রিভিউয়াররা পুল রিকোয়েস্টে পরিবর্তনটা দেখতে পান। এখানে এটা ভুলবশত হয়েছিল, তাই ফিরিয়ে দিন:
sed -i 's/ text: str = Field(min_length=3, max_length=500)/ comment: str = Field(min_length=3, max_length=500)/' app/main.py
pytest -q tests/test_contract.py. [100%]
1 passed in 0.01sআরও এগিয়ে গিয়ে কাজে লাগে বিশেষ টুল: oasdiff দুটো OpenAPI ফাইলের পার্থক্যকে ব্রেকিং বা নন-ব্রেকিং হিসেবে ভাগ করে, আর Schemathesis আপনার স্কিমা থেকে হাজার হাজার রিকোয়েস্ট বানিয়ে সার্ভার ক্র্যাশ করা ইনপুট খোঁজে।
ইন্টিগ্রেশন টেস্ট, respx ও মার্কার
কিছু টেস্টের আসল জিনিস ছোঁয়া উচিত: আসল কোর্স সার্ভিস, আসল অ্যাডাপ্টারের স্ট্রিমিং কোড, লাইফস্প্যানসহ পুরো অ্যাপ। এগুলোতে মার্ক দিন, যাতে দ্রুত স্যুট এদের বাদ দিতে পারে:
# file: tests/test_integration.py
"""Tests that talk to real services. Slower; run them on purpose with -m integration."""
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app import main
pytestmark = pytest.mark.integration
def test_real_course_service_lookup():
with httpx.Client(base_url=main.PRACTICE_API_URL, timeout=5) as http:
assert main.fetch_course(http, 2)["title"] == "Python for AI"
assert main.fetch_course(http, 999) is None
def test_practice_llm_adapter_streams():
with httpx.Client(base_url=main.PRACTICE_API_URL, timeout=5) as http:
text = "".join(main.PracticeLLM(http).stream("What is MLOps?"))
assert text.startswith("This is a practice answer about: What is MLOps.")
@respx.mock
def test_app_with_respx_and_lifespan(tmp_path, monkeypatch):
"""The whole app, real lifespan and real httpx client; respx answers the HTTP calls."""
monkeypatch.setattr(main, "engine", main.create_engine(f"sqlite:///{tmp_path}/test.db"))
route = respx.get(f"{main.PRACTICE_API_URL}/v1/courses/1").mock(
return_value=httpx.Response(200, json={"id": 1}))
with TestClient(main.app) as client: # "with" runs the lifespan
resp = client.post("/reviews", json={"course_id": 1, "rating": 5, "comment": "Great"},
headers={"Authorization": "Bearer token-nadia"})
assert resp.status_code == 201
assert route.call_count == 1তৃতীয় টেস্ট ব্যবহার করে respx, যা খোদ httpx-কে প্যাচ করে: যেকোনো httpx ক্লায়েন্টের করা প্রতিটা রিকোয়েস্ট আপনার ঘোষণা করা রুটের সঙ্গে মেলানো হয়, আর না-মেলা রিকোয়েস্ট একটা এরর। যে কোড নিজের ক্লায়েন্ট নিজে বানায় আর যেখানে ট্রান্সপোর্ট ইনজেক্ট করা যায় না, তার জন্য এটা মানানসই। monkeypatch মডিউলের ইঞ্জিনকে একটা অস্থায়ী ফাইল-ডেটাবেস দিয়ে বদলায় এবং টেস্টের পর বদল ফিরিয়ে দেয়। (respx শুধু httpx প্যাকেজকে প্যাচ করে; httpx2-ভিত্তিক ক্লায়েন্টের জন্য ওপরের ফিক্সচারের মতো MockTransport ইনজেক্ট করুন।)
pytest -q -m "not integration"
pytest -q -m integration............................ [100%]
28 passed, 3 deselected in 0.17s
... [100%]
3 passed, 28 deselected in 0.87sCI-তে সাধারণত প্রতিটা পুশে দ্রুত স্যুট চলে, আর ইন্টিগ্রেশন টেস্ট চলে main-এ মার্জে বা রাতে, টেস্ট ডেটাবেস ও প্রোভাইডার স্যান্ডবক্সের বিরুদ্ধে, কখনো প্রোডাকশনের বিরুদ্ধে নয়।
Async টেস্ট
async কোড সরাসরি (বা async টেস্টের ভেতর থেকে একটা অ্যাপ) টেস্ট করতে httpx.AsyncClient ও ASGITransport ব্যবহার করুন, যা অ্যাপকে মেমরির ভেতরেই কল করে। FastAPI-র সঙ্গে ইনস্টল হওয়া AnyIO pytest প্লাগইন anyio মার্ক করা async def টেস্ট চালায়; pytest-asyncio জনপ্রিয় বিকল্প।
# file: tests/test_async.py
import httpx
import pytest
from app.main import app
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
async def test_health_with_an_async_client():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/health")
assert resp.json() == {"status": "ok"}pytest -q tests/test_async.py. [100%]
1 passed in 0.01sকভারেজ
কভারেজ মাপে আপনার টেস্ট কোন লাইন (ও ব্রাঞ্চ) চালিয়েছে। কোনো টেস্ট ছোঁয় না এমন কোড, যেমন এরর পথ, খুঁজে বের করতে এটা ভালো, কিন্তু লক্ষ্য হিসেবে খারাপ: দুর্বল অ্যাসার্শনসহ ১০০% কভারেজ খুব কমই প্রমাণ করে। সাধারণ টুল pytest-cov প্লাগইন:
uv add --dev pytest-cov # or: pip install pytest-cov
pytest --cov=app --cov-branch --cov-report=term-missingরিপোর্টে প্রতিটা ফাইলের শতকরা হার আর কখনো না-চলা লাইন নম্বর থাকে। শতকরা হার নয়, বাদ পড়া লাইনগুলো পড়ুন: পেমেন্ট বা LLM কলের চারপাশের টেস্ট-না-হওয়া except ব্রাঞ্চ টেস্টের যোগ্য; একটা __repr__ নয়। অনেক টিম কভারেজ কমতে না দিতে একটা সীমার নিচে CI ব্যর্থ করে (--cov-fail-under=85)।
ফ্লেকি টেস্ট
ফ্লেকি টেস্ট কোড না বদলেও কখনো পাস করে কখনো ফেল করে। এটা কোনো টেস্ট না থাকার চেয়েও খারাপ: মানুষ "রি-রান" চাপতে শেখে আর লাল বিল্ডে ভরসা হারায়। সাধারণ কারণ ও সমাধান:
| কারণ | সমাধান |
|---|---|
আসল সময়: sleep, টাইমস্ট্যাম্প, "১ সেকেন্ডের মধ্যে" | ঘড়ি বা now প্যারামিটার ইনজেক্ট করুন; ঘড়ির সময়কালের ওপর কখনো অ্যাসার্ট করবেন না |
| আসল নেটওয়ার্ক: আপস্ট্রিম, LLM, DNS | দ্রুত স্যুটে মক ট্রান্সপোর্ট বা respx; আসল কল শুধু মার্ক করা ইন্টিগ্রেশন টেস্টে |
| শেয়ার করা অবস্থা: মডিউল-লেভেল লিমিটার, ক্যাশ বা dict, অন্য টেস্টের রেখে যাওয়া রো | ফাংশন-স্কোপের ফিক্সচার, প্রতি টেস্টে রোলব্যাক, monkeypatch দিয়ে গ্লোবাল রিসেট |
| ক্রমনির্ভরতা: একটা টেস্ট পাস করে শুধু আরেকটা চলার পর | এটা ফাঁস করতে র্যান্ডম ক্রমে চালান (pytest-randomly প্লাগইন) |
| র্যান্ডমনেস: শাফল, স্যাম্পলিং, মডেল ট্রেনিং | নির্দিষ্ট সিড (random_state=42) |
| কনকারেন্সি: ব্যাকগ্রাউন্ড টাস্ক, থ্রেড | সম্পন্ন হওয়ার জন্য স্পষ্টভাবে অপেক্ষা করুন; লজিক সিঙ্ক্রোনাসভাবে টেস্ট করুন |
ফ্লেকি টেস্ট খুঁজতে কাজে লাগে এমন কমান্ড: pytest -x প্রথম ব্যর্থতায় থামে, pytest --lf শুধু শেষবারের ব্যর্থগুলো আবার চালায়, pytest -p no:randomly র্যান্ডম ক্রম বন্ধ করে, আর pytest --count=50 -k name (pytest-repeat) একটা টেস্ট অনেকবার চালায়। আজ ঠিক করতে পারছেন না এমন টেস্টকে একটা xfail ও একটা টিকিট দিয়ে আলাদা করে রাখুন; লাল অবস্থায় ফেলে রাখবেন না।
লোড টেস্টিংয়ের গোড়ার কথা
ইউনিট ও API টেস্ট একটা রিকোয়েস্টের শুদ্ধতা প্রমাণ করে। লোড টেস্ট ক্ষমতার প্রশ্নের উত্তর দেয়: p95 ল্যাটেন্সি ৫০০ ms পেরোনোর আগে প্রতি সেকেন্ডে কত রিকোয়েস্ট? চাপে রেট লিমিটার ও কানেকশন পুল ঠিকমতো চলে কি? মেমরি বাড়ে কি? Locust ব্যবহারকারীর আচরণ Python-এ বর্ণনা করে:
# file: locustfile.py
from locust import HttpUser, between, task
class Learner(HttpUser):
wait_time = between(1, 3) # think time between actions
@task(5)
def read_review(self):
self.client.get("/reviews/1", name="/reviews/[id]")
@task(1)
def summary(self):
self.client.post("/courses/1/summary")uv run --with locust locust -f locustfile.py --host http://localhost:8000 \
--users 50 --spawn-rate 5 --run-time 2m --headlessLocust প্রতি এন্ডপয়েন্টে প্রতি সেকেন্ডের রিকোয়েস্ট, ব্যর্থতা ও ল্যাটেন্সি পার্সেন্টাইল জানায়। k6 (JavaScript-এ স্ক্রিপ্ট) আরেকটা জনপ্রিয় পছন্দ। লোড টেস্টের ফল বিশ্বাসযোগ্য রাখার নিয়ম: প্রোডাকশনে নয়, স্টেজিংয়ের কপিতে চালান; LLM অ্যাডাপ্টারকে একটা নকল LLM-এর দিকে তাক করুন (নইলে আপনি আপনার ক্রেডিট কার্ডের লোড টেস্ট করছেন); ধীরে ধীরে চাপ বাড়ান; আর শুধু ক্লায়েন্টের সংখ্যা নয়, সার্ভারের CPU, মেমরি, ডেটাবেস কানেকশন ও এরর লগও দেখুন।
বাস্তবে কোথায় কাজে লাগে
- একটা সাধারণ AI ব্যাকএন্ড টিম প্রতিটা পুশে এক মিনিটেরও কম সময়ে হাজার হাজার ইউনিট ও API টেস্ট চালায়, LLM, ভেক্টর স্টোর ও পেমেন্ট প্রোভাইডার নকল করে, আর ডিপ্লয়ের আগে টেস্ট ডেটাবেস ও প্রোভাইডার স্যান্ডবক্সের বিরুদ্ধে একটা ছোট ইন্টিগ্রেশন স্যুট চালায়।
- প্রোডাকশনে ধরা পড়া বাগ আগে টেস্ট হয়: একটা ব্যর্থ টেস্ট দিয়ে বাগটা আবার ঘটিয়ে দেখুন, ঠিক করুন, টেস্টটা রেখে দিন। নিরাপত্তার ফিক্স (BOLA, সিগনেচার চেক বাদ পড়া) সবসময় একটা টেস্ট পায়।
- OpenAPI স্ন্যাপশট বা
oasdiffCI-তে চলে, যাতে পাবলিক বা পার্টনার API-র ব্রেকিং চেঞ্জ কারও অজান্তে শিপ না হয়; আপনার API-কে টুল হিসেবে কল করা AI এজেন্টের জন্য স্কিমাই হলো ইন্টারফেস। - রেকর্ড করা আসল রেসপন্স ("ক্যাসেট", VCR.py বা pytest-recording দিয়ে) জটিল আপস্ট্রিমের মধ্যপন্থা: একবার রেকর্ড করুন, টেস্টে রিপ্লে করুন, আপস্ট্রিম বদলালে আবার রেকর্ড করুন।
সাধারণ ভুল
- দ্রুত স্যুটে আসল LLM বা আপস্ট্রিম কল করা টেস্ট। এগুলো ধীর, টাকা খরচ করে, নেটওয়ার্ক একটু কাঁপলে ফেল করে আর প্রতি রানে আলাদা উত্তর দেয়। প্রোভাইডার ইনজেক্ট করে নকল ব্যবহার করুন; আসল কল রাখুন মার্ক করা ইন্টিগ্রেশন টেস্টে।
app.dependency_overrides.clear()ভুলে যাওয়া। ওভাররাইড পরের টেস্টে ছড়িয়ে পড়ে আর ব্যর্থতাকে টেস্টের ক্রমের ওপর নির্ভর করায়।yield-সহ ফিক্সচারের ভেতরে ওভাররাইড বসান ও মুছুন।- টেস্টের মধ্যে শেয়ার করা অবস্থা, যেমন মডিউল-লেভেল
review_limiter: চার নম্বর টেস্ট এক থেকে তিন নম্বর টেস্টের বসানো লিমিটে আটকে যেত। ফিক্সচারের মাধ্যমে প্রতিটা টেস্টকে নতুন ইনস্ট্যান্স দিন। - শুধু স্ট্যাটাস কোড অ্যাসার্ট করা। ভুল বডিসহ একটা
201, বা আপস্ট্রিমে দশবার আঘাত করা রিট্রাই লুপ, তবুও পাস করে। বডি, পার্শ্বপ্রতিক্রিয়া (রো, কল, প্রম্পট) আর কলের সংখ্যা অ্যাসার্ট করুন। - শুধু সবকিছু ঠিকঠাক চলার পথ (happy path) টেস্ট করা। বাগ থাকে 401, 404, টাইমআউট, খালি লিস্ট আর দ্বিতীয় ব্যবহারকারীতে। কারও ডেটা ছোঁয় এমন প্রতিটা এন্ডপয়েন্টের জন্য ভুল-ব্যবহারকারীর টেস্ট লিখুন।
নিজে চেষ্টা করুন
- সহজ: একটা parametrize করা টেস্ট যোগ করুন, যা দেখায়
GET /reviews/{id}id"abc"ও"1.5"-এর জন্য422আর12345-এর জন্য404দেয়। - মাঝারি: এমন একটা টেস্ট লিখুন যে কোর্সের কোনো রিভিউ না থাকলে স্ট্রিম এন্ডপয়েন্ট
404দেয় আর LLM-কে একবারও কল করে না। - কঠিন: এমন একটা টেস্ট যোগ করুন যে কোর্স সার্ভিসের
500রিট্রাই হয় না এবং502হয়ে যায়। চালান, আর ফেল করলে সিদ্ধান্ত নিন টেস্ট ভুল না অ্যাপ ভুল।
উত্তর
# file: tests/test_exercises.py
import httpx
import pytest
from tests.conftest import NADIA
# 1. Easy
@pytest.mark.parametrize("review_id, status", [("abc", 422), ("1.5", 422), ("12345", 404)])
def test_get_review_ids(client, review_id, status):
assert client.get(f"/reviews/{review_id}").status_code == status
# 2. Medium
def test_stream_without_reviews(client, llm):
assert client.get("/courses/8/summary/stream").status_code == 404
assert llm.prompts == []
# 3. Hard
def test_500_is_not_retried(client, course_service):
course_service["handler"] = lambda request: httpx.Response(500)
resp = client.post("/reviews", json={"course_id": 1, "rating": 5, "comment": "Nice"}, headers=NADIA)
assert (resp.status_code, course_service["calls"]) == (502, 1)pytest -q tests/test_exercises.py..... [100%]
5 passed in 0.03sতৃতীয় অনুশীলনটাই সবচেয়ে আকর্ষণীয়: fetch_course শুধু 503 রিট্রাই করে, তাই 500 একবারই চেষ্টা হয় ও 502 হয়ে যায়, আর টেস্ট পাস করে। অ্যাপ প্রতিটা 5xx রিট্রাই করলে টেস্ট তিনটা কল দেখে ফেল করত, আর আপনাকে ঠিক করতে হতো ওই আপস্ট্রিমের জন্য 500 রিট্রাই করা নিরাপদ কি না। এমন সিদ্ধান্ত লিখে রাখার উপায়ই হলো টেস্ট।
সারসংক্ষেপ
- বেশিরভাগ টেস্ট হওয়া উচিত বাইরের জগৎ নকল করা দ্রুত ইউনিট ও API টেস্ট; কয়েকটা মার্ক করা ইন্টিগ্রেশন টেস্ট আসল সার্ভিস ছোঁয়।
- টেস্ট করার সুবিধা রেখে ডিজাইন করুন: বাইরের প্রতিটা ডিপেন্ডেন্সি আসে
Dependsদিয়ে, আর প্রতিটা ঘড়ি ইনজেক্ট করা যায়। টেস্ট এগুলো বদলায়app.dependency_overrides,MockTransport, respx ও নকল দিয়ে। - সঠিক স্কোপের ফিক্সচার, ফ্যাক্টরি ফিক্সচার ও
parametrizeটেস্ট ছোট রাখে; প্রতি টেস্টে রোলব্যাক হওয়া ট্রানজ্যাকশন ডেটাবেস পরিষ্কার রাখে। - ঝুঁকিপূর্ণ পথ টেস্ট করুন: ভুল ব্যবহারকারী, লিমিট, কল গুনে রিট্রাই, টাইমআউট, স্ট্রিম, সিগনেচার ও রিপ্লে।
- OpenAPI স্ন্যাপশট দিয়ে API কন্ট্রাক্ট পাহারা দিন; কভারেজ ব্যবহার করুন টেস্ট-না-হওয়া কোড খুঁজতে, লক্ষ্য হিসেবে নয়; ফ্লেকি টেস্ট গোড়া থেকে ঠিক করুন; স্টেজিংয়ে নকল LLM দিয়ে লোড টেস্ট করুন।
এরপর: একটা ML মডেল সার্ভ করা একটা ট্রেন করা scikit-learn মডেলকে FastAPI এন্ডপয়েন্টের পেছনে বসায়, ভ্যালিডেট করা ফিচার, ভার্সনিং, ওয়ার্ম-আপ এবং ছোট মডেল ব্যবহার করা টেস্টসহ।