الفصل 08

نطاق الـ fixture وملف conftest.py — مشاركة التهيئة بأمان

ابنِ التهيئة البطيئة مرة واحدة وشاركها: النطاقات الخمسة، و--setup-show، وخطر الحالة المشتركة و ScopeMismatch، وconftest.py لـ fixtures بلا استيراد وتجاوزها لكل مجلد، وكلفة 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

ثلاثة اختبارات، ثلاثة اتصالات، ثانية ونصف أُنفقت في المصافحة وحدها. مع ثلاثمئة اختبار تصبح دقيقتين ونصفاً من لا شيء، ومجموعة اختبارات بهذا البطء يتوقف الناس عن تشغيلها.

السؤال الذي يجيب عنه هذا الفصل هو: كيف تبني شيئاً مرة واحدة وتتشاركه — دون أن تتعثر الاختبارات التي تتشاركه ببعضها؟

بنهاية هذا الفصل ستكون قادراً على

  • اختيار نطاق (scope) الـ fixture: function أو class أو module أو package أو session
  • مشاهدة الـ fixtures وهي تُهيَّأ وتُفكَّك باستخدام --setup-show
  • شرح لماذا تجعل fixture واسعة النطاق تحمل حالة قابلة للتغيير الاختباراتِ معتمدة على بعضها — وإصلاح ذلك
  • قراءة خطأ ScopeMismatch ومعرفة أي fixture يجب تغييرها
  • مشاركة الـ fixtures عبر conftest.py، وتجاوز (override) إحداها لمجلد واحد
  • بيان كلفة autouse=True، وعرض كل الـ fixtures المتاحة باستخدام --fixtures
  • التنبؤ بالترتيب الذي تُنشأ به الـ fixtures

المتطلبات المسبقة: الـ fixtures.


قبل أن تكتب الاختبار

النطاق ليس شيئاً تضيفه في النهاية لتسريع مجموعة اختبارات بطيئة. إنه يُقرَّر قبل كتابة أول اختبار، انطلاقاً من سؤالين عن كل جزء من التهيئة: كم يكلّف بناؤه، وهل يغيّره أي اختبار؟ أجب عنهما أولاً، وسيتبعهما النطاق الصحيح.

العقد. الكود الذي نختبره هنا جدول users صغير. ما يَعِد به: قاعدة البيانات الجديدة لا مستخدمين فيها؛ إدراج مستخدم يضيف صفاً واحداً بالضبط؛ الاسم الذي تُدرجه هو الاسم الذي تستعيده. ويجب أن يصمد كل وعد من هذه الوعود أياً كانت الاختبارات التي جرت قبله. وهذا الشرط الأخير هو موضوع هذا الفصل في الحقيقة.

ما يجب أن يكون جاهزاً. بيئة افتراضية مثبّت فيها pytest؛ وsqlite3 الذي يأتي مع بايثون، فلا شيء يحتاج إلى تثبيت؛ وإمكانية استيراد الكود المُختبَر من ملفات الاختبار (هنا، الملفات متجاورة في مجلد واحد وتُشغَّل منه)؛ وقرار بشأن مكان الـ fixtures المشتركة — ملف conftest.py في أعلى شجرة الاختبارات.

خطة الاختبار. ثلاث حالات، يجب أن تنجح كل منها منفردة و بأي ترتيب:

| الحالة | المدخل | المتوقع | |---|---|---| | قاعدة بيانات جديدة | لم يُدرج شيء | COUNT(*) يساوي 0 | | إدراج واحد | 'asha' | COUNT(*) يساوي 1 | | عودة الاسم كما هو | 'ravi' | أول اسم يُقرأ هو 'ravi' |

خطة التهيئة. ثم جدول من النوع نفسه لما تقف عليه الاختبارات:

| المورد | كلفة البناء | هل يغيّره اختبار؟ | النطاق | |---|---|---|---| | الاتصال + الجدول | بطيء (نصف ثانية) | لا | واسع — module أو session | | الصفوف في الجدول | مجاني | نعم، مع كل إدراج | function — يُعاد ضبطه بعد كل اختبار |

اقرأ العمود الأخير بعناية: كائن واحد، هو الاتصال، يحمل شيئاً بطيئاً وشيئاً متغيراً معاً. ولهذا ينتهي الفصل إلى اثنتين من الـ fixtures بدلاً من واحدة — واحدة واسعة وأخرى ضيقة.

ما لا يجب اختباره. لا تختبر sqlite3 نفسه؛ فهو مُختبَر بعمق يفوق ما سيُختبر به كودك يوماً. ولا تختبر الـ fixtures مباشرة — فالـ fixture تُفحص عبر الاختبارات التي تستخدمها، وعبر قراءة --setup-show. ولا تختبر تأخير نصف الثانية: السرعة شيء تقيسه بـ --durations، لا شيء تتحقق منه بـ assert.


scope= — مرة واحدة لكل ماذا؟

نطاق الـ fixture يحدد كم تعيش القيمة الواحدة قبل أن يتخلص منها 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 — اتصال واحد بدلاً من ثلاثة. واختبار كان ينجح قبل لحظة صار يفشل الآن. احتفظ بهذا الفشل في ذهنك؛ فله قسم خاص أدناه. لكن أولاً، النطاقات نفسها.

هناك خمسة، من الأضيق إلى الأوسع:

| النطاق | قيمة واحدة لكل | تُفكَّك بعد | |---|---|---| | 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. بُنيت fixture الـ function ثلاث مرات، وfixture الـ class مرتين (صنفان)، وfixture الـ module والـ session مرة واحدة لكل منهما.

مشاهدة ما يحدث: --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 هو النطاق — Session وPackage وModule وClass وFunction — والإزاحة تُداخِل بعضها في بعض. اقرأ من الأعلى إلى الأسفل وسترى بالضبط متى وُلدت كل قيمة ومتى انتهت. كلما شككت فيما تفعله fixture ما، فهذا أول خيار تلجأ إليه.

الـ 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

وحده ينجح؛ مع غيره يفشل. الاختبارات التي تعتمد على ما جرى قبلها أسوأ من الاختبارات البطيئة: تفشل بترتيب وتنجح بآخر، وتُهدر بعد ظهر كاملاً في كل مرة.

القاعدة: وسّع نطاق الشيء المكلف، وأبقِ نطاق الحالة ضيقاً. قسّم الـ fixture إلى اثنتين — واحدة واسعة تملك الاتصال، وأخرى بنطاق function تنظّف بعد كل اختبار:

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

اتصال واحد، وثلاثة اختبارات ناجحة. يفتح sqlite3 معاملة (transaction) عند أول INSERT، وrollback() يتخلص من المعاملة بأكملها، فيبدأ كل اختبار بجدول فارغ.

لماذا نضع التنظيف في fixture بدلاً من أن يبدأ كل اختبار بـ DELETE FROM users؟ لأن التنظيف الذي يجب على كل اختبار أن يتذكره هو تنظيف سينساه اختبار ما — والاختبار الذي ينسى ليس هو الذي يفشل أبداً؛ بل التالي. أما في الـ fixture، فيحدث التنظيف بعد كل اختبار يطلب db، بما في ذلك اختبارات سيكتبها في العام القادم شخص لم يقرأ هذا الملف قط. ولماذا rollback() بدلاً من DELETE؟ لأن التراجع يلغي كل ما كتبه الاختبار، في كل الجداول، دون أن تحتاج الـ 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

يمكن لـ fixture بنطاق function أن تطلب بحرية 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 — ولاحظ أنه Error أثناء التهيئة، لا Failure: الاختبار لم يُشغَّل أصلاً.

تذكر الرسالة اسمي الـ fixtures وسطريهما. والإصلاح هو نفسه دائماً: اجعل الـ fixture المطلوبة بعرض الـ fixture الطالبة على الأقل — هنا @pytest.fixture(scope="session") على db_name — أو اجعل الطالبة أضيق.

conftest.py — 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 — دون أي استيراد:

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

النطاق الآن session: اتصال واحد لكل ملفات المشروع.

تجاوز fixture لمجلد واحد

تحتاج اختبارات 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 الأب عن الصفوف المزروعة أيضاً، لأن التجاوز مبنيّ فوقها. ويُظهر --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

(هذا هو الاختبار الأول؛ والبقية تكرر النمط نفسه.) يمتد التجاوز إلى الأسفل فقط: اختبارات المجلد الأعلى لا تراه أبداً.

--fixtures — ما الذي يمكنني طلبه هنا؟

حين تتوزع الـ fixtures على عدة ملفات conftest.py، تحتاج إلى طريقة لرؤية ما يستطيع اختبار بعينه طلبه. يعرض pytest --fixtures reports كل ما هو مرئي من ذلك المجلد. تبدأ القائمة بالـ fixtures المدمجة في pytest (الفصل التاسع) وتلك الخاصة بالإضافات المثبّتة؛ وتأتي 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.

