fixture-এর scope আর conftest.py — নিরাপদে সেটআপ ভাগ করা
ধীর সেটআপ একবার বানিয়ে ভাগ করা, পাঁচটি scope, --setup-show, ভাগ করা অবস্থার বিপদ ও ScopeMismatch, conftest.py দিয়ে import ছাড়া fixture আর ডিরেক্টরিভিত্তিক override, autouse-এর দাম এবং fixture তৈরির ক্রম।
- 1সমস্যা
- 2বোঝা
- 3উদাহরণ
- 4অনুমান
- 5নিজে করা
- 6কঠিন করা
যে সমস্যাটা আমরা সমাধান করছি
গত অধ্যায়ের fixture-গুলো প্রতিটি টেস্টের জন্য নতুন করে বানানো হচ্ছিল। ডিফল্ট হিসেবে সেটিই ঠিক — যতক্ষণ না যা বানানো হচ্ছে তা ব্যয়বহুল হয়। এখানে একটি ডেটাবেস সংযোগ আছে, যেটি খুলতে আধা সেকেন্ড লাগে, ঠিক যেমন সত্যিকারের কোনো সার্ভারের সাথে হ্যান্ডশেকে লাগতে পারে:
test_users.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT)")
return conn
@pytest.fixture
def db():
conn = connect()
yield conn
conn.close()
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
assert name == "ravi"pytest -q --durations=3 জানায় সময় কোথায় গেল:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
0.50s setup test_users.py::test_insert_one
0.50s setup test_users.py::test_names_are_text
3 passed in 1.65sতিনটি টেস্ট, তিনটি সংযোগ, দেড় সেকেন্ড গেল কেবল হাত মেলাতে। তিনশো টেস্ট হলে আড়াই মিনিট কিছুই না করে কাটবে — আর এত ধীর টেস্ট স্যুট একসময় কেউ আর চালায় না।
এই অধ্যায় যে প্রশ্নের উত্তর দেয়: একটি জিনিস একবার বানিয়ে কীভাবে ভাগ করে নেবেন — এমনভাবে যাতে যে টেস্টগুলো সেটি ভাগ করে, তারা একে অপরের পায়ে পা না বাঁধায়?
এই অধ্যায় শেষে আপনি পারবেন
- একটি fixture-এর scope বেছে নিতে:
function,class,module,packageবাsession --setup-showদিয়ে fixture-গুলোর তৈরি হওয়া আর ভেঙে ফেলা নিজের চোখে দেখতে- ব্যাখ্যা করতে কেন পরিবর্তনশীল অবস্থা ধরে রাখা চওড়া scope-এর fixture টেস্টগুলোকে একে অপরের ওপর নির্ভরশীল করে তোলে — আর সেটি সারাতে
- একটি
ScopeMismatchত্রুটি পড়ে বুঝতে কোন fixture বদলাতে হবে conftest.pyদিয়ে fixture ভাগ করতে, আর একটিমাত্র ডিরেক্টরির জন্য কোনো fixture override করতে- বলতে
autouse=True-এর দাম কী, আর--fixturesদিয়ে সব উপলব্ধ fixture-এর তালিকা দেখতে - আগে থেকে বলতে কোন fixture কোন ক্রমে তৈরি হবে
আগে যা জানা লাগবে: fixture।
টেস্ট লেখার আগে
Scope এমন কিছু নয় যা শেষে এসে ধীর স্যুটকে দ্রুত করতে যোগ করা হয়। এটি ঠিক হয় প্রথম টেস্ট লেখার আগেই, সেটআপের প্রতিটি অংশ নিয়ে দুটি প্রশ্ন থেকে: এটি বানাতে কত খরচ, আর কোনো টেস্ট কি এটিকে বদলায়? আগে এই দুটির উত্তর দিন, সঠিক scope নিজেই বেরিয়ে আসবে।
চুক্তিটি। এখানে যে কোড টেস্ট হচ্ছে তা একটি ছোট users টেবিল। এর প্রতিশ্রুতি: নতুন ডেটাবেসে কোনো ব্যবহারকারী নেই; একজন ব্যবহারকারী ঢোকালে ঠিক একটি সারি যোগ হয়; যে নাম ঢোকাবেন সেই নামই ফেরত পাবেন। এর প্রতিটি প্রতিশ্রুতি টিকতে হবে আগে যে টেস্টই চলুক না কেন। এই শেষ শর্তটিই আসলে এই অধ্যায়ের বিষয়।
যা আগে থেকে তৈরি থাকতে হবে। pytest ইনস্টল করা একটি virtual environment; sqlite3, যা পাইথনের সাথেই আসে, তাই কিছু ইনস্টল করতে হবে না; টেস্ট ফাইল থেকে টেস্ট-হওয়া কোড import করা যায় এমন ব্যবস্থা (এখানে, ফাইলগুলো এক ডিরেক্টরিতে পাশাপাশি, আর সেই ডিরেক্টরি থেকেই চালানো); আর ভাগ করা fixture কোথায় থাকবে সেই সিদ্ধান্ত — টেস্ট-গাছের মাথায় একটি conftest.py।
টেস্ট পরিকল্পনা। তিনটি কেস, যার প্রতিটিকে একা এবং যেকোনো ক্রমে পাস করতে হবে:
| কেস | ইনপুট | প্রত্যাশিত | |---|---|---| | নতুন ডেটাবেস | কিছুই ঢোকানো হয়নি | COUNT(*) হলো 0 | | একটি insert | 'asha' | COUNT(*) হলো 1 | | নাম ঘুরে আসা | 'ravi' | প্রথম পড়া নামটি 'ravi' |
সেটআপ পরিকল্পনা। তারপর টেস্টগুলো যার ওপর দাঁড়িয়ে, তার জন্য একই রকম একটি টেবিল:
| রিসোর্স | বানানোর খরচ | কোনো টেস্ট কি এটি বদলায়? | scope | |---|---|---|---| | সংযোগ + টেবিল | ধীর (আধা সেকেন্ড) | না | চওড়া — module বা session | | টেবিলের সারিগুলো | বিনা খরচে | হ্যাঁ, প্রতিটি insert-এ | function — প্রতিটি টেস্টের পর রিসেট |
শেষ কলামটি মন দিয়ে পড়ুন: একটিই বস্তু, সংযোগটি, একটি ধীর জিনিস আর একটি বদলাতে-থাকা জিনিস — দুটোই ধরে রাখে। ঠিক এই কারণেই অধ্যায়ের শেষে একটির বদলে দুটি fixture দাঁড়ায় — একটি চওড়া, একটি সরু।
কী টেস্ট করবেন না। sqlite3 নিজেকে নয়; আপনার কোড কোনোদিন যতটা টেস্ট হবে, তার চেয়ে অনেক বেশি পুঙ্খানুপুঙ্খভাবে সেটি টেস্ট করা। fixture-গুলোকে সরাসরি নয় — একটি fixture যাচাই হয় যে টেস্টগুলো তাকে ব্যবহার করে তাদের মাধ্যমে, আর --setup-show পড়ে। আর আধা সেকেন্ডের দেরিটিকেও নয়: গতি --durations দিয়ে মাপার জিনিস, assert করার জিনিস নয়।
scope= — কিসের প্রতি একবার?
একটি fixture-এর scope বলে দেয় একটি মান কতক্ষণ বাঁচবে, তারপর pytest সেটি ফেলে দিয়ে আরেকটি বানাবে। ডিফল্ট হলো function: প্রতি টেস্টে একটি। একটি লাইন বদলান:
@pytest.fixture(scope="module")
def db():
conn = connect()
yield conn
conn.close()pytest -q:
..F [100%]
=================================== FAILURES ===================================
_____________________________ test_names_are_text ______________________________
db = <sqlite3.Connection object at 0x79350aa45c60>
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
> assert name == "ravi"
E AssertionError: assert 'asha' == 'ravi'
E
E - ravi
E + asha
test_users.py:35: AssertionError
=========================== short test summary info ============================
FAILED test_users.py::test_names_are_text - AssertionError: assert 'asha' == ...
1 failed, 2 passed in 0.52sদুটি ঘটনা ঘটল। চলার সময় ১.৬৫ সেকেন্ড থেকে নেমে ০.৫২ হলো — তিনটির বদলে একটি সংযোগ। আর একটু আগে যে টেস্ট পাস করছিল, সেটি এখন ফেল করছে। ফেল করাটা মনে রাখুন; নিচে এর জন্য আলাদা অংশ আছে। আগে scope-গুলো নিজেরা।
পাঁচটি আছে, সবচেয়ে সরু থেকে সবচেয়ে চওড়া:
| scope | প্রতি কিসে একটি মান | কখন ভেঙে ফেলা হয় | |---|---|---| | function | টেস্ট (ডিফল্ট) | প্রতিটি টেস্টের পর | | class | টেস্ট ক্লাস | ক্লাসের শেষ টেস্টের পর | | module | টেস্ট ফাইল | ফাইলের শেষ টেস্টের পর | | package | যে ডিরেক্টরিতে fixture-টি সংজ্ঞায়িত | সেই ডিরেক্টরির শেষ টেস্টের পর, উপ-ডিরেক্টরিসহ | | session | গোটা pytest চালানো | চালানোর একেবারে শেষ টেস্টের পর |
একটি গণক পার্থক্যটা চোখে দেখিয়ে দেয়। নিচের প্রতিটি fixture গুনে রাখে সেটি কতবার বানানো হলো:
test_scopes.py:
from collections import Counter
import pytest
made = Counter()
@pytest.fixture(scope="session")
def per_session():
made["session"] += 1
@pytest.fixture(scope="module")
def per_module():
made["module"] += 1
@pytest.fixture(scope="class")
def per_class():
made["class"] += 1
@pytest.fixture
def per_test():
made["function"] += 1
class TestFirst:
def test_a(self, per_session, per_module, per_class, per_test):
pass
def test_b(self, per_session, per_module, per_class, per_test):
pass
class TestSecond:
def test_c(self, per_session, per_module, per_class, per_test):
pass
def test_report():
print(dict(made))pytest -q -s (-s দিলে print বাইরে আসতে পারে):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01sতিনটি টেস্ট fixture-গুলো ব্যবহার করেছে। function fixture তিনবার বানানো হয়েছে, class fixture দুবার (দুটি ক্লাস), আর module ও session fixture একবার করে।
ঘটতে দেখা: --setup-show
গোনা কাজ করে, কিন্তু pytest পুরো সময়রেখাটাই আপনার জন্য এঁকে দিতে পারে। একই ফাইল pytest -q --setup-show দিয়ে চালান:
SETUP S per_session
SETUP M per_module
SETUP C per_class
SETUP F per_test
test_scopes.py::TestFirst::test_a (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
SETUP F per_test
test_scopes.py::TestFirst::test_b (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
SETUP C per_class
SETUP F per_test
test_scopes.py::TestSecond::test_c (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
test_scopes.py::test_report .
TEARDOWN M per_module
TEARDOWN S per_session
4 passed in 0.01sSETUP-এর পরের অক্ষরটি scope — Session, Package, Module, Class, Function — আর ইনডেন্টেশন সেগুলোকে একটির ভেতরে আরেকটি সাজায়। ওপর থেকে নিচে পড়লে ঠিক দেখা যায় কোন মান কখন জন্মাল আর কখন মরল। কোনো fixture কী করছে তা নিয়ে সন্দেহ হলে, প্রথমে এই flag-টিই হাতে নিন।
ভাগ করা fixture তার অবস্থাও ভাগ করে
ফেল করার প্রসঙ্গে ফিরি। scope="module" দেওয়ায় তিনটি টেস্টই পেয়েছে একই সংযোগ। test_insert_one 'asha'-কে যোগ করল, আর কেউ তাকে সরাল না। তারপর test_names_are_text 'ravi'-কে যোগ করে প্রথম নামটি চাইল, আর পেল 'asha'।
এই বাগের সবচেয়ে স্পষ্ট চিহ্ন হলো এমন একটি টেস্ট যা একা চালালে পাস করে। pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50sএকা পাস করে; সবার সাথে ফেল করে। যে টেস্ট আগে কী চলেছে তার ওপর নির্ভর করে, সেটি ধীর টেস্টের চেয়েও খারাপ: এক ক্রমে ফেল করে, আরেক ক্রমে পাস করে, আর প্রতিবার একটা করে বিকেল নষ্ট করে।
নিয়মটি: ব্যয়বহুল জিনিসের scope চওড়া করুন, অবস্থার scope সরু রাখুন। fixture-টিকে দুই ভাগ করুন — একটি চওড়া, যার হাতে সংযোগ; আর একটি function-scope-এর, যে প্রতিটি টেস্টের পর পরিষ্কার করে:
@pytest.fixture(scope="module")
def connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
yield connection
connection.rollback() # undo whatever this test wroteটেস্টগুলো বদলায় না — তারা এখনও db চায়। pytest -q --durations=3:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
(2 durations < 0.005s hidden. Use -vv to show these durations.)
3 passed in 0.51sএকটি সংযোগ, তিনটি পাস করা টেস্ট। প্রথম INSERT-এ sqlite3 একটি transaction খোলে, আর rollback() গোটা transaction-টাই ফেলে দেয়, তাই প্রতিটি টেস্ট শুরু হয় খালি টেবিল দিয়ে।
পরিষ্কারের কাজটা প্রতিটি টেস্টের শুরুতে DELETE FROM users লিখে না করে fixture-এ কেন রাখবেন? কারণ যে পরিষ্কারের কথা প্রতিটি টেস্টকে মনে রাখতে হয়, কোনো একটি টেস্ট তা ভুলবেই — আর যে ভোলে, ফেল করে কখনো সে নয়; ফেল করে পরের টেস্টটি। fixture-এ থাকলে, db চাওয়া প্রতিটি টেস্টের পর পরিষ্কার হয়, এমনকি আগামী বছর এমন কারও লেখা টেস্টের পরেও, যে এই ফাইল কখনো পড়েনি। আর DELETE-এর বদলে rollback() কেন? rollback টেস্টের লেখা সবকিছু, সব টেবিলে, পূর্বাবস্থায় ফেরায় — সেই লেখাগুলো কী ছিল তা fixture-কে জানতে হয় না।
--setup-show বিভাজনটি দেখায়:
SETUP M connection
SETUP F db (fixtures used: connection)
test_users.py::test_starts_empty (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_insert_one (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_names_are_text (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN M connection
3 passed in 0.51sএকটি function-scope-এর fixture স্বচ্ছন্দে চওড়া একটিকে চাইতে পারে, যেমন এখানে db চাইছে connection-কে। উল্টো দিকে যাওয়া চলে না।
ScopeMismatch — চওড়া সরুকে চাইতে পারে না
test_mismatch.py:
import sqlite3
import pytest
@pytest.fixture
def db_name():
return ":memory:"
@pytest.fixture(scope="session")
def connection(db_name):
conn = sqlite3.connect(db_name)
yield conn
conn.close()
def test_connects(connection):
assert connection.execute("SELECT 1").fetchone() == (1,)pytest -q:
E [100%]
==================================== ERRORS ====================================
_______________________ ERROR at setup of test_connects ________________________
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Requesting fixture stack:
test_mismatch.py:11: def connection(db_name)
Requested fixture:
test_mismatch.py:6: def db_name()
=========================== short test summary info ============================
ERROR test_mismatch.py::test_connects - Failed: ScopeMismatch: You tried to a...
1 error in 0.01sভেবে দেখুন এই অনুরোধের মানে কী দাঁড়াত। connection বাঁচে গোটা চালানো জুড়ে; db_name প্রথম টেস্টের পরই ফেলে দেওয়া হয়। session-এর মানটি এমন কিছু আঁকড়ে থাকত যা তার নিজের নিয়মেই আর নেই। তাই pytest রাজি হয় না — আর লক্ষ করুন, এটি setup-এর সময়ের একটি Error, Failure নয়: টেস্টটি চলেইনি।
বার্তাটি দুটি fixture আর দুটি লাইনেরই নাম বলে দেয়। সমাধান সবসময় একই: যে fixture চাওয়া হচ্ছে সেটিকে যে চাইছে তার অন্তত সমান চওড়া করুন — এখানে db_name-এ @pytest.fixture(scope="session") — অথবা যে চাইছে সেটিকে সরু করুন।
conftest.py — import ছাড়াই fixture
দ্বিতীয় কোনো টেস্ট ফাইলের db দরকার হলে fixture-টি কপি করা ভুল উত্তর। এটিকে ঠিক conftest.py নামের একটি ফাইলে সরিয়ে নিন। pytest ফাইলটি নিজে থেকেই লোড করে, আর একই ডিরেক্টরি ও তার নিচের প্রতিটি টেস্ট এর fixture চাইতে পারে — কোনো import ছাড়াই।
একটি উপ-ডিরেক্টরিসহ একটি প্রজেক্ট:
conftest.py
test_users.py
reports/
conftest.py
test_reports.pyconftest.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, city TEXT)")
return conn
@pytest.fixture(scope="session")
def connection():
"""One database connection, shared by the whole run."""
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every write is rolled back after the test."""
yield connection
connection.rollback()test_users.py — একটিও import নেই:
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1scope এখন session: প্রজেক্টের সব ফাইলের জন্য একটিই সংযোগ।
একটি ডিরেক্টরির জন্য fixture override করা
reports/-এর টেস্টগুলোর সবার এমন একটি ডেটাবেস চাই, যাতে আগে থেকেই ব্যবহারকারী আছে। সেই ডিরেক্টরির একটি conftest.py একই নামে একটি fixture সংজ্ঞায়িত করতে পারে; reports/-এর টেস্টের জন্য কাছেরটিই জেতে। আর সেটি যদি নিজের নামটিই চায়, তবে পায় অভিভাবকের সংস্করণ:
reports/conftest.py:
import pytest
@pytest.fixture
def db(db):
"""The parent db, with three users already in it."""
db.executemany(
"INSERT INTO users VALUES (?, ?)",
[("asha", "Dhaka"), ("ravi", "Pune"), ("omar", "Cairo")],
)
return dbreports/test_reports.py:
def test_user_count(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 3
def test_cities_sorted(db):
rows = db.execute("SELECT city FROM users ORDER BY city").fetchall()
assert [city for (city,) in rows] == ["Cairo", "Dhaka", "Pune"]pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
reports/test_reports.py::test_user_count PASSED [ 25%]
reports/test_reports.py::test_cities_sorted PASSED [ 50%]
test_users.py::test_starts_empty PASSED [ 75%]
test_users.py::test_insert_one PASSED [100%]
============================== 4 passed in 0.51s ===============================ক্রমটা দেখুন। reports/-এর টেস্টগুলো আগে চলেছে আর প্রতিবার তিনজন করে ব্যবহারকারী ঢুকিয়েছে — তবু তাদের পরে চলা test_starts_empty টেবিল খালিই পেয়েছে। অভিভাবক db বীজ-বসানো সারিগুলোকেও rollback করেছে, কারণ override-টি তার ওপরে বানানো। --setup-show দুটি স্তর দেখায়, দুটিরই নাম db:
SETUP S connection
SETUP F db (fixtures used: connection)
SETUP F db (fixtures used: db)
reports/test_reports.py::test_user_count (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN F db(এটি প্রথম টেস্ট; বাকিগুলো একই ধাঁচ মেনে চলে।) override কেবল নিচের দিকে পৌঁছায়: ওপরের ডিরেক্টরির টেস্টগুলো এটি কখনো দেখে না।
--fixtures — এখানে আমি কী কী চাইতে পারি?
fixture কয়েকটি conftest.py-তে ছড়িয়ে থাকলে, একটি নির্দিষ্ট টেস্ট কী চাইতে পারে তা দেখার উপায় লাগে। pytest --fixtures reports সেই ডিরেক্টরি থেকে দেখা যায় এমন সবকিছুর তালিকা দেয়। তালিকা শুরু হয় pytest-এর বিল্ট-ইন fixture (নবম অধ্যায়) আর ইনস্টল করা plugin-গুলোরটা দিয়ে; আপনারগুলো আসে শেষে:
------------------------ fixtures defined from conftest ------------------------
connection [session scope] -- conftest.py:15
One database connection, shared by the whole run.
db -- conftest.py:23
The shared connection; every write is rolled back after the test.
db -- reports/conftest.py:5
The parent db, with three users already in it.প্রতিটি এন্ট্রিতে থাকে নাম, scope (যদি function না হয়), ফাইল ও লাইন, আর docstring-এর প্রথম লাইনগুলো। এই শেষ অংশটির জন্যই প্রতিটি ভাগ করা fixture-এ এক লাইনের docstring লেখা উচিত: মানুষ আসলে এই ডকুমেন্টেশনটিই পড়ে।
autouse=True — আর তার দাম
autouse=True চিহ্নিত fixture তার নাগালের প্রতিটি টেস্টে ব্যবহৃত হয়, টেস্ট চাক বা না চাক। ওপরের conftest.py-তে যোগ করা এই fixture-টি যাচাই করে কোনো টেস্ট সারি ফেলে রেখে যাচ্ছে কি না:
@pytest.fixture(autouse=True)
def no_rows_left(connection):
"""After every test, check that the users table is empty again."""
yield
count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0, f"{count} row(s) left behind"এটি কাজ করে — যে টেস্ট সরাসরি connection দিয়ে লেখে আর rollback এড়িয়ে যায়, সেটি teardown-এ ধরা পড়ে। কিন্তু এবার এমন একটি টেস্ট যোগ করুন যার ডেটাবেসের সাথে কোনো সম্পর্ক নেই:
test_text.py:
def slugify(title):
return title.strip().lower().replace(" ", "-")
def test_slugify():
assert slugify(" Hello World ") == "hello-world"pytest -q --setup-show test_text.py:
SETUP S connection
SETUP F no_rows_left (fixtures used: connection)
test_text.py::test_slugify (fixtures used: connection, no_rows_left) .
TEARDOWN F no_rows_left
TEARDOWN S connection
1 passed in 0.50sএক লাইনের একটি স্ট্রিং টেস্ট এখন একটি ডেটাবেস খোলে আর আধা সেকেন্ড নেয়। এটাই autouse-এর দাম: এটি অদৃশ্য — test_slugify-এর কোথাও লেখা নেই যে ডেটাবেস জড়িত — আর এটি শর্তহীন — নিজের conftest.py-এর নিচের প্রতিটি টেস্টে চলে, যাদের দরকার নেই তাদেরসহ। autouse রাখুন কেবল সস্তা আর সত্যিই সর্বজনীন জিনিসের জন্য, আর সেটি রাখুন সবচেয়ে সরু সেই conftest.py-তে যা দরকারি টেস্টগুলোকে ঢেকে দেয়।
কোন fixture আগে তৈরি হয়?
একটি টেস্টের কয়েকটি fixture দরকার হলে pytest তিনটি নিয়মে সেগুলো সাজায়:
- চওড়া scope আগে। session, তারপর module, তারপর function — টেস্টের signature-এ ক্রম যা-ই হোক।
- একই scope-এর মধ্যে
autousefixture আগে। - নির্ভরতাগুলো, যারা তাদের ওপর নির্ভর করে তাদের আগে।
test_order.py:
import pytest
log = []
@pytest.fixture(scope="session")
def server():
log.append("server")
@pytest.fixture(scope="module")
def connection(server):
log.append("connection")
@pytest.fixture
def user():
log.append("user")
@pytest.fixture
def cart(user):
log.append("cart")
@pytest.fixture(autouse=True)
def audit():
log.append("audit")
def test_order(cart, connection):
print(log)pytest -q -s:
['server', 'connection', 'audit', 'user', 'cart']
.
1 passed in 0.01sটেস্টটি connection-এর আগে cart চেয়েছিল, কিন্তু connection — module scope — আগে বানানো হয়েছে, তার নিজের নির্ভরতা server-এর পরে। function-scope-এর fixture-গুলোর মধ্যে autouse audit এসেছে প্রথমে, তারপর user, যাকে cart-এর দরকার। teardown ঠিক উল্টো ক্রমে চলে। এই নিয়মগুলোর বাইরে ক্রমের ওপর ভরসা করবেন না: একটি fixture-এর আগে আরেকটি থাকতেই হলে, সেটিকে parameter হিসেবে নাম দিয়ে নির্ভরতা বানিয়ে দিন।
একটি সম্পূর্ণ উদাহরণ
একটি ছোট shop module, যার টেস্ট হচ্ছে একটি ভাগ করা in-memory ডেটাবেসের বিপরীতে।
shop.py:
def add_order(conn, customer, total):
conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)", (customer, total)
)
def revenue(conn):
return conn.execute("SELECT COALESCE(SUM(total), 0) FROM orders").fetchone()[0]
def top_customer(conn):
row = conn.execute(
"SELECT customer FROM orders GROUP BY customer "
"ORDER BY SUM(total) DESC LIMIT 1"
).fetchone()
return row[0] if row else Noneconftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the orders table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (customer TEXT, total REAL)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; whatever a test writes is rolled back."""
yield connection
connection.rollback()test_orders.py:
from shop import add_order, revenue, top_customer
def test_revenue_starts_at_zero(db):
assert revenue(db) == 0
def test_add_order_counts_toward_revenue(db):
add_order(db, "asha", 250.0)
assert revenue(db) == 250.0
def test_no_top_customer_without_orders(db):
assert top_customer(db) is Nonereports/conftest.py:
import pytest
from shop import add_order
@pytest.fixture
def db(db):
"""The parent db, with four orders from three customers already in it."""
add_order(db, "asha", 250.0)
add_order(db, "ravi", 900.0)
add_order(db, "omar", 120.0)
add_order(db, "asha", 700.0)
return dbreports/test_reports.py:
from shop import revenue, top_customer
def test_total_revenue(db):
assert revenue(db) == 1970.0
def test_top_customer_by_total(db):
assert top_customer(db) == "asha"pytest -v --durations=1:
============================= test session starts ==============================
collecting ... collected 5 items
reports/test_reports.py::test_total_revenue PASSED [ 20%]
reports/test_reports.py::test_top_customer_by_total PASSED [ 40%]
test_orders.py::test_revenue_starts_at_zero PASSED [ 60%]
test_orders.py::test_add_order_counts_toward_revenue PASSED [ 80%]
test_orders.py::test_no_top_customer_without_orders PASSED [100%]
============================= slowest 1 durations ==============================
0.50s setup reports/test_reports.py::test_total_revenue
============================== 5 passed in 0.51s ===============================তিনটি জিনিস লক্ষ করার মতো।
প্রথমত, ধীর সেটআপটি হয়েছে একবার, চালানোর প্রথম টেস্টে, আর কোথাও নয়। session scope-এর লাভ এটাই।
দ্বিতীয়ত, report টেস্টগুলো মোট আটটি অর্ডার ঢোকানোর পর test_orders.py-এর টেস্টগুলো চলেছে, তবু আয় দেখেছে শূন্য। connection.rollback()-এর জায়গায় pass লিখে আবার চালান, ভাগ করা অবস্থার দাম সঙ্গে সঙ্গে দেখা দেয়:
FAILED test_orders.py::test_revenue_starts_at_zero - assert 3940.0 == 0
FAILED test_orders.py::test_add_order_counts_toward_revenue - assert 4190.0 =...
FAILED test_orders.py::test_no_top_customer_without_orders - AssertionError: ...
3 failed, 2 passed in 0.52sতৃতীয়ত, কোনো টেস্ট ফাইল fixture import করে না। test_orders.py import করে কেবল shop, অর্থাৎ যে কোড টেস্ট হচ্ছে; fixture-গুলো নাম ধরে আসে সবচেয়ে কাছের conftest.py থেকে।
যখন ভেঙে যায়
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. একটি চওড়া fixture একটি সরু fixture চেয়েছে। চাওয়া fixture-টিকে (এখানে db_name) যে চাইছে তার অন্তত সমান scope-এ চওড়া করুন, অথবা যে চাইছে তাকে সরু করুন। Requesting fixture stack-এর নিচের দুটি লাইন বলে দেয় কোনটি কে।
AssertionError: assert 'asha' == 'ravi' — অথচ একা চালালে টেস্টটি পাস করে function-এর চেয়ে চওড়া একটি fixture দিয়ে টেস্টগুলোর মধ্যে অবস্থা চুইয়ে পড়ছে। কোন fixture ভাগ হচ্ছে দেখতে --setup-show দিয়ে চালান, তারপর সেটিকে ভাগ করুন: ব্যয়বহুল বস্তুটিকে চওড়া রাখুন আর এমন একটি function-scope-এর fixture যোগ করুন যা প্রতিটি টেস্টের পরিবর্তন ফিরিয়ে নেয়।
fixture 'report_title' not found fixture-টি আছে, কিন্তু এই টেস্ট যেখান থেকে দেখতে পায় সেখানে নয়। একটি conftest.py কেবল নিজের ডিরেক্টরি আর তার নিচের ডিরেক্টরিগুলোকে সেবা দেয় — reports/conftest.py-এর একটি fixture ওপরের ডিরেক্টরির টেস্টের কাছে অদৃশ্য। এটিকে এমন একটি conftest.py-তে তুলে নিন যা দুটিকেই ঢাকে। pytest --fixtures path/to/test_file.py ঠিক দেখিয়ে দেয় সেই ফাইল কী কী দেখতে পায়।
db rollback করা সত্ত্বেও এক টেস্টের সারি আরেক টেস্টে দেখা যাচ্ছে কিছু একটা commit() ডেকেছে। rollback কেবল সেটুকুই ফেলে দেয় যা commit হয়নি, তাই যে কোড commit করে — বা যে টেস্ট db-র বদলে সরাসরি connection ব্যবহার করে — সে স্থায়ীভাবে লেখে। no_rows_left-এর মতো একটি পাহারাদার fixture এটিকে চোখে পড়ার মতো ERROR at teardown ... AssertionError: 1 row(s) left behind-এ পরিণত করে।
ডেটাবেস ছোঁয় না এমন একটি টেস্ট ধীর হয়ে গেছে ওপরের কোথাও একটি autouse fixture ব্যয়বহুলটির ওপর নির্ভর করছে। সেই টেস্টে pytest --setup-show চালালে সেটি আসলে যত fixture ব্যবহার করেছে সবগুলোর তালিকা আসে, যেগুলো সে কখনো চায়নি সেগুলোসহ।
টেস্ট ফাইলে from conftest import db conftest.py থেকে import করবেন না। pytest তার fixture আপনাকে নাম ধরেই দিয়ে দেয়, আর import conftest পায় পাইথন প্রথমে যে conftest.py খুঁজে পায় সেটি। ওপরের প্রজেক্টে, ওপরের স্তরের test_users.py-তে এই লাইনটি যোগ করায় import হয়েছে reports/-এরটি — বীজ-বসানো override — আর test_starts_empty ফেল করেছে assert 3 == 0 দিয়ে। import করা fixture টেস্ট ফাইলেই সংজ্ঞায়িত বলে গণ্য হয়, যা প্রতিটি conftest.py-কে হারিয়ে দেয়। fixture চান কেবল parameter হিসেবে নাম দিয়ে, আর কিছু নয়।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
pytest -q -s দিয়ে চালালে test_report কী ছাপবে?
import pytest
calls = {"module": 0, "function": 0}
@pytest.fixture(scope="module")
def conn():
calls["module"] += 1
@pytest.fixture
def row(conn):
calls["function"] += 1
def test_a(row):
pass
def test_b(row):
pass
def test_c(conn):
pass
def test_report():
print(calls)- A{'module': 3, 'function': 3}
- B{'module': 1, 'function': 3}
- C{'module': 1, 'function': 2}
- D{'module': 3, 'function': 2}
এই টেস্টটি চলার আগেই ScopeMismatch দেয়। সবচেয়ে ছোট সঠিক সমাধান কোনটি?
import pytest
@pytest.fixture
def settings():
return {"db": ":memory:"}
@pytest.fixture(scope="session")
def connection(settings):
return settings["db"]
def test_connects(connection):
assert connection == ":memory:"- A`test_connects`-কে একটি ক্লাসের ভেতরে রাখা
- B`settings`-কে `@pytest.fixture(scope="session")` দেওয়া
- C`connection`-এ `autouse=True` যোগ করা
- D`settings`-কে `connection`-এর নিচে সরিয়ে লেখা
দুটি conftest.py-তেই db আছে। reports/test_reports.py-এর test_count কোন db পায়?
# conftest.py
@pytest.fixture
def db(connection):
yield connection
connection.rollback()
# reports/conftest.py
@pytest.fixture
def db(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
return db
# reports/test_reports.py
def test_count(db):
...- Aওপরের `conftest.py`-এরটি, কারণ সেটি আগে লোড হয়
- Bকোনোটিই নয় — একই নামের দুটি fixture থাকলে ত্রুটি হয়
- C`reports/`-এরটি, কিন্তু ভেতরের `db` প্যারামিটার নিজেকেই পায়, তাই অসীম পুনরাবৃত্তি হয়
- D`reports/`-এরটি, যা ভেতরে ওপরেরটিকে পায় আর তার ওপর একটি সারি যোগ করে
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
একটি library.py ঘিরে একটি ছোট প্রজেক্ট বানান, যাতে দুটি ফাংশন থাকবে: add_book(conn, title, author) আর books_by(conn, author), যা শিরোনামগুলোর একটি সাজানো তালিকা ফেরত দেয়।
- ওপরের স্তরের একটি
conftest.py-তে session-scope-এর একটিconnectionfixture লিখুন, যাsqlite3.connect(":memory:")খোলে, একটিbooks (title TEXT, author TEXT)টেবিল বানায়, আর ধীর সার্ভারের ভূমিকায় আধা সেকেন্ড ঘুমায়। এতে একটি docstring দিন। - এর ওপর function-scope-এর একটি
dbfixture যোগ করুন, যা প্রতিটি টেস্টের পর rollback করে। - তিনটি টেস্টসহ
test_library.pyলিখুন, যার প্রতিটি বই যোগ করেbooks_byযাচাই করে।pytest -q --durations=3চালিয়ে নিশ্চিত হন আধা সেকেন্ড একবারই দেখা যাচ্ছে। search/conftest.pyবানান, যাdb-কে override করে আগে থেকে পাঁচটি বইসহ শুরু করায়, আরsearch/test_search.py, যাতে সেগুলোর ওপর নির্ভর করা দুটি টেস্ট আছে।pytest --setup-showচালিয়েsearch/-এর টেস্টগুলোর আউটপুটেdb-র দুটি স্তর খুঁজে বের করুন। তারপরpytest --fixtures searchচালিয়ে দেখুন দুটি docstring-ই আসছে কি না।
তারপর ইচ্ছে করে দুবার ভাঙুন। প্রথমবার, rollback() লাইনটির জায়গায় pass লিখে গোটা স্যুট চালান — কোন টেস্টগুলো ফেল করে, আর প্রতিটি কি একা চালালে পাস করে? দ্বিতীয়বার, connection-কে নির্ভর করার জন্য function-scope-এর একটি db_name fixture দিন, আর ScopeMismatch বার্তাটি লাইন ধরে ধরে পড়ুন।
এই পাঁচ মিনিটই অধ্যায়টির সারকথা। scope গতির সিদ্ধান্ত, কিন্তু এর জন্ম দেওয়া বাগগুলো বিচ্ছিন্নতার বাগ — আর সেগুলো চিনতে শেখার একমাত্র উপায় নিজে কয়েকটি ঘটানো।
সমাধান
বিন্যাস:
library.py
conftest.py
test_library.py
search/
conftest.py
test_search.pylibrary.py:
def add_book(conn, title, author):
conn.execute("INSERT INTO books VALUES (?, ?)", (title, author))
def books_by(conn, author):
rows = conn.execute(
"SELECT title FROM books WHERE author = ? ORDER BY title", (author,)
).fetchall()
return [title for (title,) in rows]conftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the books table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, author TEXT)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every test's writes are rolled back."""
yield connection
connection.rollback()test_library.py:
from library import add_book, books_by
def test_unknown_author_has_no_books(db):
assert books_by(db, "Nobody") == []
def test_one_book(db):
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali"]
def test_titles_come_back_sorted(db):
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali", "Kabuliwala"]search/conftest.py:
import pytest
from library import add_book
@pytest.fixture
def db(db):
"""The parent db, with five books by three authors already in it."""
add_book(db, "Gitanjali", "Tagore")
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Godaan", "Premchand")
add_book(db, "Nirmala", "Premchand")
add_book(db, "Palace Walk", "Mahfouz")
return dbsearch/test_search.py:
from library import books_by
def test_two_books_by_premchand(db):
assert books_by(db, "Premchand") == ["Godaan", "Nirmala"]
def test_author_match_is_exact(db):
assert books_by(db, "tagore") == []pytest -q --durations=3:
..... [100%]
============================= slowest 3 durations ==============================
0.50s setup search/test_search.py::test_two_books_by_premchand
(2 durations < 0.005s hidden. Use -vv to show these durations.)
5 passed in 0.51spytest -q --setup-show search দুটি টেস্টের প্রতিটির জন্য অধ্যায়ের সেই একই ছবি দেখায়: SETUP F db (fixtures used: connection), তার পরে SETUP F db (fixtures used: db) — অভিভাবক স্তর, তারপর তার ওপরে override। pytest --fixtures search connection-কে [session scope]-সহ দেখায়, দুটি db fixture-কে তাদের ফাইল ও লাইনসহ, আর তিনটি docstring-ই।
প্রথম পরীক্ষা — rollback()-এর জায়গায় pass — দেয়:
FAILED test_library.py::test_one_book - AssertionError: assert ['Gitanjali',....
FAILED test_library.py::test_titles_come_back_sorted - AssertionError: assert...
2 failed, 3 passed in 0.52sআর pytest -q test_library.py::test_one_book একা:
. [100%]
1 passed in 0.50ssearch/-এর টেস্টগুলো আগে চলে পাঁচটি বই ফেলে রেখে গেছে, তাই test_one_book একটির বদলে দুটি Tagore-এর শিরোনাম পেয়েছে। একা চালালে পাস করে। এটাই চুইয়ে পড়া অবস্থার স্বাক্ষর, ঠিক অধ্যায়ের মতো। দ্বিতীয় পরীক্ষায় পাঁচটি টেস্টই E হয়ে যায়, প্রতিটিতে আগে দেখা সেই একই ScopeMismatch, এবার conftest.py-কে দেখিয়ে: def connection(db_name) হলো যে চাইছে, আর def db_name() সেই fixture যা বেশি সরু।
প্রতিটি সিদ্ধান্তের কারণ:
connectionsession-scope-এর, কারণ এটিই একমাত্র ধীর জিনিস, আর কোনো টেস্ট সংযোগটিকে নিজে বদলায় না — বদলায় কেবল তার ভেতরের সারিগুলো।dbfunction-scope-এর, কারণ সারিগুলো বদলায়। এর পুরো কাজটাই প্রতিটি টেস্টের পর rollback; টেস্টগুলোর এর নাম ছাড়া আর কিছু জানার দরকার নেই।- টেস্টগুলো চায়
db, কখনোconnectionনয়। যে টেস্ট সরাসরিconnectionনিত, সে rollback এড়িয়ে যেত আর তার সারিগুলো পরের টেস্টে চুইয়ে পড়ত। search/-এর override চায়db,connectionনয়, তাই বীজ-বসানো বইগুলো টেস্টের নিজের কাজের অংশ হয়ে যায়, আর অভিভাবকের rollback সেগুলোকে বাকি সবকিছুর সাথে সরিয়ে দেয়।db-র পরিষ্কারের নিয়ম কখনো বদলালে, override-কে না ছুঁয়েই সে পরিবর্তনটি পেয়ে যায়।test_author_match_is_exactইচ্ছে করেই পরিকল্পনায় আছে: এটি পাকা করে দেয় যে"tagore"আর"Tagore"এক নয় — এমন একটি সীমারেখা যা কেউ একদিন বদলাতে চাইবে, আর বদলানো উচিতও ইচ্ছে করে, একটি ফেল করা টেস্ট তাকে সেটা জানিয়ে দেওয়ার পর।- প্রতিটি ভাগ করা fixture-এ docstring আছে, কারণ পরের মানুষটি
pytest --fixturesদিয়েই খুঁজে পাবে সে কী কী চাইতে পারে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন