अध्याय 08

fixture का scope और conftest.py — सेटअप को सुरक्षित रूप से साझा करना

धीमा सेटअप एक बार बनाकर साझा करना: पाँच scopes, --setup-show, साझा state का ख़तरा और ScopeMismatch, बिना import के fixtures और डायरेक्टरी-वार override के लिए conftest.py, autouse की क़ीमत, और fixtures बनने का क्रम।

50 मिनटPython 3.12
  1. 1समस्या
  2. 2समझें
  3. 3उदाहरण
  4. 4अनुमान
  5. 5स्वयं करें
  6. 6चुनौती

वह समस्या जिसे हम हल कर रहे हैं

पिछले अध्याय के fixtures हर टेस्ट के लिए नए सिरे से बनाए जा रहे थे। डिफ़ॉल्ट के तौर पर यही सही है — जब तक कि जो चीज़ बन रही है वह महंगी न हो। यहाँ एक डेटाबेस कनेक्शन है जिसे खुलने में आधा सेकंड लगता है, जैसे किसी असली सर्वर के साथ हैंडशेक में लग सकता है:

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 से fixtures को बनते और हटते हुए देखना
  • समझाना कि बदलने वाली state रखने वाला चौड़े scope का fixture टेस्टों को एक-दूसरे पर निर्भर क्यों बना देता है — और उसे ठीक करना
  • ScopeMismatch एरर पढ़कर जानना कि कौन-सा fixture बदलना है
  • conftest.py के ज़रिए fixtures साझा करना, और किसी एक डायरेक्टरी के लिए fixture को override करना
  • बताना कि autouse=True की क़ीमत क्या है, और --fixtures से सभी उपलब्ध fixtures की सूची देखना
  • पहले से बताना कि fixtures किस क्रम में बनेंगे

ज़रूरी शर्तें: fixtures।


टेस्ट लिखने से पहले

Scope कोई ऐसी चीज़ नहीं है जिसे अंत में धीमे सूट को तेज़ करने के लिए जोड़ा जाए। यह पहला टेस्ट लिखने से पहले ही तय होता है, सेटअप के हर हिस्से के बारे में दो सवालों से: इसे बनाने में कितना खर्च आता है, और क्या कोई टेस्ट इसे बदलता है? पहले इनके जवाब दीजिए, सही scope अपने आप निकल आएगा।

कॉन्ट्रैक्ट। यहाँ जिस कोड का टेस्ट हो रहा है वह एक छोटी users टेबल है। उसका वादा: नए डेटाबेस में कोई यूज़र नहीं होता; एक यूज़र डालने पर ठीक एक row जुड़ती है; जो नाम आप डालते हैं वही नाम वापस मिलता है। इनमें से हर वादा चाहे पहले कोई भी टेस्ट चला हो तब भी टिकना चाहिए। यह आख़िरी शर्त ही असल में इस अध्याय का विषय है।

जो पहले से तैयार होना चाहिए। pytest इंस्टॉल किया हुआ एक virtual environment; sqlite3, जो पायथन के साथ ही आता है, इसलिए कुछ इंस्टॉल नहीं करना; टेस्ट फ़ाइलों से टेस्ट होने वाला कोड import हो सके (यहाँ फ़ाइलें एक ही डायरेक्टरी में साथ-साथ हैं, और उसी डायरेक्टरी से चलाई जाती हैं); और यह फ़ैसला कि साझा fixtures कहाँ रहेंगे — टेस्ट ट्री के सबसे ऊपर एक conftest.py।

टेस्ट प्लान। तीन केस, जिनमें से हर एक को अकेले और किसी भी क्रम में पास होना चाहिए:

| केस | इनपुट | अपेक्षित | |---|---|---| | नया डेटाबेस | कुछ नहीं डाला गया | COUNT(*) है 0 | | एक insert | 'asha' | COUNT(*) है 1 | | नाम का वापस आना | 'ravi' | पढ़ा गया पहला नाम 'ravi' है |