كل مدخل يعطي الاسم، والنطاق (إن لم يكن function)، والملف والسطر، والأسطر الأولى من docstring. وهذا الجزء الأخير هو سبب كتابة docstring من سطر واحد لكل fixture مشتركة: إنه التوثيق الذي سيقرؤه الناس فعلاً.

autouse=True — وما يكلّفه

الـ fixture الموسومة بـ autouse=True تُستخدم في كل اختبار ضمن نطاق وصولها، سواء طلبها الاختبار أم لا. هذه الـ fixture، المضافة إلى conftest.py الأعلى، تتحقق من أن أي اختبار لا يترك صفوفاً خلفه:

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 مباشرة ويتخطى التراجع يُكتشف عند التفكيك. لكن أضف الآن اختباراً لا علاقة له بقواعد البيانات:

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 تُنشأ أولاً؟

حين يحتاج اختبار إلى عدة fixtures، يرتّبها pytest وفق ثلاث قواعد:

  1. النطاق الأوسع أولاً. session قبل module قبل function، أياً كان الترتيب في توقيع الاختبار.
  2. داخل النطاق الواحد، fixtures الـ autouse أولاً.
  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

طلب الاختبار cart قبل connection، لكن connection — بنطاق module — بُنيت أولاً، بعد اعتماديتها server. ومن بين fixtures نطاق function، جاءت audit ذات الـ autouse أولاً، ثم user التي تحتاجها cart. ويجري التفكيك بالترتيب المعاكس تماماً. وفيما عدا هذه القواعد، لا تعتمد على الترتيب: إن كان يجب أن توجد fixture قبل أخرى، فاجعلها اعتمادية بتسميتها معاملاً.


مثال متكامل

وحدة متجر صغيرة، تُختبر على قاعدة بيانات واحدة مشتركة في الذاكرة.

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.

ثانياً، جرت اختبارات 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. يستورد test_orders.py الوحدة shop، أي الكود المُختبَر، ولا شيء غيرها؛ وتصل الـ 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 هنا) إلى نطاق الطالبة على الأقل، أو ضيّق الطالبة. والسطران تحت Requesting fixture stack يخبرانك أيهما أيّ.

AssertionError: assert 'asha' == 'ravi' — لكن الاختبار ينجح حين يُشغَّل وحده الحالة تتسرب بين الاختبارات عبر fixture أوسع من function. شغّل مع --setup-show لترى أي fixture مشتركة، ثم قسّمها: أبقِ الكائن المكلف واسعاً وأضف fixture بنطاق function تلغي تغييرات كل اختبار.

fixture 'report_title' not found الـ fixture موجودة، لكن ليس حيث يستطيع هذا الاختبار رؤيتها. ملف conftest.py يخدم مجلده والمجلدات التي تحته فقط — فالـ fixture في reports/conftest.py غير مرئية لاختبار في المجلد الأعلى. انقلها إلى conftest.py أعلى يغطي الاثنين. ويعرض pytest --fixtures path/to/test_file.py بالضبط ما يستطيع ذلك الملف رؤيته.

صفوف من اختبار تظهر في آخر رغم أن db تتراجع شيءٌ ما استدعى commit(). التراجع لا يلغي إلا ما لم يُثبَّت، لذا فالكود الذي يثبّت — أو الاختبار الذي يستخدم connection مباشرة بدلاً من db — يكتب بشكل دائم. وحارس مثل fixture الـ no_rows_left يحوّل ذلك إلى ERROR at teardown ... AssertionError: 1 row(s) left behind واضح للعيان.

اختبار لا يلمس أي قاعدة بيانات صار بطيئاً هناك fixture بـ autouse في مكان ما فوقه تعتمد على الـ fixture المكلفة. وتشغيل pytest --setup-show على ذلك الاختبار يسرد كل fixture استخدمها فعلاً، بما فيها تلك التي لم يطلبها قط.

from conftest import db في ملف اختبار لا تستورد من conftest.py. فـ pytest يعطيك الـ fixtures التي فيه بالاسم أصلاً، وimport conftest يجلب أي conftest.py يصادفه بايثون أولاً. في المشروع أعلاه، أدى إضافة ذلك السطر إلى test_users.py في المستوى الأعلى إلى استيراد الملف الموجود في reports/ — أي التجاوز الذي يزرع البيانات — ففشل test_starts_empty بـ assert 3 == 0. كما أن الـ fixture المستوردة تُعدّ معرّفة في ملف الاختبار نفسه، وهذا يتقدم على كل conftest.py. اطلب الـ fixtures بتسميتها معاملات، ولا شيء غير ذلك.