Testing (pytest) ও Code Quality

Test, Ruff, type checker, pre-commit

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

AI এর যুগে test লেখা আগের চেয়েও জরুরি। আগে তুমি নিজে code লিখতে, তাই প্রতিটা লাইন জানতে। এখন AI ৫০ লাইন লিখে দেয় ১০ সেকেন্ডে। সেই code ঠিক কিনা প্রমাণ করার সবচেয়ে নির্ভরযোগ্য উপায় হলো test। আর পরে যখন AI কে বলবে "এই function টা দ্রুত করো", test বলে দেবে কিছু ভেঙেছে কিনা। এই অধ্যায়ে pytest দিয়ে test লেখা, আর Ruff, Pyright, pre-commit দিয়ে code এর মান ধরে রাখা শিখবো।

#প্রথম test

Terminal
uv add --dev pytest
Python
# src/shop/pricing.py
def apply_discount(price: int, percent: int) -> int:
    if not 0 <= percent <= 100:
        raise ValueError("percent ০ থেকে ১০০ এর মধ্যে হতে হবে")
    return price - price * percent // 100
Python
# tests/test_pricing.py
import pytest
from shop.pricing import apply_discount


def test_normal_discount():
    assert apply_discount(1000, 10) == 900


def test_zero_discount():
    assert apply_discount(1000, 0) == 1000


def test_full_discount():
    assert apply_discount(1000, 100) == 0


def test_invalid_percent_raises():
    with pytest.raises(ValueError, match="০ থেকে ১০০"):
        apply_discount(1000, 150)
Terminal
uv run pytest            # সব test
uv run pytest -v         # প্রতিটা test এর নাম সহ
uv run pytest -x         # প্রথম ব্যর্থ test এ থামো
uv run pytest -k discount   # নামে "discount" আছে এমন test

নিয়ম: test file এর নাম test_ দিয়ে শুরু, test function এর নামও test_ দিয়ে শুরু। pytest নিজেই খুঁজে নেয়।

#ভালো test এর গঠন: Arrange, Act, Assert

Python
def test_cart_total_with_discount():
    # Arrange: প্রস্তুতি
    cart = Cart()
    cart.add(Item("Burger", 25000), qty=2)
    cart.add(Item("Coke", 4000))

    # Act: আসল কাজ
    total = cart.total(coupon="EID10")

    # Assert: যাচাই
    assert total == 48600

প্রতিটা test একটা জিনিস যাচাই করবে, নাম দেখেই বোঝা যাবে কী যাচাই হচ্ছে। Test ব্যর্থ হলে নাম পড়েই বুঝবে কী ভেঙেছে।

#কী test করবে

শুধু "স্বাভাবিক" ক্ষেত্র না। Bug লুকিয়ে থাকে কিনারায়:

  • সাধারণ ক্ষেত্র: ১০০০ টাকায় ১০% ছাড়
  • সীমা (boundary): ০%, ১০০%, ঠিক ১০০০ টাকায় (যদি "১০০০ এর বেশি হলে ছাড়" নিয়ম থাকে)
  • খালি: খালি cart, খালি string, খালি list
  • ভুল input: ঋণাত্মক দাম, ১৫০% ছাড়, None
  • বড় সংখ্যা: ১০ লাখ item

#parametrize: একই test অনেক data দিয়ে

Python
@pytest.mark.parametrize(
    ("price", "percent", "expected"),
    [
        (1000, 10, 900),
        (1000, 0, 1000),
        (1000, 100, 0),
        (999, 33, 670),      # ভাগের পর পূর্ণসংখ্যা
        (1, 50, 1),          # ছোট সংখ্যায় rounding
    ],
)
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected

#Fixture: বারবার লাগা প্রস্তুতি

Python
# tests/conftest.py  ← এই file এর fixture সব test file পায়
import pytest
from shop.cart import Cart, Item


@pytest.fixture
def burger() -> Item:
    return Item("Burger", 25000)


@pytest.fixture
def cart_with_items(burger) -> Cart:          # fixture অন্য fixture ব্যবহার করতে পারে
    cart = Cart()
    cart.add(burger, qty=2)
    return cart


# tests/test_cart.py
def test_total(cart_with_items):
    assert cart_with_items.total() == 50000

def test_remove(cart_with_items, burger):
    cart_with_items.remove(burger)
    assert cart_with_items.total() == 0

প্রতিটা test নতুন fixture পায়, তাই এক test এর বদল আরেক test কে প্রভাবিত করে না।

#Cleanup সহ fixture

Python
@pytest.fixture
def temp_store(tmp_path):                 # tmp_path: pytest এর দেওয়া অস্থায়ী folder
    store = JsonStore(tmp_path / "data.json")
    yield store                            # test এখানে চলে
    store.close()                          # test শেষে পরিষ্কার

#Mock: বাইরের জিনিস নকল করা

