Fixture — টেস্টের প্রস্তুতি এক জায়গায়
একই setup বারবার না লিখে @pytest.fixture দিয়ে একবার লিখুন, নাম ধরে চেয়ে নিন, প্রতিটি টেস্টে নতুন কপি পান, আর yield দিয়ে টেস্ট fail করলেও পরিষ্কার করুন।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
এই হলো একটি ছোট শপিং কার্ট, cart.py:
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)আর এর জন্য তিনটি test, test_cart.py:
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sএগুলো pass করে, কিন্তু পড়তে ক্লান্তিকর। তিনটি test, নয় লাইন setup, আর মাত্র তিনটি লাইন যা আসলে কিছু যাচাই করে। setup প্রতিটিতে একই, তাই যে একটি লাইন আলাদা — যে লাইনের জন্যই test-টি লেখা — সেটিই চাপা পড়ে যায়।
পরেও এর একটা দাম দিতে হয়। যেদিন Cart()-এর একটি argument লাগবে, সেদিন আপনাকে তিন জায়গায় বদলাতে হবে, কিংবা ত্রিশ জায়গায়। গত অধ্যায়ে parametrize একটি test-এর input-এর পুনরাবৃত্তি দূর করেছিল। এই অধ্যায় দূর করবে তার setup-এর পুনরাবৃত্তি।
এই অধ্যায় শেষে আপনি পারবেন
@pytest.fixtureদিয়ে একটি fixture লিখতে, আর test-এর parameter হিসেবে তার নাম লিখে সেটি চাইতে- বোঝাতে কেন প্রতিটি test নিজের একটি নতুন কপি পায়, আর একটি তালিকা দিয়ে তা দেখাতে
- একটি fixture দিয়ে আরেকটি fixture বানাতে
- এমন একটি
yieldfixture লিখতে যা নিজের পরে নিজেই পরিষ্কার করে, আর setup ও teardown-এর ক্রম আগে থেকে বলতে --setup-showদিয়ে fixture তৈরি হওয়া চোখে দেখতেfixture 'x' not foundআর "called directly" ত্রুটি পড়ে বুঝতে- fixture আর একটি সাধারণ helper function-এর মধ্যে বেছে নিতে
আগে যা জানা লাগবে: `parametrize` দিয়ে একটি test অনেকগুলো ক্ষেত্রে চালানো।
টেস্ট লেখার আগে
fixture এমন কিছু নয় যা test লেখার পরে জুড়ে দেওয়া হয়। এগুলো একটি পরিকল্পনা থেকে আপনাআপনি বেরিয়ে আসে, আর পরিকল্পনাটি আসে সবার আগে।
কী প্রতিশ্রুতি দেওয়া হয়েছে। Cart তিনটি প্রতিশ্রুতি দেয়: add() একটি লাইন লিখে রাখে, আর 1-এর কম quantity হলে ValueError দিয়ে প্রত্যাখ্যান করে; total() হলো দাম × quantity-র যোগফল; count() হলো quantity-গুলোর যোগফল। কোনো ফাইল নেই, network নেই, global অবস্থা নেই — অর্থাৎ এখন পর্যন্ত ফিরিয়ে নেওয়ার মতো কিছু নেই।
কী আগে থেকে প্রস্তুত থাকতে হবে। প্রতিটি অধ্যায়ের মতোই: pytest ইনস্টল করা একটি virtual environment, আর যে ফোল্ডার থেকে আপনি pytest চালান সেখান থেকে cart.py import করা যায় এমন অবস্থা (test ফাইলটি তার পাশেই রাখুন)।
কোন কোন ক্ষেত্র। সেগুলো লিখে ফেলুন, আর একটি কলাম যোগ করুন যা মানুষ সাধারণত বাদ দেয় — test যে অবস্থা থেকে শুরু হয়:
| ক্ষেত্র | শুরুর অবস্থা | কাজ | প্রত্যাশিত ফল | |---|---|---|---| | সাধারণ মোট | pen × 3, bag × 1 | total() | 895.0 | | জিনিসের সংখ্যা | pen × 3, bag × 1 | count() | 4 | | আরও একটি লাইন | pen × 3, bag × 1 | add("notebook", 60.0, 2) | total() হবে 1015.0 | | প্রান্তিক ক্ষেত্র: কিছুই নেই | খালি কার্ট | total() | 0 | | অবৈধ quantity | খালি কার্ট | add("pen", 15.0, 0) | ValueError |
দ্বিতীয় কলামটি উপর থেকে নিচে পড়ুন। তিনটি সারি শুরু হয় একই ভরা কার্ট থেকে, দুটি একটি খালি কার্ট থেকে। ওই কলামের প্রতিটি পুনরাবৃত্ত মান হলো একটি fixture, যা লেখার অপেক্ষায় আছে — এখানে empty_cart, আর তার উপর বানানো একটি cart। আর যে সারি এমন একটি অবস্থা থেকে শুরু হয় যা আর কেউ ভাগ করে না, তার কোনো fixture-ই দরকার নেই; সে নিজের অবস্থা নিজেই বানায়।
প্রতিটি শুরুর অবস্থাকে আরও একটি প্রশ্ন করুন: এটি তৈরি করলে কি test-এর বাইরে কিছু বদলায়? একটি নতুন Cart বদলায় না। একটি global dictionary-তে register করা discount code, একটি খোলা connection, একটি লেখা ফাইল — এগুলো বদলায়, আর ঠিক এটাই আপনাকে বলে দেয় যে একটি fixture-এর পরিষ্কারের কাজ দরকার (yield, এই অধ্যায়ে পরে আসছে)।
কী test করবেন না। pytest প্রতিটি test-এর জন্য নতুন fixture বানায় — এটি প্রমাণ করতে test লিখবেন না; সেটি pytest-এর প্রতিশ্রুতি, আর এই অধ্যায় একবার তা দেখিয়ে দেয় যাতে আপনি ভরসা করতে পারেন। কিন্তু আপনার নিজের পরিষ্কারের কাজ যখন global অবস্থা ফিরিয়ে আনে, সেটি অবশ্যই test করুন — ওই code আপনার, আর সেটি ভুল হতে পারে।
অধ্যায়ের বাকি অংশ এই পরিকল্পনাকে code-এ রূপ দেয়।
প্রথম fixture
setup-টিকে একটি function-এ সরিয়ে নিন, আর তার উপরে @pytest.fixture বসান:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sকোনো test-ই cart() call করে না। প্রতিটি test-এর কেবল cart নামে একটি parameter আছে, আর চাওয়া বলতে ওইটুকুই।
একটি test চালানোর আগে pytest তার parameter-গুলোর নাম দেখে। প্রতিটি নামের জন্য সে ওই নামের একটি fixture খুঁজে বের করে, সেটি চালায়, আর যা ফেরত এল তা argument হিসেবে test-এ পাঠিয়ে দেয়। test বলে তার কী দরকার; সেটি নিজে বানায় না। এই ব্যবস্থার একটি নাম আছে — dependency injection — কিন্তু কৌশলটি «parameter-এর নাম fixture-এর নামের সাথে মেলানো»-র বেশি কিছু নয়।
test-টি এখন উদ্দেশ্যের একটিমাত্র লাইন হিসেবে পড়া যায়। আর setup থাকে এক জায়গায়, তাই Cart()-এ কোনো পরিবর্তন মানে একটিমাত্র function-এ পরিবর্তন।
প্রতিটি test পায় একটি নতুন কপি
test_add_one_more কার্টে একটি notebook যোগ করে। এর পরে যে test-গুলো চলল, তারা কি notebook-সহ একটি কার্ট দেখল? না — আর এটি পরিষ্কারভাবে দেখে নেওয়া দরকার, কারণ এই বৈশিষ্ট্যটিই fixture-কে নিরাপদ করে।
আগে fixture ছাড়া সংস্করণটি দেখি। module-এর উপরে একটি তালিকা, যা দুটি test ভাগ করে ব্যবহার করে:
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01sদ্বিতীয় test-টি ঠিকই আছে; সেটি fail করেছে প্রথম test-টি যা করেছে তার কারণে। একা চালালে এটি pass করে:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sযে test-এর ফল নির্ভর করে তার আগে কোন test-গুলো চলেছে তার উপর, সেটি একটি test suite-এর সবচেয়ে ব্যয়বহুল bug-গুলোর একটি, কারণ এটি দেখা দেয় কেবল মাঝে মাঝে।
এবার একই দুটি test, তালিকাটি আসছে একটি fixture থেকে। fixture-এর ভেতরে একটি print দেখাবে সেটি কখন চলে; -s pytest-কে বলে ছাপা output আটকে না রাখতে, যাতে আমরা তা দেখতে পাই:
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01s"building a new list" দুবার এসেছে। fixture function-টি চলেছে যে test তাকে চেয়েছে তার প্রত্যেকটির জন্য একবার করে, আর প্রতিটি test এমন একটি তালিকা পেয়েছে যা আর কেউ ছোঁয়নি। এটিই default, আর এর একটি নাম আছে, function scope: fixture-এর মান টিকে থাকে একটি test function যতক্ষণ চলে ততক্ষণ। অষ্টম অধ্যায়ে দেখানো হবে এটি কীভাবে বদলানো যায়, আর কেন সাধারণত বদলানো উচিত নয়।
fixture-ও fixture ব্যবহার করতে পারে
একটি fixture আরেকটি fixture চাইতে পারে ঠিক একইভাবে, যেভাবে একটি test চায় — parameter হিসেবে তার নাম লিখে:
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01scart তৈরি হয় empty_cart-এর উপর, তাই একটি test প্রস্তুতির যে স্তরটি দরকার সেটিই চাইতে পারে। কোনো test কিছুই call করে না; pytest নামগুলো অনুসরণ করে, test_total থেকে cart-এ আর cart থেকে empty_cart-এ, আর সেই শৃঙ্খল যে ক্রম দাবি করে সেই ক্রমেই এগুলো বানায়।
চোখে দেখা — --setup-show
কোন fixture-গুলো চলল তা জানতে আপনাকে print যোগ করতে হবে না। --setup-show প্রতিটি setup আর teardown ছেপে দেয়:
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01sউপর থেকে পড়ুন। F মানে function scope — একটি test-এর জন্য বানানো, আর তার পরে ফেলে দেওয়া। empty_cart দুবার setup হয়েছে, প্রতিটি test-এর জন্য একবার — আগের অংশের সেই নতুনত্ব। যেখানে cart ব্যবহৃত হয়েছে, সেখানে empty_cart আগে setup হয়েছে, কারণ cart তার উপর নির্ভর করে। আর teardown-গুলো আসে setup-এর উল্টো ক্রমে: সবার শেষে যেটি বানানো, সেটিই সবার আগে সরানো।
(আপনার plugin ইনস্টল করা থাকলে তাদের নিজস্ব fixture-এর জন্য বাড়তি লাইন দেখা যেতে পারে। সেগুলো তাদের, আপনার নয়।)
এখন পর্যন্ত teardown চোখে পড়ার মতো কিছু করেনি, কারণ ফিরিয়ে নেওয়ার মতো কিছু ছিল না। পরের অংশে সেটি বদলাবে।
yield — আগে setup, তারপর পরিষ্কার
কিছু setup পেছনে এমন কিছু রেখে যায় যা ফিরিয়ে নিতে হয়: একটি connection বন্ধ করতে হবে, একটি ফাইল মুছতে হবে, একটি global setting আগের অবস্থায় ফেরাতে হবে। এর জন্য fixture return-এর বদলে yield ব্যবহার করে:
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01syield-এর আগের সবকিছু setup। yield-এর পরের মানটিই test পায়। তারপর fixture থেমে থাকে — test চলে — আর test শেষ হলে pytest fixture-টিকে yield-এর পর থেকে আবার চালু করে, আর সেই বাকি অংশটিই teardown। ("connection: close"-এর সামনে . বসে আছে কারণ test শেষ হওয়ার মুহূর্তেই তার ফল ছাপা হয়, teardown শুরু হওয়ার ঠিক আগে।)
পরিষ্কারের কাজটি থাকে setup-এর সাথে একই function-এ, তার কয়েক লাইন নিচে। একটি যোগ করে অন্যটি ভুলে যাবেন, আর তা চোখে পড়বে না — এমন হওয়ার উপায় নেই।
দুটি yield fixture থাকলে, একটি অন্যটির উপর নির্ভরশীল, ক্রমটি সেটাই যার ইঙ্গিত --setup-show দিয়েছিল:
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01ssetup চলে নির্ভরতার শৃঙ্খলের নিচ থেকে উপরে; teardown ফেরে আবার নিচের দিকে। কাজ করে কেবল এই ক্রমটিই: saved_order তার row মোছে connection-এর মাধ্যমে, তাই মোছার সময় connection-টি তখনও খোলা থাকতে হবে।
test fail করলেও teardown চলে
test নিজেই শেষে পরিষ্কার করে নিতে পারলে, পরিষ্কারের জন্য fixture-এর ঝামেলায় যাওয়া কেন? চেষ্টা করে দেখুন। OPEN এখানে এমন যেকোনো কিছুর প্রতিনিধি যা একটি test পেছনে রেখে যেতে পারে — একটি খোলা connection, একটি অস্থায়ী ফাইল, একটি বদলানো setting:
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(--tb=no traceback লুকিয়ে রাখে, কেবল সারসংক্ষেপটুকু রাখে।) একটি ভুল, দুটি fail। একটি fail করা assert exception raise করে, আর raise করা মানে test সেখানেই শেষ, তাই তার নিচের পরিষ্কারের লাইনটি কখনো চলেনি। connection-টি OPEN-এ থেকে গেছে, আর পরের test — যা পুরোপুরি সঠিক ছিল — উত্তরাধিকারসূত্রে পাওয়া জিনিসের কারণে fail করেছে। একটি বড় suite-এ এই দ্বিতীয় fail-টির পেছনেই আপনার একটা গোটা বিকেল চলে যেত।
এবার একই কাজ, পরিষ্কারের কাজটি একটি yield-এর পরে:
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sএকটি ভুল, একটি fail। ভাঙা test-টির খবর তবু আসে — fixture কিছুই লুকায় না — কিন্তু তার ছড়ানো জঞ্জাল পরের test পর্যন্ত পৌঁছায়নি।
এটাই কারণ, আর এর থেকে আসা নিয়মটি হলো: পরিষ্কারের কাজ কখনো test-এর শরীরের শেষে যায় না। সেটি যায় একটি fixture-এর yield-এর পরে।
fixture, নাকি সাধারণ function?
setup ভাগ করার একমাত্র উপায় fixture নয়। একটি সাধারণ function — ধরুন cart_with(*lines), যা তাকে দেওয়া লাইনগুলো দিয়ে একটি কার্ট বানায় — সেটিও কাজ করে, আর নিচের সম্পূর্ণ উদাহরণে একটি এমন function ব্যবহার করা হয়েছে।
সিদ্ধান্ত নেয় যে পার্থক্যটি, সেটি এই: একটি test একটি helper-কে call করে, আর তাকে argument দিতে পারে। একটি test fixture-কে call করতে পারে না; কেবল তার নাম লিখতে পারে। একটি fixture যে test-ই চাক, সবার জন্য সবসময় একই জিনিস তৈরি করে।
তাই:
- fixture ব্যবহার করুন যখন অনেক test-এর একই শুরুর অবস্থা দরকার, বিশেষ করে যখন সেই অবস্থা পরে পরিষ্কার করতে হয়। নিশ্চিত teardown কেবল fixture-ই পায়।
- helper function ব্যবহার করুন যখন প্রতিটি test-এর একটু আলাদা একটি অবজেক্ট দরকার — এই লাইনগুলোসহ একটি কার্ট, ওই নামের একজন user। argument তো function-এর জন্যই।
দুটি ভালোভাবে একসাথে চলে: একটি fixture একটি helper-কে call করতে পারে, যেমনটি পরের উদাহরণে করা হয়েছে। (একটি fixture বানানোর এমন উপায়ও আছে যা একটি function ফেরত দেয় — দ্বাদশ অধ্যায়ের "factory" pattern। এখনই সেটি দরকার নেই।)
একটা সম্পূর্ণ উদাহরণ
একটি দোকান, যেখানে discount code থাকে module-স্তরের একটি dictionary-তে। একটি code register করলে global অবস্থা বদলায়, তাই যে test একটি code register করে, তাকে সেটি আবার সরিয়ে ফেলতে হবে।
shop.py:
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py:
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01sতিনটি জিনিস লক্ষ করুন।
প্রথমত, প্রতিটি test কেবল সেটুকুই চায় যা সে ব্যবহার করে। test_small_cart-এর একটি আলাদা কার্ট দরকার ছিল, তাই সে cart চায়ইনি; সে নিজের লাইনগুলো দিয়ে helper-টিকে call করেছে — আর --setup-show নিশ্চিত করছে যে তার জন্য cart কখনো বানানোই হয়নি। test_zero_quantity_is_refused-এর দরকার কেবল একটি খালি কার্ট আর একটি call, তাই সে কোনো fixture ব্যবহার করে না; পরিকল্পনার সেই «এমন শুরুর অবস্থা যা আর কেউ ভাগ করে না» সারিটি এটাই।
দ্বিতীয়ত, test_code_is_gone_afterwards আসলে fixture-এর একটি test। এটি চলে test_summer_discount-এর পরে, আর যাচাই করে যে SUMMER আর নেই। কেন এটি গুরুত্বপূর্ণ তা দেখতে, summer_code-এ yield আর পরিষ্কারের লাইনটির বদলে return "SUMMER" লিখে দিন:
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01scode-টি যে test তাকে register করেছিল, সেখান থেকে চুইয়ে পরের test-এ চলে গেছে। ওই যাচাইটি না থাকলে এই চুইয়ে পড়া নীরবই থেকে যেত।
তৃতীয়ত, helper আর fixture একসাথে কাজ করে। cart_with জানে কীভাবে একটি কার্ট বানাতে হয়; cart fixture ঠিক করে বেশিরভাগ test কোন কার্টটি ভাগ করবে।
কিছু ভাঙা অবস্থা ও তার সমাধান
fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.একটি parameter-এর নাম কোনো fixture-এর সাথে মেলেনি। হয় বানান ভুল — এখানে cart-এর বদলে crat, আর cart তো তালিকাতেই আছে — নয়তো function-টির উপরে @pytest.fixture লাইনটি নেই, সেক্ষেত্রে তালিকায় সেটি থাকবে না। খেয়াল করুন, এখানে E আর ERROR, F আর FAILED নয়: test-টি শুরুই হয়নি।
Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.test-টি তার শরীরে cart() লিখেছে। একটি fixture চাওয়া হয় parameter হিসেবে তার নাম লিখে, কখনো call করে নয়। যদি সত্যিই নিজের argument দিয়ে সেটি call করতে চান, তাহলে আপনার আসলে দরকার একটি helper function।
fail-এর বদলে ERROR at setup of test_total exception-টি উঠেছে fixture-এর ভেতরে, test চলার আগেই — যেমন setup-এ একটি ভুল add() থেকে ValueError: quantity must be at least 1: 0। E মানে «প্রস্তুতি ভেঙেছে»; F মানে «দাবিটি মিথ্যা ছিল»। আগে fixture-এ খুঁজুন।
fixture function has more than one 'yield' একটি fixture ঠিক একবার yield করে। setup থাকে yield-এর উপরে, পরিষ্কারের কাজ নিচে; দুটি মানের জন্য একটি tuple yield করুন, অথবা দুটি fixture লিখুন।
একটি test একা pass করে, অন্যদের সাথে fail করে test-গুলোর মধ্যে কিছু একটা ভাগ হচ্ছে — module-স্তরের একটি তালিকা বা dictionary, কিংবা এমন একটি global যা কোনো fixture বদলেছে কিন্তু আগের অবস্থায় ফেরায়নি। সেটি একটি fixture-এ বানান, আর যদি সেটি global অবস্থা হয়, তাহলে yield-এর পরে পরিবর্তনটি ফিরিয়ে নিন।
পরিষ্কারের কাজটি চলেনি পরিষ্কারের কাজটি test-এর শরীরে, fail করা একটি assert-এর পরে লেখা, অথবা fixture-টি return ব্যবহার করছে। এটি একটি yield fixture-এ সরিয়ে নিন, yield-এর পরে।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
দুটি টেস্টই একই fixture চায়, আর দুটিই তালিকায় কিছু যোগ করে। pytest -q চালালে শেষ লাইনে কী আসবে?
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — test_two পায় ['pen', 'bag']
- B2 failed
- C2 passed
- D1 passed, 1 error
pytest -q -s চালানো হলো। pytest-এর নিজের বিন্দু আর সারাংশ বাদ দিলে, তিনটি লাইন কোন ক্রমে ছাপা হবে?
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen close query
- Bquery open close
- Copen query
- Dopen query close
এই টেস্টটি fail করে। সমস্যা কোথায়?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- Afixture-কে একটি tuple ফেরত দিতে হবে
- Bটেস্ট নিজেই fixture-টিকে ডাকছে; এর বদলে `cart`-কে প্যারামিটার হিসেবে নেওয়া উচিত
- Cfixture-এ `return` নয়, `yield` লাগবে
- Dfixture-এর ওপর `len()` চালানো যায় না
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
library.py লিখুন, যেখানে থাকবে module-স্তরের একটি LOG = [] আর একটি Library class: add(title); borrow(title), যা বইটি তাকে না থাকলে ValueError raise করে, আর থাকলে সেটি সরিয়ে নেয় এবং LOG-এ "borrow <title>" যোগ করে; give_back(title); আর available(), যা তাকে থাকা বইগুলোর নামের একটি sorted তালিকা ফেরত দেয়।
তারপর test_library.py লিখুন। আগে পরিকল্পনা করুন — একটি শুরুর অবস্থা কলামসহ একটি টেবিল, এই অধ্যায়ের শুরুর মতো — তারপর লিখুন:
- একটি fixture
library, যা তিনটি বইসহ একটিLibraryফেরত দেয়, আর অন্তত তিনটি test যা এটি ব্যবহার করে — তার একটি একটি বই ধার নেয়, যাতে পরের কোনো test দেখাতে পারে যে তাক আবার ভরা - একটি fixture
borrowed, যাlibrary-র উপর নির্ভর করে, একটি বই ধার নেয় আর তার নাম ফেরত দেয়, সাথে দুটি test যা এটি ব্যবহার করে - একটি
yieldfixtureclean_log, যা test-এর আগেLOGখালি করে এবং পরে আবার খালি করে; একটি test যা এটি ব্যবহার করে, আর তার পরে একটি test যা assert করেLOG == [] - একটি সাধারণ helper
library_with(*titles), যাlibraryব্যবহার করে, আর ব্যবহার করে সেই একটি test যার একটি খালি তাক দরকার
pytest -q চালান, তারপর pytest -q --setup-show, আর প্রতিটি লাইন আপনার প্রত্যাশার সাথে মিলিয়ে দেখুন।
শেষে ইচ্ছে করে এটি ভাঙুন: clean_log-এ yield আর তার পরের লাইনটির বদলে return LOG লিখুন। কোন test fail করে, আর সেটি কি সেই test যা fixture-টি ব্যবহার করেছিল?
সমাধান
আগে পরিকল্পনা:
| ক্ষেত্র | শুরুর অবস্থা | কাজ | প্রত্যাশিত ফল | |---|---|---|---| | তাকে তিনটি বই | Dune, Emma, Ulysses | available() | তিনটিই, sorted | | ধার নিলে বই সরে যায় | একই | borrow("Dune") | Emma, Ulysses | | test-গুলোর মধ্যে কিছু চুইয়ে পড়ে না | একই | — | তখনও তিনটি | | দুবার ধার নেওয়া যায় না | একই, Emma ধার নেওয়া | borrow("Emma") | ValueError | | ফেরত দেওয়া | একই, Emma ধার নেওয়া | give_back("Emma") | Emma তাকে আছে | | ধার নেওয়া log হয় | একই, LOG খালি | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | log পরিষ্কার হয় | উপরের test-এর পরে | — | LOG == [] | | প্রান্তিক ক্ষেত্র: খালি তাক | কোনো বই নেই | available() | [] |
দুটি শুরুর অবস্থা বারবার আসে — তিন বইয়ের তাক, আর Emma বাইরে থাকা তাক — তাই এগুলো fixture হয়েছে। খালি log লাগে মাত্র একবার, কিন্তু সেটি global অবস্থা ছোঁয়, যা পরে আগের অবস্থায় ফেরাতে হয়, আর সেটির নিশ্চয়তা কেবল fixture-ই দেয়; তাই এটিও fixture। খালি তাক লাগে একবার, আর পেছনে কিছু রেখে যায় না, তাই সেটি helper ব্যবহার করে।
library.py:
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py:
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01s--setup-show দিয়ে চালালে দেখবেন, যে ছয়টি test library ব্যবহার করে তাদের প্রত্যেকটির জন্য একবার করে SETUP F library, borrowed setup হচ্ছে library-র পরে আর teardown হচ্ছে তার আগে, আর শেষ দুটি test-এর জন্য কোনো fixture লাইনই নেই।
আর পরীক্ষাটি, yield-এর জায়গায় return LOG দিয়ে:
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01sসিদ্ধান্তগুলো, একটি একটি করে:
libraryreturn করে, yield করে না। একটি নতুনLibrarytest-এর বাইরে কিছুই বদলায় না, তাই ফিরিয়ে নেওয়ার মতো কিছু নেই।yieldসেই fixture-এর জন্য যার পরিষ্কার করার মতো কিছু আছে; এটি রুচির ব্যাপার নয়।test_next_test_still_sees_threeইচ্ছে করেই রাখা। এটি চলে ঠিক সেই test-এর পরে যাDuneধার নিয়েছিল, আর pass করে কারণlibraryআবার নতুন করে বানানো হয়েছে।--setup-show-এ আপনি গুনে দেখতে পারেন: প্রতিটি test-এর জন্য একবারSETUP F library।borrowedবইয়ের নাম ফেরত দেয়। test-গুলো"Emma"বারবার না লিখেborrowedব্যবহার করে, তাই fixture কোন বই ধার নেবে তা বদলালে test-গুলোও সাথে সাথে বদলায়। আর যেহেতুborrowedনির্ভর করেlibrary-র উপর, যে দুটি test দুটোই চায় তারা একই library পায় — যেটি থেকে Emma-কে নেওয়া হয়েছে।clean_logyield-এর দুই পাশেই খালি করে। আগে, কারণ অন্য যে test-গুলোborrow()call করে —test_borrow_removes_the_book,borrowedfixture — তারা ইতিমধ্যেইLOG-এ লিখে ফেলেছে; test-কে একটি জানা অবস্থা থেকে শুরু করতে হবে। পরে, যাতে পরের test-এ কিছু চুইয়ে না যায়।- *পরীক্ষাটি fail করে অন্য test-এ।*
test_borrow_is_loggedতখনও pass করে; fail করেtest_log_is_empty_afterwards, যে কোনো ভুল করেনি। পরিষ্কারের কাজ না থাকলে সবসময় এমনই দেখায়: fail দেখা দেয় অন্য কোথাও। আর ঠিক এই কারণেই সমাধানে পরিষ্কারের কাজ যাচাই করার একটি test আছে। library_withএকটি helper, fixture নয়, কারণ যে একটি test এটি ব্যবহার করে তার একটি আলাদা তাক দরকার, আর argument নিতে পারে কেবল একটি function।library-ও এটিকে call করে, তাই একটি library কীভাবে ভরা হয় সেই জ্ঞান থাকে এক জায়গায়।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন