Портал не бежит, или как мы с pytest воевали и мирились

Этот портал не бежит.

Именно с таким безапелляционным заявлением от pytest я столкнулся энное количество времени назад, попытавшись запустить тесты на отдельной тестовой БД в рабочем проекте. RuntimeError: This portal is not running.

И длинный страшный стектрейс. Разумеется, первым инстинктивным позывом было открыть StackOverflow и прочитать разъяснения умных дядек по поводу моих бесстыжих ошибок в коде. Однако здесь меня поджидала вторая неприятная новость: ни стаковерфло, ни вообще гугл о такой ошибке ничего не слышал и упорно делал вид, что её не существует в нашей реальности.

Что ж, время пересилить страх и копаться самостоятельно. Спустя какое-то время и все пять стадий принятия неизбежного было установлено, что вообще-то эта ошибка представляет из себя ERROR at teardown of test_*, то есть дело вообще не в ней. Приглядевшись, мне с трудом удалось отыскать ниже изрыгнутой простыни фреймов стектрейса этой ошибки настоящую причину: Fixture * is called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.

Однако осадочек остался, куча красных кричащих строк с жалобами на портал на фоне неприметных двух строчек непосредственной ошибки, посему не желающий бежать портал был вынесен в заголовок поста (а то чего это два результата в поиске, из которых совершенно 0 полезной информации).

Конечно, самого поста не было бы, если бы всё было так просто, и на самом деле пост — не жалоба на некрасивые ошибки при завершении упавших тестов. Проблема остаётся — какое ещё fixture is called directly? Я её нигде не вызывал, и никакого стектрейса относительно этого не наблюдаем… Время внести немного контекста и показать необходимый код. Опуская тщательные проверки самих тесткейсов, покажу сразу фикстуры — в итоге пришёл к тому, что проблема именно в них.

@pytest.fixture(scope="session")
def db() -> Generator:
    try:
        db = TestingSessionLocal()
        yield db
    finally:
        db.close()

...

@pytest.fixture(scope="module")
def client() -> Generator:
    app.dependency_overrides[get_db] = db
    client = TestClient(app, backend_options={"use_uvloop": True})
    with client as c:
        yield c

Это почти стоковый сетап для тестов FastAPI/Starlette, единственное, что было добавлено — dependency override для зависимости БД, подкидываем тестовую вместо продовой.

Казалось бы, что может пойти не так? У FastAPI свой IoC контейнер, у pytest — свой, не могут же они мешать друг другу, в самом деле. В фикстуру client мы не прокидываем db фикстурой, а используем её как стороннюю функцию.

Тем не менее, это ожидаемое поведение, видимо, не является ожидаемым для самого pytest, и он нагло лезет в чужой IoC контейнер, запрещая старлету использовать db по прямому назначению, как зависимость. Потому что, видите ли, fixtures are not meant to be called directly. Даже если это тебя никак не касается, и всё, что от твоих интересов есть в этом сетапе — это декоратор @pytest.fixture() над функцией.

Пришлось пожертвовать DRY и сделать идентичную копию функции db, чтобы использовать её как dependency в старлетовом клиенте, и ругаться все перестали. Мир? Мир. На неравных условиях, конечно, и ценой потерянных нескольких дней на поиски ошибки и решения. Но главное, что война закончилась.

=================================

UPD: после обсуждения с более опытными коллегами мне было разъяснено, что это ожидаемое поведение, и что IoC-контейнеры — это не про DI, это про управление зависимостями при наличии DI. А также было предложено решение, не только более элегантно выглядящее, но и логически более правильное:

@pytest.fixture(scope="module")
def client(db) -> Generator:
    app.dependency_overrides[get_db] = lambda: db
    client = TestClient(app, backend_options={"use_uvloop": True})
    with client as c:
        yield c

Большое спасибо @Tishka17 за консультацию.