অধ্যায় 28

pytest দিয়ে টেস্ট লেখা

assert থেকে শুরু করে pytest, ব্যর্থতার রিপোর্ট পড়া, pytest.raises, parametrize, tmp_path আর fixture — আর কোন আকারের কোড পরীক্ষা করা সহজ।

37 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

এতদিন আমরা কোড «পরীক্ষা» করেছি একভাবেই: চালিয়ে দেখেছি, আর আউটপুটটা পড়ে ঠিক মনে হয়েছে কিনা ভেবেছি।

সেটা কাজ করে, একবার। সমস্যাটা শুরু হয় দ্বিতীয়বার।

পঞ্চবিংশ অধ্যায়ের প্রোগ্রামটায় TAX_RATE ছিল 0.15। ধরুন সেটা বদলে 0.18 করা হলো। প্রোগ্রামটা চলবে, সব সংখ্যা ছাপা হবে, কোথাও কোনো ত্রুটি আসবে না — আর আপনি টেরও পাবেন না কোনো কিছু বদলেছে কিনা, যদি না আগের আউটপুটটা আপনার মনে থাকে।

আর যা মনে রাখতে হয়, তা একসময় মনে থাকে না।

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 1) == 17.25
print("ok")
text
ok

assert একটা সহজ বাক্য: «এটা সত্য হওয়ার কথা»। সত্য হলে কিছুই ঘটে না; মিথ্যা হলে প্রোগ্রাম থেমে যায়।

python
def line_total(price, quantity):
    return round(price * quantity * 1.15, 2)


assert line_total(15.0, 3) == 51.0
print("ok")
text
AssertionError

এটাই ভিত্তি। এই অধ্যায়টা সেই ভিত্তির উপরে একটা টুল — pytest — যে এরকম শত শত দাবি চালায়, আর ব্যর্থ হলে কেন ব্যর্থ হলো তা বলে।

এই অধ্যায় শেষে আপনি পারবেন

  • একটা টেস্ট ফাইল লিখতে আর pytest চালাতে
  • একটা ব্যর্থ টেস্টের রিপোর্ট পড়ে সমস্যাটা খুঁজে বের করতে
  • pytest.raises দিয়ে ত্রুটি পরীক্ষা করতে
  • @pytest.mark.parametrize দিয়ে এক টেস্টে অনেক ক্ষেত্র চালাতে
  • tmp_path দিয়ে ফাইল-নির্ভর কোড পরীক্ষা করতে
  • বলতে কোন কোডটা পরীক্ষা করা সহজ আর কেন

পূর্বশর্ত: ডেটাক্লাস আর টাইপ হিন্ট।


প্রথম টেস্ট

দুটো নিয়ম, আর ব্যস: ফাইলের নাম শুরু হবে test_ দিয়ে, আর ফাংশনের নামও।

pricing.py:

python
TAX_RATE = 0.15


def line_total(price: float, quantity: int) -> float:
    if quantity < 1:
        raise ValueError(f"quantity must be at least 1: {quantity}")
    return round(price * quantity * (1 + TAX_RATE), 2)

test_pricing.py:

python
from pricing import line_total


def test_one_item():
    assert line_total(15.0, 1) == 17.25


def test_three_items():
    assert line_total(15.0, 3) == 51.75

তারপর pytest -q:

text
..                                                                       [100%]
2 passed in 0.01s

দুটো বিন্দু, দুটো টেস্ট। pytest নিজেই ফাইলগুলো খুঁজে নেয়, ফাংশনগুলো খুঁজে নেয়, আর চালায় — কিছু নিবন্ধন করতে হয় না।

ব্যর্থতার রিপোর্টটাই আসল জিনিস

একটা টেস্টে ইচ্ছে করে ভুল সংখ্যা বসালে:

text
F                                                                        [100%]
================================== FAILURES ===================================
______________________________ test_three_items _______________________________

    def test_three_items():
>       assert line_total(15.0, 3) == 51.0
E       assert 51.75 == 51.0
E        +  where 51.75 = line_total(15.0, 3)

test_pricing.py:5: AssertionError
=========================== short test summary info ===========================
FAILED test_pricing.py::test_three_items - assert 51.75 == 51.0
1 failed in 0.01s

সাধারণ assert দিয়ে আমরা পেয়েছিলাম কেবল AssertionError — একটা শব্দ, কোনো তথ্য ছাড়া। এখানে পাওয়া যাচ্ছে:

  • কোন টেস্টটা (test_three_items) আর কোন লাইনে (test_pricing.py:5)
  • > চিহ্ন দিয়ে ঠিক কোন দাবিটা ভেঙেছে
  • assert 51.75 == 51.0 — আসলে কী পাওয়া গেছে, আর কী আশা করা হয়েছিল
  • where 51.75 = line_total(15.0, 3) — সংখ্যাটা কোথা থেকে এলো

