অধ্যায় 4 · আপনার প্রথম ব্যাকএন্ড
pytest দিয়ে আপনার API টেস্ট
- পৃষ্ঠা 19 / 22
- 17 মিনিট পড়া
API হাতে পরীক্ষা করা যায়: সার্ভার চালু করুন, /docs খুলুন, "Try it out" চাপুন, উত্তর পড়ুন। একটা বিকেলে একটা এন্ডপয়েন্টের জন্য এটা চলে। কিন্তু বিশটা এন্ডপয়েন্ট, শুক্রবারে সহকর্মীর একটা পরিবর্তন, বা আগামী মাসে মডেল আপগ্রেডের বেলায় চলে না। টেস্ট হলো ছোট ছোট প্রোগ্রাম, যারা আপনার কোড চালায় আর উত্তর মিলিয়ে দেখে, প্রতিবার, এক সেকেন্ডে।
এই পাতায় আপনি একটা ছোট টেক্সট টুলস API বানাবেন (টোকেন গোনা আর লেখাকে চাঙ্কে কাটা, যা RAG পাইপলাইন LLM-কে ডকুমেন্ট দেখানোর আগে করে) এবং pytest ও FastAPI-র TestClient দিয়ে সেটা টেস্ট করবেন। সফল রেসপন্স, ভ্যালিডেশন এরর (422), এরর কোড ও এজ কেস টেস্ট করবেন, ফেইল করা টেস্ট পড়া শিখবেন, আর টেস্টগুলো tests/ ফোল্ডারে সাজাবেন।
যা শিখবেন
- স্বয়ংক্রিয় টেস্ট হাতে পরীক্ষার চেয়ে ভালো কেন, আর টেস্ট আসলে কী।
- pytest-এর মূল কথা:
assert, টেস্ট খুঁজে পাওয়া (discovery), আর-q,-v,-kফ্ল্যাগ। - সার্ভার চালু না করেই
TestClientদিয়ে API টেস্ট করা (আর pytest-এর পাশেhttpx2কেন ইনস্টল করবেন)। - সফল রেসপন্স, 422 ভ্যালিডেশন এরর, 404 এরর ও এজ কেস টেস্ট করা।
- ফেইল করা টেস্ট ও Python ট্রেসব্যাক পড়া, আর
breakpoint()ব্যবহার। - টেস্ট সাজানো:
tests/ফোল্ডার আরconftest.py-তে প্রথম ফিক্সচার।
প্রজেক্ট সেট আপ করুন
Pydantic মডেল, ভ্যালিডেশন ও এরর রেসপন্স পাতার মতোই একটা নতুন uv প্রজেক্ট, অ্যাপের কোড থাকবে app প্যাকেজে। FastAPI ছাড়া দুটো টেস্ট প্যাকেজ লাগবে: pytest (টেস্ট চালানোর টুল) আর httpx2 (TestClient-এর দরকার, নিচে ব্যাখ্যা আছে)। এ দুটো শুধু ডেভেলপমেন্টের জন্য, তাই --dev দিয়ে uv এদের আলাদা গ্রুপে রাখে।
mkdir -p ~/projects
cd ~/projects
uv init -q --no-package bitbyte-text-tools
cd bitbyte-text-tools
uv add -q "fastapi[standard]"
uv add -q --dev pytest httpx2
rm main.py
mkdir app tests
touch app/__init__.py
ls -A.git
.gitignore
.python-version
.venv
README.md
app
pyproject.toml
tests
uv.lockpip দিয়ে করতে চাইলে: ভার্চুয়াল এনভায়রনমেন্ট বানিয়ে চালু করুন, তারপর pip install "fastapi[standard]" pytest httpx2। এনভায়রনমেন্ট চালু থাকলে নিচের সব জায়গায় uv run pytest-এর বদলে শুধু pytest লিখলেই হবে।
httpx2 কেন?
TestClient আপনার অ্যাপে রিকোয়েস্ট পাঠায় একই Python প্রসেসের ভেতরে, একটা HTTP ক্লায়েন্ট লাইব্রেরি দিয়ে। আগে এর জন্য httpx লাগত। Starlette 1.7 (FastAPI-র নিচের লাইব্রেরি) এখন TestClient httpx-এর ওপর চললে সতর্কবার্তা দেয় এবং httpx2 পছন্দ করে। এটা httpx-এর রক্ষণাবেক্ষণ-করা ফর্ক, বানিয়েছে Pydantic টিম (ইমপোর্ট করলে httpx2 নামে, কিন্তু আপনাকে কখনো ইমপোর্ট করতে হয় না: TestClient নিজেই খুঁজে নেয়)। তাই নতুন প্রজেক্টের নিয়ম সহজ: pip install pytest httpx2। কোনো পুরোনো টিউটোরিয়াল httpx ইনস্টল করতে বললে সেটা এখনো মোটামুটি চলে, তবে ডেপ্রিকেশন সতর্কবার্তা আসতে পারে। নিজের কোডে অন্য API কল করার জন্য এই কোর্স requests-ই ব্যবহার করে; HTTP ক্লায়েন্টগুলোর তুলনা অ্যাডভান্সড টিউটোরিয়ালে।
যে কোড টেস্ট করবেন
দুটো ফাইল। প্রথমটায় আছে সাধারণ ফাংশন, ওয়েবের কোনো কোড ছাড়া: এগুলোই সবচেয়ে সহজে টেস্ট করা যায়। ভালো অভ্যাস হলো যুক্তি এমন ফাংশনে রাখা আর এন্ডপয়েন্ট পাতলা রাখা।
# file: app/chunking.py
"""Split text into pieces, the way RAG pipelines prepare documents."""
def estimate_tokens(text: str) -> int:
"""A rough rule of thumb: about 4 characters per token."""
return (len(text) + 3) // 4
def chunk_text(text: str, size: int) -> list[str]:
"""Cut text into chunks of at most `size` characters."""
if size < 1:
raise ValueError("size must be at least 1")
return [text[i:i + size] for i in range(0, len(text), size)]দ্বিতীয়টা API। প্রতিটা রিকোয়েস্টের জন্য একটা মডেল আছে, লেখার আকারে সীমা আছে, আর অচেনা মডেলের নামে 404 আছে। এর সবই ভুল হতে পারে, তাই সবই টেস্ট করার যোগ্য:
# file: app/main.py
"""BitByte Text Tools: a tiny API for preparing text for LLMs."""
from typing import Annotated
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from app.chunking import chunk_text, estimate_tokens
app = FastAPI(title="BitByte Text Tools")
CONTEXT_WINDOWS = {"bitbyte-mini": 8000, "bitbyte-pro": 32000}
class CountRequest(BaseModel):
text: Annotated[str, Field(min_length=1, max_length=1000)]
class ChunkRequest(BaseModel):
text: Annotated[str, Field(min_length=1, max_length=5000)]
size: Annotated[int, Field(ge=10, le=500)] = 100
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/count")
def count(body: CountRequest):
return {
"characters": len(body.text),
"words": len(body.text.split()),
"estimated_tokens": estimate_tokens(body.text),
}
@app.post("/chunk")
def chunk(body: ChunkRequest):
chunks = chunk_text(body.text, body.size)
return {"count": len(chunks), "chunks": chunks}
@app.get("/models/{name}")
def model_info(name: str):
if name not in CONTEXT_WINDOWS:
raise HTTPException(status_code=404, detail=f"Unknown model {name!r}")
return {"name": name, "context_window": CONTEXT_WINDOWS[name]}টেস্ট কেন, আর স্বয়ংক্রিয় কেন
হাতে পরীক্ষার নিজস্ব মূল্য আছে: আপনি অ্যাপটা ব্যবহারকারীর চোখে দেখেন। তবু দুটোর তুলনা করুন:
| হাতে পরীক্ষা | স্বয়ংক্রিয় টেস্ট | |
|---|---|---|
| গতি | প্রতি রাউন্ডে কয়েক মিনিট | পুরো সুইট কয়েক সেকেন্ডে |
| পুনরাবৃত্তি | আজ আপনি যতটা সতর্ক, ততটাই | প্রতিবার হুবহু একই |
| পুরোনো বাগ ফিরে এলে ধরা | তাকাতে মনে থাকলে তবেই | হ্যাঁ, প্রতি রানে (একে বলে রিগ্রেশন টেস্ট) |
| নির্ভয়ে কোড বদলানো | না: প্রতি পরিবর্তনে পুরোটা আবার দেখতে হয় | হ্যাঁ: সবুজ মানে পুরোনো আচরণ ঠিক আছে |
| আচরণের দলিল | না | হ্যাঁ: টেস্ট এমন উদাহরণ যা কখনো পুরোনো হয় না |
AI প্রোডাক্টে এটা আরও বেশি জরুরি। মডেল, প্রম্পট ও ডেটা ঘনঘন বদলায়, আর প্রতিটা বদল তার চারপাশের সাধারণ কোড ভেঙে দিতে পারে: JSON-এর আকার, সীমা, এরর কোড। টেস্ট এই নির্ধারিত (deterministic) অংশটা সামলায়, ফলে কিছু গোলমাল হলে বোঝা যায় দোষটা মডেলের না আপনার প্লাম্বিংয়ের। (মডেলের উত্তরের মান পরীক্ষা আলাদা কাজ, যাকে বলে ইভ্যালুয়েশন; AI পথে পরে আসবে।) দুই উপায়ই ব্যবহার করুন: খোঁজার সময় হাতে দেখুন, তারপর যা চলতে থাকা দরকার তার জন্য টেস্ট লিখুন।
pytest-এর মূল কথা
টেস্ট হলো এমন একটা ফাংশন, যার নাম test_ দিয়ে শুরু। ভেতরে কিছু কোড চালিয়ে সাধারণ assert ব্যবহার করেন: শর্ত মিথ্যা হলে টেস্ট ফেইল। দুটো ফাংশনের প্রথম টেস্ট, tests/test_chunking.py-তে:
# file: tests/test_chunking.py
import pytest
from app.chunking import chunk_text, estimate_tokens
def test_estimate_tokens():
assert estimate_tokens("hello world") == 3
def test_chunk_text_splits_evenly():
assert chunk_text("abcdef", 3) == ["abc", "def"]
def test_chunk_text_keeps_the_remainder():
assert chunk_text("abcdefg", 3) == ["abc", "def", "g"]
def test_chunk_text_rejects_size_zero():
with pytest.raises(ValueError):
chunk_text("abc", 0)দুটো খুঁটিনাটি। pytest.raises(ValueError) তখনই পাস করে যখন ভেতরের কোড ওই এরর তোলে; "এটা ব্যর্থ হতেই হবে" টেস্ট করার উপায় এটাই। আর from app.chunking import ...-এর মতো ইমপোর্টের জন্য pytest-কে জানতে হয় আপনার প্রজেক্টের রুট কোথায়। pyproject.toml-এ সামান্য কনফিগারেশনেই তা হয় (pythonpath); addopts লাইনটা প্রতিটা রানে অপশন জুড়ে দেয়, এখানে ভার্সন হেডার লুকায় আর আউটপুট পরিষ্কার রাখতে .pytest_cache ফোল্ডার বন্ধ করে। দুটোই যোগ করে pytest চালান:
cat >> pyproject.toml <<'EOF'
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "--no-header -p no:cacheprovider"
EOF
uv run pytest======================================= test session starts ========================================
collected 4 items
tests/test_chunking.py .... [100%]
======================================== 4 passed in 0.01s =========================================এটাই একটা টেস্ট সেশন। pytest টেস্টগুলো নিজেই খুঁজে নিয়েছে (discovery): প্রথমে test_*.py (বা *_test.py) নামের ফাইল, তারপর ভেতরের test_* নামের ফাংশন। কিছু রেজিস্টার করতে হয়নি। প্রতিটা বিন্দু একটা পাস করা টেস্ট। যে ফ্ল্যাগগুলো প্রতিদিন কাজে লাগবে:
uv run pytest -q
uv run pytest -v
uv run pytest -q -k remainder.... [100%]
4 passed in 0.01s
======================================= test session starts ========================================
collecting ... collected 4 items
tests/test_chunking.py::test_estimate_tokens PASSED [ 25%]
tests/test_chunking.py::test_chunk_text_splits_evenly PASSED [ 50%]
tests/test_chunking.py::test_chunk_text_keeps_the_remainder PASSED [ 75%]
tests/test_chunking.py::test_chunk_text_rejects_size_zero PASSED [100%]
======================================== 4 passed in 0.01s =========================================
. [100%]
1 passed, 3 deselected in 0.01s| ফ্ল্যাগ | কী করে |
|---|---|
-q | Quiet: বিন্দু আর এক লাইনের সারাংশ |
-v | Verbose: প্রতিটা টেস্টের নামসহ এক লাইন করে |
-k text | শুধু সেই টেস্ট চালায় যার নামে text আছে (-k "chunk and not zero"-ও চলে) |
-x | প্রথম ফেইলেই থামে |
file.py::test_name | একটা ফাইল বা একটা টেস্ট চালায় |
TestClient দিয়ে API টেস্ট
TestClient আপনার FastAPI অ্যাপকে মুড়ে নেয়। requests-এর মতোই client.get(...) বা client.post(...) ডাকেন, আর রিকোয়েস্ট সোজা অ্যাপের ভেতরে যায়: সার্ভার নেই, পোর্ট নেই, নেটওয়ার্ক নেই। তাই টেস্ট এত দ্রুত, আর আপনাকে কিছুই চালু করতে হয় না। সফল রেসপন্সের প্রথম টেস্ট ফাইল:
# file: tests/test_api.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_count_returns_characters_words_and_tokens():
response = client.post("/count", json={"text": "Tokens are small pieces of text"})
assert response.status_code == 200
assert response.json() == {"characters": 31, "words": 6, "estimated_tokens": 8}
def test_known_model_has_a_context_window():
response = client.get("/models/bitbyte-mini")
assert response.status_code == 200
assert response.json()["context_window"] == 8000uv run pytest -q....... [100%]
7 passed in 0.50sভালো API টেস্টের তিন ধাপ: arrange (ইনপুট বানান), act (এন্ডপয়েন্ট ডাকুন), assert (স্ট্যাটাস কোড এবং বডি মেলান)। আগে স্ট্যাটাস দেখুন; অনেক বাগ সেখানেই ধরা পড়ে। পারলে পুরো বডি একসঙ্গে মেলান (== {...}); তাহলে যে ফিল্ড থাকার কথা না, সেটাও ধরা পড়ে।
এরর ও এজ কেস টেস্ট
সফল পথটা সহজ অর্ধেক। যে টেস্ট আপনাকে বাঁচায় সেগুলো খারাপ ইনপুটের, কারণ ব্যবহারকারী, স্ক্রিপ্ট আর LLM সবাই খারাপ ইনপুট পাঠায়। এরর কেসের জন্য দ্বিতীয় একটা ফাইল:
# file: tests/test_api_errors.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_unknown_model_is_404():
response = client.get("/models/gpt-nothing")
assert response.status_code == 404
assert response.json() == {"detail": "Unknown model 'gpt-nothing'"}
def test_count_without_text_is_422():
response = client.post("/count", json={})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["type"] == "missing"
assert error["loc"] == ["body", "text"]
def test_count_with_empty_text_is_422():
response = client.post("/count", json={"text": ""})
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "string_too_short"
def test_count_accepts_exactly_the_maximum_length():
assert client.post("/count", json={"text": "a" * 1000}).status_code == 200
assert client.post("/count", json={"text": "a" * 1001}).status_code == 422
def test_chunk_uses_the_default_size():
response = client.post("/chunk", json={"text": "x" * 250})
assert response.status_code == 200
assert response.json()["count"] == 3
@pytest.mark.parametrize("size", [0, 9, 501, -5])
def test_chunk_rejects_sizes_outside_the_range(size):
response = client.post("/chunk", json={"text": "some text", "size": size})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "size"]
def test_chunk_rejects_a_text_that_is_not_a_string():
response = client.post("/chunk", json={"text": 12345})
assert response.status_code == 422uv run pytest -q................. [100%]
17 passed in 0.54sএই টেস্টগুলো যা শেখায়:
- 422 টেস্টে এরর বডির
typeওlocমেলান, লম্বাmsgনয়, কারণ ওই দুটোই স্থির অংশ (আগের পাতা দেখুন)। - সীমানা-তেই বাগ লুকায়। ১০০০ ও ১০০১ অক্ষরের টেস্ট সীমার দুই পাশই দেখে: সবচেয়ে বড় যে মান চলতেই হবে, আর সবচেয়ে ছোট যে মান চলবে না। আপনার লেখা প্রতিটা
ge,le,min_lengthওmax_length-এর জন্য এটা করুন। @pytest.mark.parametrizeতালিকার প্রতিটা মানের জন্য একই টেস্ট ফাংশন একবার করে চালায়, তাই চারটা খারাপ সাইজের জন্য চারটার বদলে একটাই ফাংশন লাগে। রিপোর্টে এগুলো চারটা টেস্ট হিসেবেই গোনা হয়।- 404 টেস্টেও বডি মেলান, যাতে ভুল বার্তা চুপচাপ ঢুকে পড়তে না পারে।
ফেইল করা টেস্ট পড়া
যে সুইট কখনো ফেইল করে না তা কিছু শেখায় না; আসল পরীক্ষা ফেইলের মুহূর্তে। ইচ্ছে করে দুটো ভাঙা টেস্ট নিয়ে একটা ফাইল বানান। প্রথমটায় প্রত্যাশা ভুল (লেখাটা ৩১ অক্ষরের, তাই উত্তর ৮ টোকেন, ৭ নয়)। দ্বিতীয়টায় কোড নিজেই এরর তোলে। || true শুধু শেল ব্লককে ফেইলের কারণে থামতে দেয় না; pytest ঠিকই ফেইল জানায়:
# file: tests/test_demo.py
from app.chunking import chunk_text, estimate_tokens
def test_estimate_tokens_for_a_sentence():
text = "Tokens are small pieces of text"
assert estimate_tokens(text) == 7
def test_chunk_with_zero_size_is_not_handled():
assert chunk_text("abc", 0) == []uv run pytest -q -k sentence || trueF [100%]
============================================= FAILURES =============================================
_______________________________ test_estimate_tokens_for_a_sentence ________________________________
def test_estimate_tokens_for_a_sentence():
text = "Tokens are small pieces of text"
> assert estimate_tokens(text) == 7
E AssertionError: assert 8 == 7
E + where 8 = estimate_tokens('Tokens are small pieces of text')
tests/test_demo.py:7: AssertionError
===================================== short test summary info ======================================
FAILED tests/test_demo.py::test_estimate_tokens_for_a_sentence - AssertionError: assert 8 == 7
1 failed, 18 deselected in 0.58sফেইল পড়তে হয় নিচ থেকে উপরে:
- শেষ লাইনগুলো (
FAILED tests/test_demo.py::...) এক লাইনে বলে কোন টেস্ট কেন ফেইল করেছে। >চিহ্নিত লাইন হলো টেস্ট ফাংশনের ভেতরের যে স্টেটমেন্ট ফেইল করেছে।Eচিহ্নিত লাইন হলো ব্যাখ্যা। pytest আপনারassertনতুন করে সাজিয়ে আসল মানগুলো দেখায়:assert 8 == 7, আর তার নিচে ৮ এসেছেestimate_tokens('Tokens are small pieces of text')থেকে।printপ্রায় লাগেই না।
এবার দ্বিতীয় টেস্ট, যেখানে কোড নিজেই এরর তোলে। ছোট ট্রেসব্যাক চাইতে --tb=short দিন:
uv run pytest -q -k not_handled --tb=short || trueF [100%]
============================================= FAILURES =============================================
_____________________________ test_chunk_with_zero_size_is_not_handled _____________________________
tests/test_demo.py:11: in test_chunk_with_zero_size_is_not_handled
assert chunk_text("abc", 0) == []
^^^^^^^^^^^^^^^^^^^^
app/chunking.py:13: in chunk_text
raise ValueError("size must be at least 1")
E ValueError: size must be at least 1
===================================== short test summary info ======================================
FAILED tests/test_demo.py::test_chunk_with_zero_size_is_not_handled - ValueError: size must be at...
1 failed, 18 deselected in 0.74s
এখানে ভুল মান মেলানো হয়নি; ফাংশনটা এক্সেপশন তুলেছে। শিরোনামের নিচের অংশটা একটা ট্রেসব্যাক, আর ট্রেসব্যাক পড়ার নিয়ম সবসময় এক: আগে শেষ লাইন (এররের ধরন ও বার্তা: ValueError: size must be at least 1), তারপর স্ট্যাক বেয়ে উপরে উঠে আপনার ফাইলের লাইনগুলো দেখুন (tests/test_demo.py ডেকেছে app/chunking.py-র chunk_text)। লাইব্রেরির ফ্রেম থেকে কারণ আসে কম। এখানে কোড বদলানো ঠিক নয়, কারণ size ০ প্রত্যাখ্যান করাই সঠিক; ভুল ছিল টেস্টের, আর শুদ্ধ রূপ আপনার আগের pytest.raises টেস্টটাই। ডেমো ফাইল মুছে ফেলুন:
rm tests/test_demo.py
uv run pytest -q................. [100%]
17 passed in 0.56sকোনো টেস্ট ফেইল করলে এই ক্রমে ভাবুন: ভুল কি টেস্টের, কোডের, নাকি চাহিদাটাই অস্পষ্ট? টেস্ট সবুজ করতে শুধু তাকে বদলাতে থাকবেন না।
এক্সেপশন ডিবাগ করা
ট্রেসব্যাক হলো ক্র্যাশ করা প্রোগ্রামের নিজস্ব Python-রিপোর্ট, আর pytest-এর বাইরেও এর সঙ্গে দেখা হবে। একটা চেনা বাগসহ ছোট স্ক্রিপ্ট:
cat > average.py <<'EOF'
def average_words(texts):
total = sum(len(t.split()) for t in texts)
return total / len(texts)
print(average_words(["one two", "three four five"]))
print(average_words([]))
EOF
uv run python average.py || true2.5
Traceback (most recent call last):
File "/home/nadia/projects/bitbyte-text-tools/average.py", line 7, in <module>
print(average_words([]))
^^^^^^^^^^^^^^^^^
File "/home/nadia/projects/bitbyte-text-tools/average.py", line 3, in average_words
return total / len(texts)
~~~~~~^~~~~~~~~~~~
ZeroDivisionError: division by zeroশেষ লাইনে সমস্যার নাম (ZeroDivisionError), তার ওপরের লাইন ঠিক কোন কোডের দিকে আঙুল তোলে, আর প্রথম File লাইন দেখায় কোন কল থেকে সব শুরু। খালি লিস্ট ঠিক এমন এজ কেস, যা টেস্টে থাকা উচিত। কারণ তবু স্পষ্ট না হলে প্রোগ্রামটাকে সমস্যার ভেতরেই থামিয়ে চারপাশ দেখুন। Python-এর বিল্ট-ইন উপায় breakpoint(): কোডে বসান, প্রোগ্রাম চালান, আর (Pdb) প্রম্পট পাবেন, যেখানে ভেরিয়েবল ছাপতে (p total), পরের লাইনে যেতে (n), চালিয়ে যেতে (c) বা বেরিয়ে আসতে (q) পারেন:
cat > stop_here.py <<'EOF'
texts = ["one two", "three four five"]
total = sum(len(t.split()) for t in texts)
breakpoint()
print(total / len(texts))
EOF
printf 'p total\np len(texts)\nc\n' | uv run python stop_here.py> /home/nadia/projects/bitbyte-text-tools/stop_here.py(4)<module>()
-> print(total / len(texts))
(Pdb) 5
(Pdb) 2
(Pdb) 2.5এখানে কমান্ডগুলো printf থেকে পাঠানো হয়েছে যাতে পাতাটা বারবার একই ফল দেয়; নিজের টার্মিনালে আপনি প্রম্পটে টাইপ করবেন। pytest-এর সঙ্গে pytest --pdb কোনো টেস্ট ফেইল করার ঠিক মুহূর্তে একই প্রম্পট খুলে দেয়। কমিটের আগে প্রতিটা breakpoint() মুছে ফেলতে ভুলবেন না। VS Code ও PyCharm-এর মতো এডিটরে মার্জিনে একটা ক্লিক করেই একই কাজ হয়।
টেস্ট সাজানো
সুইট বড় হলে তিনটা অভ্যাস সবকিছু সামলে রাখে:
- টেস্ট রাখুন
tests/ফোল্ডারে, যা আপনার কোডের কাঠামো অনুসরণ করে:app/chunking.py-র জন্যtests/test_chunking.py,app/main.py-র জন্যtests/test_api.py। - একটা টেস্টে একটা আচরণ, আর নাম এমন যা বাক্যের মতো পড়া যায় (
test_unknown_model_is_404), যাতে ফাইল না খুলেই ফেইল দেখে বোঝা যায় কী ভেঙেছে। - সেট-আপ বারবার লিখবেন না। ফিক্সচার (fixture) ঠিক এই কাজের জন্য।
ফিক্সচার হলো @pytest.fixture দিয়ে চিহ্নিত একটা ফাংশন, যা টেস্টের দরকারি কিছু জোগায়। টেস্ট প্যারামিটার হিসেবে ফিক্সচারের নাম লিখে সেটা চায়, আর pytest ফাংশনটা চালিয়ে ফল টেস্টে পাঠিয়ে দেয়। conftest.py নামের ফাইলে লেখা ফিক্সচার ওই ফোল্ডারের সব টেস্টে কোনো ইমপোর্ট ছাড়াই পাওয়া যায়। ওপরের দুটো API টেস্ট ফাইলই একই client বানায়; সেটা এখানে সরিয়ে আনুন:
# file: tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""A fresh TestClient for each test."""
return TestClient(app)নতুন টেস্ট ফাইলকে আর নিজের ক্লায়েন্ট বানাতে হয় না; শুধু চাইলেই হয়:
# file: tests/test_models.py
def test_pro_model_has_a_bigger_context_window(client):
small = client.get("/models/bitbyte-mini").json()["context_window"]
large = client.get("/models/bitbyte-pro").json()["context_window"]
assert large > small
def test_model_names_are_case_sensitive(client):
assert client.get("/models/BitByte-Mini").status_code == 404uv run pytest -q
uv run pytest -q tests/test_models.py
ls -A tests................... [100%]
19 passed in 0.07s
.. [100%]
2 passed in 0.01s
conftest.py
test_api.py
test_api_errors.py
test_chunking.py
test_models.pyফিক্সচার ডেটাও বানাতে পারে, টেস্ট ডেটাবেস খুলতে পারে বা এনভায়রনমেন্ট ভেরিয়েবল বসাতে পারে, আর শেষে সব গুছিয়ে দেয়; স্কোপ, ফ্যাক্টরি, ডিপেন্ডেন্সি ওভাররাইড আর বাইরের API মক করা অ্যাডভান্সড টিউটোরিয়ালের টেস্টিং পাতায় আছে। প্রজেক্টের কাঠামো এখন:
bitbyte-text-tools/
├── app/
│ ├── __init__.py
│ ├── chunking.py plain functions: easy to test
│ └── main.py the API
├── tests/
│ ├── conftest.py shared fixtures (client)
│ ├── test_api.py
│ ├── test_api_errors.py
│ ├── test_chunking.py
│ └── test_models.py
├── pyproject.toml dependencies and the [tool.pytest.ini_options] settings
└── uv.lockআসল AI কাজে এটা কোথায় দেখবেন
- পুল রিকোয়েস্ট ও CI: টেস্ট পাস করলে তবেই টিম মার্জ করে; অ্যাডভান্সড টিউটোরিয়ালে প্রতিটা পুশে GitHub Actions-এ
pytestচলবে। - প্রেডিকশন এন্ডপয়েন্ট: টেস্ট জানা ফিচার সারি পাঠিয়ে রেসপন্সের আকার, পরিসর ও এরর কোড মেলায়, একটা ছোট নকল মডেল দিয়ে যাতে টেস্ট মিলিসেকেন্ডে চলে।
- LLM ব্যাকএন্ড: টেস্টে আসল প্রোভাইডারের জায়গায় নকল প্রোভাইডার বসে, তাই খরচ নেই, রেট লিমিটে পড়ার ভয় নেই আর উত্তর সবসময় এক। শুধু প্লাম্বিং টেস্ট হয়, CI-তে ঠিক এটাই চাই।
- ডেটা পাইপলাইন:
chunk_text-এর মতো চাঙ্কিং, ক্লিনিং ও পার্সিং ফাংশনের জন্য ডজন ডজন এজ কেস টেস্ট (খালি লেখা, চাঙ্কের চেয়ে ছোট লেখা, বাংলা লেখা) থাকে, কারণ ওখানকার একটা নীরব বাগ পরের প্রতিটা ধাপ নষ্ট করে।
সাধারণ ভুল
- ফাইল বা ফাংশনের নাম ভুল দেওয়া।
check_api.pyবাdef api_test()কখনো টেস্ট হিসেবে ধরা পড়ে না, ফলে কিছুই চলে না (pytest "no tests ran" ছাপে আর এক্সিট কোড 5 দিয়ে শেষ হয়, যা CI ব্যর্থ বলে ধরে, কিন্তু চোখ বুলিয়ে গেলে মানুষ খেয়াল নাও করতে পারে)। সমাধান:test_*.pyফাইল,test_*ফাংশন, আর সারাংশ লাইনের সংখ্যাটা দেখুন। - শুধু সফল পথ টেস্ট করা। 200 তো সেই ক্ষেত্র যা হাতে চেষ্টা করেই আপনি ঠিক দেখেছেন। সমাধান: প্রতিটা এন্ডপয়েন্টে অন্তত একটা 404 বা 422 টেস্ট আর সীমানার মান যোগ করুন।
- কাজের কিছু assert না করা। যে টেস্ট শুধু এন্ডপয়েন্ট ডাকে, বা
status_code == 200মেলায় কিন্তু বডি দেখে না, উত্তর ভুল হলেও পাস করতে পারে। সমাধান: স্ট্যাটাস ও কনটেন্ট দুটোই assert করুন। - একে অপরের বা ক্রমের ওপর নির্ভর করা টেস্ট। যে টেস্টের জন্য আগে অন্য টেস্টকে ডেটা বানাতে হয়,
-kদিয়ে একা চালালেই সে ভাঙে। সমাধান: প্রতিটা টেস্ট নিজের দরকারি জিনিস নিজে বানাবে (ফিক্সচার কাজে লাগে)। - সবুজ না হওয়া পর্যন্ত টেস্ট বদলানো। এতে আসল বাগ চাপা পড়ে। সমাধান: আগে ঠিক করুন টেস্ট, কোড না চাহিদা, কোনটা ভুল।
ModuleNotFoundError: No module named 'app'। pytest আপনার প্রজেক্টের রুট খুঁজে পাচ্ছে না। সমাধান:pyproject.toml-এpythonpath = ["."], আর প্রজেক্ট ফোল্ডার থেকে pytest চালান।
নিজে চেষ্টা করুন
- সহজ:
tests/test_exercise_easy.pyলিখুন, যাতেestimate_tokens("")-এর টেস্ট থাকবে (কী ফেরত দেওয়া উচিত?) আর ৪ অক্ষর ও ৫ অক্ষরের লেখার একটা টেস্ট থাকবে।-kদিয়ে শুধু এগুলো চালান। - মাঝারি:
clientফিক্সচার ব্যবহার করে এমন টেস্ট যোগ করুন, যা দেখাবেPOST /chunkবডি{"text": "abcdefghijkl", "size": 10}হলে 200 আসে, চাঙ্ক["abcdefghij", "kl"]আর count ২। সঙ্গে একটা parametrize করা টেস্ট লিখুন, যা/count-এ তিন রকম অবৈধ বডি পাঠায় (কী নেই, লেখার বদলে সংখ্যা, আরnull) এবং প্রতিটায় 422 মেলায়। - কঠিন: নিচের ফাংশনটা লেখার শেষ
nটা শব্দ ফেরত দেওয়ার কথা, কিন্তু এতে একটা বাগ আছে। বাগটা ধরে এমন টেস্ট লিখুন,n=0আর শব্দসংখ্যার চেয়ে বড়n-এর এজ কেসসহ, তারপর ফাংশনটা ঠিক করুন।def last_words(text, n): return text.split()[-n:]
উত্তর
# file: tests/test_exercise_easy.py
from app.chunking import estimate_tokens
def test_empty_text_has_zero_tokens():
assert estimate_tokens("") == 0
def test_four_and_five_characters():
assert estimate_tokens("abcd") == 1
assert estimate_tokens("abcde") == 2uv run pytest -q -k "empty or characters".... [100%]
4 passed, 17 deselected in 0.02s# file: tests/test_exercise_medium.py
import pytest
def test_chunk_returns_chunks_and_count(client):
response = client.post("/chunk", json={"text": "abcdefghijkl", "size": 10})
assert response.status_code == 200
assert response.json() == {"count": 2, "chunks": ["abcdefghij", "kl"]}
@pytest.mark.parametrize("body", [{}, {"text": 42}, {"text": None}])
def test_count_rejects_invalid_bodies(client, body):
assert client.post("/count", json=body).status_code == 422uv run pytest -q tests/test_exercise_medium.py.... [100%]
4 passed in 0.02sকঠিন অনুশীলনীর ব্যাখ্যা: text.split()[-0:] আর text.split()[0:] একই জিনিস, তাই n=0 দিলে কোনো শব্দ না এসে সব শব্দ ফেরত আসে। (বড় n ঠিকই আছে: স্লাইসিং কখনো এরর দেয় না।) নিচের টেস্টগুলো বাগটা ধরে; ঠিক করা ফাংশন এজ কেস সামলায়। আসল ফাংশনে test_last_words_zero_returns_nothing ফেইল করত assert ['one', 'two', 'three'] == [] বার্তা নিয়ে। আগে এজ কেসের টেস্ট লেখা, তাকে ফেইল করতে দেখা, তারপর কোড ঠিক করা: এটাই "টেস্ট-ড্রিভেন ডেভেলপমেন্ট"-এর অভ্যাস।
# file: last_words.py
def last_words(text, n):
if n <= 0:
return []
return text.split()[-n:]# file: tests/test_exercise_hard.py
from last_words import last_words
def test_last_words_normal():
assert last_words("one two three four", 2) == ["three", "four"]
def test_last_words_zero_returns_nothing():
assert last_words("one two three", 0) == []
def test_last_words_more_than_available_returns_all():
assert last_words("one two", 5) == ["one", "two"]uv run pytest -q tests/test_exercise_hard.py... [100%]
3 passed in 0.01s
from last_words import last_words ইমপোর্ট কাজ করে কারণ pythonpath = ["."] প্রজেক্ট ফোল্ডারকে পাথে রাখে।
সারসংক্ষেপ
- স্বয়ংক্রিয় টেস্ট প্রতিবার কয়েক সেকেন্ডে আপনার কোড পরীক্ষা করে; যে আচরণ ধরে রাখতে চান তার প্রতিটার জন্য, আর সারানো প্রতিটা বাগের জন্য একটা টেস্ট লিখুন।
- pytest
test_*.pyফাইল ওtest_*ফাংশন খুঁজে নেয়, সাধারণassertব্যবহার করে, আর কাজের ফ্ল্যাগ আছে:-q,-v,-k,-x। TestClient(app)সার্ভার ছাড়াই আপনার API ডাকে।pytestওhttpx2ইনস্টল করুন (Starlette 1.7 সাধারণhttpxনিয়ে সতর্ক করে)।- সফল পথ, 422 ও 404 এরর (
typeওlocমেলান) আর প্রতিটা সীমার দুই পাশের সীমানা টেস্ট করুন। - ফেইল পড়ুন নিচ থেকে:
Eলাইন বলে কী আলাদা,>লাইন বলে কোথায়, আর ট্রেসব্যাকের শেষ লাইন এররের নাম বলে। ভেতরে উঁকি দিতেbreakpoint()বা--pdbব্যবহার করুন। - টেস্ট রাখুন
tests/-এ, আর সেট-আপ ভাগ করুনconftest.py-এর ফিক্সচারে।
এরপর: প্রজেক্ট, পর্ব ১: একটা ডেটা এক্সপ্লোরার API বানানো। এ পর্যন্ত শেখা সবকিছু, Git, requests, FastAPI, Pydantic ও pytest, একসঙ্গে জুড়ে একটা ছোট প্রজেক্ট বানাবেন।