যে function SMS পাঠায় বা payment gateway call করে, test চালালে আসল SMS যাবে না, আসল টাকা কাটবে না। তখন নকল জিনিস দাও। OOP অধ্যায়ের dependency injection এখানে কাজে লাগে:

Python
class FakeNotifier:
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, message: str) -> None:
        self.sent.append((to, message))       # পাঠায় না, শুধু মনে রাখে


def test_order_sends_confirmation():
    notifier = FakeNotifier()
    service = OrderService(notifier=notifier)

    service.place(Order(id=1, customer="01711111111"))

    assert notifier.sent == [("01711111111", "আপনার order নেওয়া হয়েছে")]

এভাবে নিজের নকল class বানানো সবচেয়ে পরিষ্কার। Dependency inject করা না গেলে unittest.mock বা pytest এর monkeypatch দিয়ে ভেতরের জিনিস বদলানো যায়:

Python
def test_weather_fallback(monkeypatch):
    def fake_get(*args, **kwargs):
        raise httpx.TimeoutException("timeout")

    monkeypatch.setattr(httpx.Client, "get", fake_get)
    assert get_temperature_or_default("Dhaka") == 30.0

#Async test

Terminal
uv add --dev pytest-asyncio
Python
import pytest

@pytest.mark.asyncio
async def test_fetch_user():
    user = await fetch_user(1)
    assert user["id"] == 1

FastAPI এর API test আমরা API Testing অধ্যায়ে দেখবো।

#Coverage: কতটা code test হলো

Terminal
uv add --dev pytest-cov
uv run pytest --cov=src --cov-report=term-missing
Text
Name                     Stmts   Miss  Cover   Missing
------------------------------------------------------
src/shop/pricing.py         12      0   100%
src/shop/cart.py            40      6    85%   51-56

Missing বলে কোন লাইন কোনো test এ চলেনি। ১০০% coverage লক্ষ্য না। লক্ষ্য হলো গুরুত্বপূর্ণ logic (টাকা, permission, status বদল) পুরোপুরি test করা।

#Code quality tool

#Ruff: format আর lint

Terminal
uv run ruff format .          # code সাজাও
uv run ruff check .           # সম্ভাব্য bug আর খারাপ অভ্যাস খোঁজো
uv run ruff check --fix .     # যেগুলো নিজে ঠিক করা যায়, ঠিক করো

pyproject.toml এ নিয়ম:

TOML
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "ASYNC", "S"]
# E,F: মৌলিক ভুল | I: import সাজানো | B: সাধারণ bug (mutable default ইত্যাদি)
# UP: পুরনো syntax আধুনিক করা | SIM: সহজ করা | ASYNC: async এর ভুল | S: security

#Pyright: type check

Terminal
uv run pyright

#pre-commit: commit এর আগে সব চেক

Terminal
uv add --dev pre-commit
YAML
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: ruff-format
        name: ruff format
        entry: uv run ruff format
        language: system
        types: [python]
      - id: ruff-check
        name: ruff check
        entry: uv run ruff check --fix
        language: system
        types: [python]
      - id: pyright
        name: pyright
        entry: uv run pyright
        language: system
        types: [python]
        pass_filenames: false
Terminal
uv run pre-commit install

এখন প্রতিটা git commit এর আগে এগুলো চলবে। ভুল থাকলে commit হবে না।

#Test আর AI: কাজের পদ্ধতি

#অনুশীলন

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

Fixture কী, আর কেন ব্যবহার করবে?

Fixture হলো test এর জন্য প্রস্তুতির function (যেমন একটা cart বানানো, অস্থায়ী database)। বারবার একই setup লেখা লাগে না, আর প্রতিটা test নতুন copy পায়, ফলে test গুলো একে অপরকে প্রভাবিত করে না।

SMS পাঠানো function test করার সময় আসল SMS এড়াবে কীভাবে?

Notifier কে বাইরে থেকে দেওয়ার (dependency injection) ব্যবস্থা রাখবে, আর test এ একটা নকল notifier দেবে যা শুধু মনে রাখে কী পাঠানো হতো। তারপর assert করবে ঠিক message ঠিক নম্বরে "পাঠানো" হয়েছে কিনা।

১০০% coverage কি নিশ্চিত করে code এ bug নেই?

না। Coverage শুধু বলে কোন লাইন চলেছে, ঠিক ফল দিয়েছে কিনা না। দুর্বল assert দিয়েও ১০০% coverage হয়। আর অনেক bug লুকিয়ে থাকে এমন input এ যা কেউ test করেনি।

`parametrize` এর সুবিধা কী?

একই test logic অনেকগুলো input/expected জোড়া দিয়ে চালানো যায়, প্রতিটার জন্য আলাদা function লিখতে হয় না। আর কোনটা ব্যর্থ হলো সেটা আলাদা করে দেখা যায়।

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