এটাকে বলে assertion introspection, আর এটাই pytest-এর মূল কারণ। অন্য অনেক ভাষায় এর জন্য assertEqual(a, b) ধরনের আলাদা মেথড শিখতে হয়; এখানে সাধারণ == লিখলেই চলে।

ত্রুটিও পরীক্ষা করতে হয়

চতুর্বিংশ অধ্যায়ে আমরা শিখেছি একটা ফাংশনের ভুল ইনপুটে raise করা উচিত। সেই আচরণটাও একটা প্রতিশ্রুতি, আর তাই পরীক্ষাযোগ্য:

python
import pytest

from pricing import line_total


def test_zero_is_rejected():
    with pytest.raises(ValueError):
        line_total(15.0, 0)


def test_message_names_the_value():
    with pytest.raises(ValueError, match="at least 1: -3"):
        line_total(15.0, -3)
text
..                                                                       [100%]
2 passed in 0.01s

pytest.raises ব্লকটা বলছে «এর ভিতরে এই ত্রুটিটা হওয়ার কথা»। না হলে টেস্ট ব্যর্থ:

text
F                                                                        [100%]
================================== FAILURES ===================================
____________________________ test_one_is_rejected _____________________________

    def test_one_is_rejected():
>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE <class 'ValueError'>

test_pricing.py:7: Failed
=========================== short test summary info ===========================
FAILED test_pricing.py::test_one_is_rejected - Failed: DID NOT RAISE <class '...
1 failed in 0.01s

match= অংশটা বার্তার ভিতরে ওই লেখাটা আছে কিনা দেখে। এটা লেখা মূল্যবান, কারণ চতুর্বিংশ অধ্যায়ের নিয়মটা — যে মানটা সমস্যা করেছে সেটা বার্তায় রাখুন — তখন নিজেই একটা পরীক্ষিত প্রতিশ্রুতি হয়ে যায়।

একই টেস্ট, অনেক ক্ষেত্র

চারটে ক্ষেত্রের জন্য চারটে ফাংশন লেখা একঘেয়ে, আর কপি-পেস্টের ভুল ডেকে আনে।

python
import pytest

from pricing import line_total


@pytest.mark.parametrize(
    "price, quantity, expected",
    [
        (15.0, 1, 17.25),
        (15.0, 3, 51.75),
        (0.0, 5, 0.0),
        (100.0, 2, 230.0),
    ],
)
def test_line_total(price, quantity, expected):
    assert line_total(price, quantity) == expected

pytest -v দিলে:

text
============================= test session starts =============================
collecting ... collected 4 items

test_pricing.py::test_line_total[15.0-1-17.25] PASSED                    [ 25%]
test_pricing.py::test_line_total[15.0-3-51.75] PASSED                    [ 50%]
test_pricing.py::test_line_total[0.0-5-0.0] PASSED                       [ 75%]
test_pricing.py::test_line_total[100.0-2-230.0] PASSED                   [100%]

চারটে আলাদা টেস্ট, একটা ফাংশন থেকে। আর প্রতিটার নামে তার মানগুলো বসে আছে, তাই কোনটা ব্যর্থ হলে সাথে সাথেই বোঝা যায় — লুপ দিয়ে লিখলে প্রথম ব্যর্থতাতেই বাকিগুলো থেমে যেত আর কোন মানটা দোষী তাও বলা যেত না।

একটা ভুল থাকলে রিপোর্টটা মানগুলোও দেখায়:

text
________________________ test_line_total[15.0-3-51.0] _________________________

price = 15.0, quantity = 3, expected = 51.0

ফাইল লাগলে — tmp_path

তেইশতম অধ্যায়ের কোড পরীক্ষা করতে একটা ফাইল লাগে। সেটা রেপোজিটরিতে রেখে দেওয়া খারাপ অভ্যাস: টেস্টগুলো তখন একে অপরের সাথে জড়িয়ে যায়, আর একটা টেস্ট ফাইলটা বদলালে অন্যটা ভাঙে।

pytest প্রতিটা টেস্টকে তার নিজের একটা খালি ফোল্ডার দিতে পারে:

python
from reader import read_names


def test_blank_lines_are_skipped(tmp_path):
    path = tmp_path / "names.txt"
    path.write_text("one\n\ntwo\n", encoding="utf-8")

    assert read_names(path) == ["one", "two"]