सेटअप प्लान। फिर उसी तरह की टेबल उस चीज़ के लिए जिस पर टेस्ट खड़े हैं:

| रिसोर्स | बनाने का खर्च | क्या कोई टेस्ट इसे बदलता है? | scope | |---|---|---|---| | कनेक्शन + टेबल | धीमा (आधा सेकंड) | नहीं | चौड़ा — module या session | | टेबल की rows | मुफ़्त | हाँ, हर insert पर | function — हर टेस्ट के बाद रीसेट |

आख़िरी कॉलम ध्यान से पढ़िए: एक ही ऑब्जेक्ट, कनेक्शन, एक धीमी चीज़ और एक बदलने वाली चीज़ — दोनों को रखता है। इसीलिए अध्याय के अंत में एक की जगह दो fixtures बनते हैं — एक चौड़ा, एक संकरा।

क्या टेस्ट न करें। sqlite3 को ख़ुद नहीं; आपका कोड कभी जितना टेस्ट होगा, उससे कहीं ज़्यादा गहराई से वह टेस्ट किया हुआ है। fixtures को सीधे नहीं — 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

दो चीज़ें हुईं। रन 1.65 सेकंड से घटकर 0.52 पर आ गया — तीन की जगह एक कनेक्शन। और जो टेस्ट अभी-अभी पास हो रहा था, वह अब फ़ेल हो रहा है। इस फ़ेलियर को याद रखिए; इसका अपना अलग हिस्सा नीचे है। पहले scopes ख़ुद।

ये पाँच हैं, सबसे संकरे से सबसे चौड़े तक:

| 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

तीन टेस्टों ने fixtures इस्तेमाल किए। 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 — और indentation उन्हें एक-दूसरे के अंदर सजाता है। ऊपर से नीचे पढ़िए और साफ़ दिखेगा कि कौन-सी वैल्यू कब पैदा हुई और कब ख़त्म हुई। जब भी शक हो कि कोई fixture क्या कर रहा है, सबसे पहले यही flag उठाइए।

साझा fixture अपनी state भी साझा करता है

फ़ेलियर पर लौटते हैं। 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 चौड़ा करें, state का 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 फेंक देता है, इसलिए हर टेस्ट ख़ाली टेबल से शुरू होता है।

सफ़ाई fixture में क्यों रखें, बजाय इसके कि हर टेस्ट DELETE FROM users से शुरू हो? क्योंकि जिस सफ़ाई को हर टेस्ट को याद रखना पड़े, उसे कोई एक टेस्ट भूलेगा ही — और जो भूलता है, फ़ेल वह कभी नहीं होता; फ़ेल होता है अगला टेस्ट। 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 बेझिझक किसी चौड़े 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 नहीं: टेस्ट चला ही नहीं।

संदेश दोनों fixtures और दोनों लाइनों के नाम बताता है। हल हमेशा एक ही है: माँगे गए fixture को माँगने वाले के कम से कम बराबर चौड़ा कीजिए — यहाँ db_name पर @pytest.fixture(scope="session") — या माँगने वाले को संकरा कीजिए।

conftest.py — बिना import के fixtures

जैसे ही किसी दूसरी टेस्ट फ़ाइल को db चाहिए, fixture को कॉपी करना ग़लत जवाब है। उसे ठीक conftest.py नाम की फ़ाइल में ले जाइए। pytest उस फ़ाइल को ख़ुद लोड करता है, और उसी डायरेक्टरी और उसके नीचे का हर टेस्ट उसके fixtures माँग सकता है — बिना किसी 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/ के टेस्टों के लिए नज़दीकी परिभाषा जीतती है। और अगर वह अपना ही नाम माँगे, तो उसे parent का संस्करण मिलता है:

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 को टेबल ख़ाली मिली। parent db ने पहले से डाली गई rows को भी 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 — मैं यहाँ क्या-क्या माँग सकता हूँ?

