অধ্যায় 08

fixture-এর scope আর conftest.py — নিরাপদে সেটআপ ভাগ করা

ধীর সেটআপ একবার বানিয়ে ভাগ করা, পাঁচটি scope, --setup-show, ভাগ করা অবস্থার বিপদ ও ScopeMismatch, conftest.py দিয়ে import ছাড়া fixture আর ডিরেক্টরিভিত্তিক override, autouse-এর দাম এবং fixture তৈরির ক্রম।

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

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

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

test_users.py:

python
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 জানায় সময় কোথায় গেল:

text
...                                                                      [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: প্রতি টেস্টে একটি। একটি লাইন বদলান:

python
@pytest.fixture(scope="module")
def db():
    conn = connect()
    yield conn
    conn.close()

pytest -q:

text
..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:

python
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 বাইরে আসতে পারে):

text
...{'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 দিয়ে চালান:

text
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.01s

SETUP-এর পরের অক্ষরটি 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:

text
.                                                                        [100%]
1 passed in 0.50s

একা পাস করে; সবার সাথে ফেল করে। যে টেস্ট আগে কী চলেছে তার ওপর নির্ভর করে, সেটি ধীর টেস্টের চেয়েও খারাপ: এক ক্রমে ফেল করে, আরেক ক্রমে পাস করে, আর প্রতিবার একটা করে বিকেল নষ্ট করে।

নিয়মটি: ব্যয়বহুল জিনিসের scope চওড়া করুন, অবস্থার scope সরু রাখুন। fixture-টিকে দুই ভাগ করুন — একটি চওড়া, যার হাতে সংযোগ; আর একটি function-scope-এর, যে প্রতিটি টেস্টের পর পরিষ্কার করে:

python
@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:

text
...                                                                      [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 বিভাজনটি দেখায়:

text
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:

python
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:

text
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 ছাড়াই।

একটি উপ-ডিরেক্টরিসহ একটি প্রজেক্ট:

text
conftest.py
test_users.py
reports/
    conftest.py
    test_reports.py

conftest.py:

python
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 নেই:

python
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 == 1

scope এখন session: প্রজেক্টের সব ফাইলের জন্য একটিই সংযোগ।

একটি ডিরেক্টরির জন্য fixture override করা

reports/-এর টেস্টগুলোর সবার এমন একটি ডেটাবেস চাই, যাতে আগে থেকেই ব্যবহারকারী আছে। সেই ডিরেক্টরির একটি conftest.py একই নামে একটি fixture সংজ্ঞায়িত করতে পারে; reports/-এর টেস্টের জন্য কাছেরটিই জেতে। আর সেটি যদি নিজের নামটিই চায়, তবে পায় অভিভাবকের সংস্করণ:

reports/conftest.py:

python
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 db

reports/test_reports.py:

python
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:

text
============================= 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:

text
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-গুলোরটা দিয়ে; আপনারগুলো আসে শেষে:

text
------------------------ 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-টি যাচাই করে কোনো টেস্ট সারি ফেলে রেখে যাচ্ছে কি না:

python
@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:

python
def slugify(title):
    return title.strip().lower().replace(" ", "-")


def test_slugify():
    assert slugify(" Hello World ") == "hello-world"

pytest -q --setup-show test_text.py:

text
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 তিনটি নিয়মে সেগুলো সাজায়:

  1. চওড়া scope আগে। session, তারপর module, তারপর function — টেস্টের signature-এ ক্রম যা-ই হোক।
  2. একই scope-এর মধ্যে autouse fixture আগে।
  3. নির্ভরতাগুলো, যারা তাদের ওপর নির্ভর করে তাদের আগে।

test_order.py:

python
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:

text
['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:

python
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 None

conftest.py:

python
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:

python
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 None

reports/conftest.py:

python
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 db

reports/test_reports.py:

python
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:

text
============================= 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 লিখে আবার চালান, ভাগ করা অবস্থার দাম সঙ্গে সঙ্গে দেখা দেয়:

text
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 হিসেবে নাম দিয়ে, আর কিছু নয়।