def test_empty_file_gives_empty_list(tmp_path):
    path = tmp_path / "names.txt"
    path.write_text("", encoding="utf-8")

    assert read_names(path) == []
text
..                                                                       [100%]
2 passed in 0.01s

tmp_path একটা pathlib.Path — তেইশতম অধ্যায়ের সেই Path, আর / দিয়ে পথ জোড়াও সেখানকারই। প্যারামিটারের নামটাই pytest-কে বলে দেয় কী দিতে হবে, আর প্রতিটা টেস্ট একটা নতুন ফোল্ডার পায় — তাই দুটো টেস্টে একই নামের ফাইল থাকলেও তারা আলাদা।

পুনরাবৃত্ত সাজানো — fixture

একই ইনপুট কয়েকটা টেস্টে লাগলে সেটা একবার লিখে নাম দেওয়া যায়:

python
import pytest

from pricing import order_total


@pytest.fixture
def order():
    return [("pen", 15.0, 3), ("bag", 850.0, 1)]


def test_total(order):
    assert order_total(order) == 1029.25


def test_one_line_removed(order):
    assert order_total(order[:1]) == 51.75
text
..                                                                       [100%]
2 passed in 0.01s

tmp_path-এর মতোই: প্যারামিটারের নাম দেখে pytest মিলিয়ে দেয়। আর ফাংশনটা প্রতিটা টেস্টের জন্য আবার চলে, তাই একটা টেস্ট লিস্টটা বদলালে পরেরটা তবু একটা তাজা লিস্ট পায় — একবিংশ অধ্যায়ের ভাগ করা অবস্থার সমস্যাটা এখানে নকশা দিয়েই এড়ানো।

ভাসমান সংখ্যার ফাঁদ

python
def test_addition():
    assert 0.1 + 0.2 == 0.3
text
F                                                                        [100%]
================================== FAILURES ===================================
________________________________ test_addition ________________________________

    def test_addition():
>       assert 0.1 + 0.2 == 0.3
E       assert (0.1 + 0.2) == 0.3

test_money.py:2: AssertionError

পঞ্চম অধ্যায়ের সেই পুরোনো ব্যাপারটা, আর টেস্টে সেটা প্রায়ই প্রথমবার কামড়ায়।

python
import pytest


def test_addition():
    assert 0.1 + 0.2 == pytest.approx(0.3)
text
.                                                                        [100%]
1 passed in 0.01s

pytest.approx বলে «প্রায় সমান হলেই চলবে»। ভাসমান সংখ্যা নিয়ে যেকোনো টেস্টে এটাই ব্যবহার করুন — যদি না আপনি round() দিয়ে সংখ্যাটা আগেই বেঁধে ফেলেন, যেমন line_total করে।

কী পরীক্ষা করবেন

একটা নিয়ম বাকি সব নিয়মের চেয়ে বেশি কাজে আসে: যে ফাংশন মান নেয় আর মান ফেরত দেয়, সে পরীক্ষা করা সহজ।

একবিংশ অধ্যায়ের পিওর ফাংশনগুলোর কথা মনে আছে? এই অধ্যায়টাই তাদের পুরস্কার। line_total পরীক্ষা করতে কোনো ফাইল লাগেনি, কোনো ইনপুট লাগেনি, কিছু সাজাতে হয়নি — কেবল ডেকে ফলটা দেখা।

উল্টোদিকে, যে ফাংশন ছাপে, ইনপুট চায়, বা ফাইল লেখে — তাকে পরীক্ষা করতে ব্যবস্থা করতে হয়। সেজন্যই তেইশতম অধ্যায়ের প্রোগ্রামটায় report কোনো ফাইল নেয়নি: সে একটা লিস্ট নিয়েছিল আর একটা লিস্ট ফেরত দিয়েছিল।

টেস্ট লেখা কঠিন হওয়া মানে সাধারণত টেস্টের দোষ নয় — কোডের আকারের দোষ।

কী পরীক্ষা করবেন, তিন ধরনের ক্ষেত্র: যা সাধারণত ঘটে, প্রান্তের ক্ষেত্রগুলো (শূন্য, খালি, এক), আর যা ব্যর্থ হওয়ার কথা।


একটা সম্পূর্ণ উদাহরণ

pricing.py:

python
"""Prices and tax. Pure functions, which is what makes them testable."""

TAX_RATE = 0.15


def line_total(price: float, quantity: int) -> float:
    if quantity < 1:
        raise ValueError(f"quantity must be at least 1: {quantity}")
    return round(price * quantity * (1 + TAX_RATE), 2)