जब fixtures कई conftest.py फ़ाइलों में बिखरे हों, तो यह देखने का तरीका चाहिए कि कोई ख़ास टेस्ट क्या माँग सकता है। pytest --fixtures reports उस डायरेक्टरी से दिखने वाली हर चीज़ की सूची देता है। सूची pytest के बिल्ट-इन fixtures (अध्याय नौ) और इंस्टॉल किए गए plugins के fixtures से शुरू होती है; आपके वाले अंत में आते हैं:

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 जाँचता है कि कोई टेस्ट rows पीछे तो नहीं छोड़ रहा:

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

एक लाइन का string टेस्ट अब डेटाबेस खोलता है और आधा सेकंड लेता है। यही autouse की क़ीमत है: यह अदृश्य है — test_slugify में कहीं नहीं लिखा कि डेटाबेस शामिल है — और यह बिना शर्त है — अपने conftest.py के नीचे के हर टेस्ट में चलता है, उनमें भी जिन्हें इसकी ज़रूरत नहीं। autouse को सिर्फ़ सस्ती और सच में सार्वभौमिक चीज़ों के लिए रखिए, और उसे सबसे संकरे उस conftest.py में रखिए जो ज़रूरतमंद टेस्टों को ढकता हो।

कौन-सा fixture पहले बनता है?

जब किसी टेस्ट को कई fixtures चाहिए, pytest उन्हें तीन नियमों से क्रम देता है:

  1. चौड़ा scope पहले। session, फिर module, फिर function — टेस्ट के signature में क्रम चाहे जो हो।
  2. एक ही scope के भीतर, autouse fixtures पहले।
  3. निर्भरताएँ उन fixtures से पहले जिन्हें उनकी ज़रूरत है।

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 वाले fixtures में 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 का फ़ायदा है।

दूसरी, test_orders.py के टेस्ट तब चले जब report टेस्ट कुल आठ ऑर्डर डाल चुके थे, फिर भी उन्हें revenue शून्य मिला। connection.rollback() की जगह pass लिखकर फिर से चलाइए, साझा state की क़ीमत तुरंत दिख जाती है:

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 सिर्फ़ shop import करती है, यानी टेस्ट होने वाला कोड; fixtures नाम से सबसे नज़दीकी 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 के ज़रिए टेस्टों के बीच state रिस रही है। कौन-सा 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 करने के बावजूद एक टेस्ट की rows दूसरे में दिख रही हैं किसी ने commit() बुलाया है। rollback सिर्फ़ वही फेंकता है जो commit नहीं हुआ, इसलिए जो कोड commit करता है — या जो टेस्ट db की जगह सीधे connection इस्तेमाल करता है — वह स्थायी रूप से लिखता है। no_rows_left जैसा पहरेदार fixture इसे साफ़ दिखने वाले ERROR at teardown ... AssertionError: 1 row(s) left behind में बदल देता है।

डेटाबेस को न छूने वाला एक टेस्ट धीमा हो गया है ऊपर कहीं एक autouse fixture महंगे वाले पर निर्भर है। उस टेस्ट पर pytest --setup-show चलाने से वे सभी fixtures दिखते हैं जो उसने असल में इस्तेमाल किए, वे भी जो उसने कभी माँगे नहीं।

टेस्ट फ़ाइल में from conftest import db conftest.py से import मत कीजिए। pytest उसके fixtures आपको नाम से पहले ही दे देता है, और import conftest को वह conftest.py मिलता है जो पायथन को पहले मिल जाए। ऊपर के प्रोजेक्ट में, सबसे ऊपर की test_users.py में यह लाइन जोड़ने पर reports/ वाला import हुआ — पहले से डेटा भरने वाला override — और test_starts_empty assert 3 == 0 के साथ फ़ेल हुआ। import किया गया fixture टेस्ट फ़ाइल में ही परिभाषित माना जाता है, जो हर conftest.py पर भारी पड़ता है। fixtures सिर्फ़ parameter के रूप में नाम देकर माँगिए, और कुछ नहीं।