def order_total(lines: list[tuple[str, float, int]]) -> float:
    return round(sum(line_total(price, qty) for _, price, qty in lines), 2)

test_pricing.py:

python
"""Tests for pricing. Each one names the behaviour it protects."""

import pytest

from pricing import line_total, order_total


@pytest.mark.parametrize(
    "price, quantity, expected",
    [
        (15.0, 1, 17.25),
        (15.0, 3, 51.75),
        (0.0, 5, 0.0),
        (0.01, 1, 0.01),
    ],
)
def test_line_total_applies_tax(price, quantity, expected):
    assert line_total(price, quantity) == expected


@pytest.mark.parametrize("quantity", [0, -1, -100])
def test_quantity_below_one_is_rejected(quantity):
    with pytest.raises(ValueError, match="at least 1"):
        line_total(15.0, quantity)


def test_error_message_names_the_value():
    with pytest.raises(ValueError, match="at least 1: -3"):
        line_total(15.0, -3)


@pytest.fixture
def order():
    return [("pen", 15.0, 3), ("bag", 850.0, 1), ("ink", 120.0, 2)]


def test_order_total_sums_the_lines(order):
    assert order_total(order) == 1305.25


def test_empty_order_totals_zero():
    assert order_total([]) == 0


def test_one_bad_line_stops_the_order(order):
    with pytest.raises(ValueError):
        order_total(order + [("clip", 5.0, 0)])
text
...........                                                              [100%]
11 passed in 0.01s

চারটে জিনিস দেখার মতো।

এগারোটা টেস্ট, সাতটা ফাংশন। parametrize পার্থক্যটা বানিয়ে দিয়েছে, আর প্রতিটা ক্ষেত্র আলাদাভাবে গোনা হচ্ছে।

নামগুলো আচরণ বলে, কোড নয়। test_quantity_below_one_is_rejected পড়ে জানা যায় প্রোগ্রামটা কী প্রতিশ্রুতি দেয়; test_line_total_2 পড়ে কিছুই জানা যায় না। ব্যর্থতার রিপোর্টে এই নামটাই প্রথমে দেখা যাবে, তাই সে একটা বাক্য হওয়াই ভালো।

0.01 ক্ষেত্রটা দৈব নয়। সবচেয়ে ছোট সম্ভাব্য দাম — একটা প্রান্তের ক্ষেত্র, আর round()-এর আচরণ ঠিক ওখানেই সবচেয়ে সন্দেহজনক। প্রান্তগুলোতেই বাগ থাকে, মাঝখানে নয়।

শেষ টেস্টটা একটা পরোক্ষ প্রতিশ্রুতি ধরে রাখছে। order_total নিজে কোনো যাচাই করে না — সে line_total-এর উপর নির্ভর করে। কেউ যদি একদিন order_total-এ একটা try/except বসিয়ে খারাপ লাইনগুলো নীরবে বাদ দিতে চায়, এই টেস্টটা ব্যর্থ হয়ে জিজ্ঞেস করবে — «তুমি কি সত্যিই এটা চাও?»


কিছু ভাঙা অবস্থা ও তার সমাধান

no tests ran ফাইলের নাম test_ দিয়ে শুরু হয়নি, নয়তো ফাংশনের নাম। দুটোই লাগে।

ModuleNotFoundError: No module named 'pricing' যে ফোল্ডারে ফাইলগুলো আছে সেখান থেকেই pytest চালান।

টেস্ট পাস করে, কিন্তু কিছুই পরীক্ষা করে না assert লিখতে ভুলে গেছেন। একটা ফাংশন কেবল ডাকলে সে ত্রুটি না দিলেই পাস।

fixture 'order' not found @pytest.fixture লিখতে ভুলে গেছেন, নয়তো নামটা প্যারামিটারের নামের সাথে মিলছে না।

ভাসমান সংখ্যার তুলনা ব্যর্থ হচ্ছে, যদিও সংখ্যাগুলো ঠিক pytest.approx নিন, নয়তো ফাংশনটাতেই round() করুন।

DID NOT RAISE pytest.raises ব্লকের ভিতরের কোডটা ত্রুটি দেয়নি। হয় কোডটা ভুল, নয়তো টেস্টের প্রত্যাশাটা।

একটা টেস্ট একা চালালে পাস করে, সবার সাথে চালালে ফেল টেস্টগুলো কিছু একটা ভাগ করছে — প্রায়ই একটা মডিউল-স্তরের লিস্ট বা ডিকশনারি। দ্বাবিংশ আর ষড়বিংশ অধ্যায়ের সেই ভাগ করা অবস্থা। fixture ব্যবহার করুন।