Dependency Injection w Pythonie na przykładzie Pytest i FastAPI

Maj 31, 2024 | #python , #fastapi , #design-pattern , #pytest

000001
000110
101000
011110

Czym jest Dependency Injection

Wstrzykiwanie Zależności z ang. Dependency Injection (DI) to w gruncie rzeczy bardzo prosta koncepcja, której podstawowym celem jest zmniejszenie zależności pomiędzy obiektami. W praktyce sprowadza się to do przekazywania zależności (np. obiektów, usług, danych) do klasy (lub w najprostszym scenariuszu do funkcji) z zewnątrz, za pośrednictwem parametrów.

Przykład bez Dependency Injection

class Car:
    def __init__(self):
        self._engine: Engine = Engine()
        self._tires: Tires = Tires()

Przykład z Dependency Injection

class Car:
    def __init__(self, engine: Engine, tires: Tires):
        self._engine = engine
        self._tires = tires

Zalety Dependency Injection

Jako główne zalety DI wymieniane są:

Przejrzystość i reużywalność

Luźne powiązania pomiędzy komponentami zwiększają modularność oprogramowania co sprzyja zachowaniu zasady pojedynczej odpowiedzialności -Single Responsibility Principle (SRP). Wydzielenie wyspecjalizowanej funkcjonalności do osobnej funkcji, klasy, czy modułu ułatwia utrzymanie jej spójności i użycie w wielu miejscach. Kod zrestrukturyzowany z uwagi na logikę jest też łatwiejszy do zrozumienia, i debugowania.

Łatwość rekonfiguracji i refaktoryzacji

W strukturze zbudowanej z wielu elementów łatwiej jest podmienić jeden klocek i zastąpić go innym aniżeli rozplątywać węzeł gordyjski przeplatających się zależności. Jeśli koniecznym jest dostarczenie do klasy alternatywnej implementacji, jakiejś funkcjonalności np. zastąpienie jednego providera notyfikacji innym koniecznym jest przeniesienie kontroli na tworzeniem obiektu, który zajmie się danym zadaniem na zewnątrz klasy. Jest to egzemplifikacja innego terminu związanego z wytwarzaniem oprogramowania jakim jest odwrócenie kontroli - Inversion of Control (IoC) .

Testowalność

Wyznaczenie wyraźnych granic pomiędzy zależnościami i fakt dostarczania ich z zewnątrz ułatwia testowanie kodu. Ponieważ zależności są wstrzykiwane, można je łatwo zamieniać na atrapy (mocki) lub inne implementacje podczas testów jednostkowych. Można zdecydować o zakresie testu. Czy ograniczyć się do sprawdzenia pojedynczej funkcjonalności czy też poddać testom integrację pomiędzy klasami, modułami czy wręcz systemami.

Wydajność

Dependency Injection przyczynia się do większej efektywności zarządzania zasobami ponieważ daje większą kontrolę nad cyklem życia obiektów, tworząc je tylko wtedy, gdy są naprawdę potrzebne (lazy loading) i niszcząc je, gdy nie są już potrzebne. Ma też pozytywny wpływ na koszty związane z tworzeniem obiektów, ponieważ, czy to za pośrednictwem wzorca Singleton czy też innych mechanizmów współdzielenia zależności może zapobiegać tworzenia nadmiarowych instancji. Modularność dodatkowo ułatwia cache-owanie, profilowanie i monitorowanie wybranych fragmentów kodu i wyszukiwanie wąskich gardeł.

Wady Dependency Injection

Choć wzorzec projektowy DI koncepcyjnie jest bardzo prosty jego implementacje we frameworkach, które na nim bazują są już bardziej złożone. Poszukując informacji na temat tego jak się pracuje np. z frameworkiem Spring łatwo można natrafić na słowa krytyki związane właśnie związane z mechanizmem wstrzykiwania zależności.

Do głównych zastrzeżeń podnoszonych w kierunku frameworków zorientowanych na Dependency Injection należą:

Zwiększona złożoność

Mechanizm zarządzania zależnościami oraz ich rozwiązywania dodaje dodatkową warstwę abstrakcji, która wymaga zrozumienia. Stosowanie DI nie zawsze jest oczywiste i proste podnosi zatem krzywą uczenia dla danego narzędzia, gdyż niezrozumienie może spowodować niepoprawne użycie przez co zalety zmienią się w wady.

Utrudnione debugowanie

Modularyzacja i podział logiczny z natury ułatwiają przegląd kodu, ale nadmiarowe "umagicznienie" i rozdrobnienie utrudnia śledzenie jego przepływu do tego stopnia, że bez uruchomienia interaktywnego debugera podążanie za wykonywalnymi instrukcjami staje się niemal niemożliwe. Jeśli edytor nie potrafi podlinkować wywoływanej metody do jej definicji a autocomplete nie działa, praca z kodem staje się irytująca i mniej efektywna.

Nadużycie

Choć zgodnie z piątą zasadą SOLID - Dependency Inversion Principle (DIP) moduły powinny być między sobą powiązane nie wprost, a za pośrednictwem abstrakcji, a Dependency Injection jest sposobem na zapewnienie tej zasady w praktyce bezkompromisowe trzymanie się DIP jest kosztowne. Utrzymywanie abstrakcji pomiędzy obiektami, których relacja ma charakter stabilny jest bezcelowe. Jeśli jest niska szansa na to, że jedna funkcjonalność ma rację bytu bez drugiej to dostarczanie jej z zewnątrz zamiast bezpośredniego wywołania nie daje żadnych korzyści a przyczynia się jedynie do większej złożoności kodu. Przykładem niech będzie wywołanie standardowych funkcji czy modułów Pythona wewnątrz funkcji w opozycji do dostarczenia ich za pośrednictwem parametrów.

import csv

def get_csv(csv_file):
    rows = []
    with open(csv_file, mode='r', newline='') as file:
        reader = csv.reader(file)
        for row in reader:
            rows.append(row)
    return rows

csv_content = get_csv('plik.csv')

kontra

import csv

def get_csv(csv_file, *, open_manager, csv_module):
    rows = []
    with open_manager(csv_file, mode='r', newline='') as file:
        reader = csv_module.reader(file)
        for row in reader:
            rows.append(row)
    return rows

csv_content = get_csv('plik.csv', open_manager=open, csv_module=csv)

Nadużywanie DI, jak już wspomniano komplikuje kod, ale jest też źródłem nadmiarowych operacji, które są wykonywane niepotrzebnie.

Ograniczona przydatność w testach

W zależności od języka programowania i konkretnej implementacji przydatność DI w trakcie testowania ma różną wartość. Sam fakt zastosowania wzorca Kompozycji, która jest naturalnym sprzymierzeńcem DI oraz wyniesienie zależności na zewnątrz czyni kod łatwiejszy do testowania, ale już zaawansowane implementacje wzorca wstrzykiwania zależności nie wiele wnoszą w tym temacie. Dostrzega się również minusy omawianego wzorca w kontekście testów, jak np przeniesienie problemów z kompilacji - dotyczy to języków kompilowanych - na czas uruchomienia. Nadużyty Dependency Injection może skłaniać do nadmiernej modularyzacji i rozproszenia logiki co prowadzi w konieczności do pisania większej liczby testów. Ponieważ DI ułatwia także mock-owanie zależności, duża liczba napisanych testów jednostkowych może dać programiście poczucie, że ma dobrze zweryfikowany kod, gdy tymczasem, przy braku wystarczającej liczby testów integracyjnych, jedyne co zostało przetestowane to atrapy.

Jak można zauważyć krytyczne uwagi dotyczące wstrzykiwania zależności nie dotyczą istoty DI a implementacji tego wzorca. Przyjrzyjmy się zatem dwóm przykładom wdrożenia go w frameworkach Pythona i rozważmy jakie przynoszą zalety i jakie mają wady.

Pytest i dostarczanie zależności za pośrednictwem fixtur

Pytest to narzędzie przeznaczone do implementacji testów jednostkowych w języku Python. Jego popularność wynika z łatwości użycia, konfiguracji i integracji z różnorodnymi python-owymi frameworkami. Jedną z jego cech charakterystycznych są fixtury, których przeznaczeniem jest dostarczanie do testów zasobów

  • danych testowych,
  • mock-ów połączeń do zewnętrznych usług,
  • alternatywnych połączeń do bazy danych, cache,
  • dodatkowych narzędzi testujących
  • itd.

Deklarowanie zależności

Pytest zapewnia rozbudowany ekosytem plugin-ów, a każdy z nich może oferować zestaw przydatnych fixtur. Fixtury można też definiować ad-hock a ich implementacja jest zazwyczaj bardzo prosta, gdyż jest to zwykła funkcja udekorowana dekoratorem @pytest.fixture lub @pytest_asyncio.fixture w przypadku funkcji asynchronicznych.

import pytest

@pytest.fixture
def fake_user_data():
    return {"user_id": 1, "user_name": "John", "is_admin": False}

Framework Pytest zapewnia dostępność fixtur w sposób niestandardowy. Fixtur nie importujemy, a to gdzie je możemy użyć zależy od tego gdzie zostały zdefiniowane np. w pliku conftest.py w katalogu głównym projektu. Przez to, do organizacji tych funkcji pomocniczych trzeba podejść w nieco niepythonowy sposób. Dodatkową wadą takiego rozwiązania jest to, że edytory podpowiadające składnię czy umożliwiające przejście do definicji funkcji poprzez kliknięcie jej nazwy w miejscu użycia, nie mogą w tym wypadku polegać na standardowych mechanizmach Pythona i nie zawsze sobie z tym radzą.

Wstrzykiwanie zależności

Wstrzyknięcie zależności polega na przekazaniu do funkcji testu parametru, którego nazwa odpowiada nazwie fixtury. Pytest automatycznie wywołuje fixturę i przekazuje jej wynik do testu. To maksymalnie uproszczone użycie fixtur w formie argumentów funkcji testowej jest bardzo wygodne, ale trzeba pamiętać, że nie są to zwykłe parametry czego nie widać na pierwszy rzut oka.

def test_fixture(fake_user_data):
    assert fake_user_data["id"] == 1

Parametryzowanie zależności

Fixtury mogą przyjmować argumenty za pośrednictwem innej wbudowanej fixtury request

@pytest.fixture
def fake_user_data(request):
    is_admin = request.param.get("is_admin", False)
    return {"user_id": 1, "user_name": "John", "is_admin": is_admin}

Przekazanie parametrów do fixtury wymaga użycia dekoratora @pytest.mark.parametrize

@pytest.mark.parametrize("fake_user_data", [{"is_admin": True}])
def test_fixture_with_params(fake_user_data):
    assert fake_user_data["is_admin"] is True

Przekazanie parametrów do fixtury wymaga użycia fixtury pośredniej i dekoratora komplikuje nieco sprawę i jest kolejnym nieintuicyjnym elementem pracy z Pytest, aczkolwiek w tym wypadku pewnym obejściem jest zastosowanie wzorca fabryki o ile nie przeszkadza nam, że zasób będzie generowany dla każdej funkcji testowej.

@pytest.fixture
def fake_user_data_factory():
    def _fake_user_data(is_admin: bool = False):
        return return {"user_id": 1, "user_name": "John", "is_admin": is_admin}
    return _fake_user_data

def test_fake_user_data(fake_user_data_factory):
    default_user = fake_user_data_factory()
    assert default_user["is_admin"] is False

    admin_user = fake_user_data_factory(is_admin=True)
    assert admin_user["is_admin"] is True

Dekoratora @pytest.mark.parametrize można użyć także do wywołania funkcji testowanej z różnymi parametrami.

def sum(a: int, b: int) -> int:
    return a + b

@pytest.mark.parametrize("a,b,res",[
	(1, 2, 3),
	(5, 7, 12),
    (1, "", TypeError),
    (None, 2, TypeError),
])
def test_sum(a, b, res):
    if isinstance(res, int):
        assert sum(a, b) == res
    else:
        with pytest.raises(TypeError):
            assert sum(a, b)

Zarządzanie zakresem i współdzielenie logiki

Pytest zarządza zakresem działania fixtur, dzięki czemu mogą być one uruchamiane dla każdego testu, modułu, klasy lub całej sesji testowej. Robi się to poprzez przekazanie określonej wartości parametru scope, domyślnie przyjmującego wartość function co oznacza, że dana fixtura zostanie wywołana dla każdego pojedynczego testu. Ze względów wydajnościowych nie zawsze jest to optymalne zachowanie - np. połączenie z bazą danych wystarczy ustanowić raz na całą sesję testową.

@pytest.fixture(scope="session")
def db_connection():
    return ...

Tym samym można stwierdzić, że mechanizm Dependency Injection wdrożony w Pytest nie tylko jest odpowiedzialny za dostarczanie zależności ale pozwala też zarządzać współdzieleniem tych zasobów pomiędzy określonymi grupami testów.

Teardown

Fixtury mogą być generatorami dzięki czemu możliwym jest użycie instrukcji yield. Wszystko co jest wykonywane po tej instrukcji odbywa się już po zakończeniu testu dzięki czemu Pytest zapewnia prosty sposób na teardown. Pojęcie to w testach jednostkowych odnosi się do procedur, które są wykonywane po zakończeniu testów w celu posprzątania środowiska testowego. Celem teardown jest zapewnienie, że po zakończeniu testu zasoby są zwalniane, a środowisko jest przywracane do stanu początkowego, aby kolejne testy mogły być uruchomione w czystym i przewidywalnym środowisku.

@pytest.fixture(scope="session")
def db_connection():
    # Connect to data base
    yield db
    # Reset data base
    # Close connection

Podsumowanie DI w Pytest

Mechanizm dostarczania zależności w Pytest jest elastyczny i ma duże możliwości nie mniej praca z nim wymaga jego zrozumienia i przyzwyczajenia ponieważ działa nieco nieintuicyjnie. O ile tworzenie i stosowanie fixtur jest proste, o tyle bardziej zaawansowane aspekty ich działania wymagają już zapoznania się z dokumentacją w celu prawidłowego użycia i wykorzystania pełnego potencjału. A potencjał jest duży więc w sumie warto. Z uwagi na specyficzne przeznaczenie tej biblioteki niedogodności, czy też niewygody nie są zbyt dotkliwe a szala przechyla się na stronę korzyści ponieważ po "zaprzyjaźnieniu się" z fixturami pisanie testów jest w gruncie rzeczy proste i szybkie. Z drugiej jednak strony nie jestem pewien czy chciałbym widzieć wdrożenie tego rozwiązania do innych narzędzi.

Depends w FastAPI

FastAPI wykorzystuje funkcje zależności (dependencies) do wstrzykiwania potrzebnych zasobów do endpointów. Mechanizm ten działa na zasadzie przekazywania funkcji jako zależności do innej funkcji, co jest wykonywane za pomocą specjalnych dekoratorów i funkcji pomocniczych.

Deklarowanie zależności

Zależności dostarczane są przez zwykłe funkcje, które mogą być użyte wraz z mechanizmem wstrzykiwania zależności, jak i wprost - co się okazuje przydatne.

def get_settings() -> dict:
    return {"foo": "bar"}

Wstrzykiwanie zależności

Wstrzyknięcie zależności wymaga użycia dekoratora Depends - co z jednej strony wydłuża składnię, ale z drugiej strony w jednoznaczny sposób pokazuje czym jest.

from fastapi import Depends, FastAPI

app = FastAPI()

@app.get("/settings/")
async def get_config(config: Depends(get_settings)):
    return config

Są też zależności, które można użyć z pominięciem dekoratora Depends (Query, Path, Body itd), gdyż zostały od razu przygotowane do działania z tym mechanizmem, co udowadnia, że warto, a nawet trzeba zajrzeć do dokumentacji.

Parametryzowanie zależności

Ponieważ podstawowym przeznaczeniem FastAPI jest budowa API REST mechanizm wstrzykiwania zależności został tak pomyślany, żeby parametry do tych zależności można było dostarczać w trakcie wykonywania żądania do API. Podstawowym i domyślny źródłem parametrów są zmienne przekazywane w adresie URL w query stringu - Query.

from fastapi import FastAPI, Depends

app = FastAPI()

def common_parameters(q: str = None, skip: int = 0, limit: int = 100):
    return {"q": q, "skip": skip, "limit": limit}

@app.get("/items/")
def read_items(commons: dict = Depends(common_parameters)):
    return commons
http://example.com/api/items?q=foo&skip=10&limit=50

Parametry - z natury żądania - mogą być też przekazane w ścieżce Path w ciele żądania Body, w nagłówku Header ale też w ciasteczku Cookie. Framework korzysta z adnotacji typów, aby określić, skąd mają pochodzić wartości parametrów. Przykładowo, dla parametru funkcji ze zdefiniowanym typem podstawowym (np. int, str), FastAPI zakłada, że jego wartość pochodzi z query stringa. Można jednak wprost zdefiniować źródło danych.

from fastapi import FastAPI, Query, Path, Body

app = FastAPI()

@app.get("/items/{item_id}")
def read_item(
    item_id: int = Path(..., description="The ID of the item to retrieve"),
    q: str = Query(None, max_length=50),
    data: dict = Body(None)
):
    return {"item_id": item_id, "q": q, "data": data}

Może zajść potrzeba, że nie chcemy przekazywać parametrów w żądaniu http tylko zaaplikować zależność z góry ustalonym parametrem. Przykładowo. Mamy kilka baz danych i chcemy aby dany endpoint czytał z konkretnej, innej niż domyślna bazy. Naszym celem jest przekazanie do endpointa uchwytu do połączenia z tą konkretną bazą danych i w żadnym wypadku nie chcemy aby można było - czy to w query stringu, ścieżce itp. - przekazać inną nazwę bazy. W takim wypadku możemy opakować wywołanie funkcji dostarczającej połączenie z konkretną nazwą bazy danych inną funkcją.

def get_db(db_name):
    db = SessionLocal(db_name)
    try:
        yield db
    finally:
        db.close()

def get_foo_db():
    yield from get_db("foo")

@app.get("/items/")
def read_items(db: DummyDBConnection = Depends(get_foo_db)):
    return {"message": "Connected with 'foo' data base."}

Ponieważ get_db jest generatorem, funkcja ją opakowująca też musi być generatorem. Stąd instrukcja delegująca wywołanie generatora yield from. ``

Zarządzanie zakresem i współdzielenie logiki

Mechanizm Dependency Injection w FastAPI nie implementuje wprost czegoś na wzór scope z Pytest, ale to nie znaczy, że programista nie ma wpływu na to, co i kiedy jest uruchamiane.

Zależności są rozwiązywane raz na żądanie oznacza to, że jeżeli nawet jakaś zależność zostanie użyta wielokrotnie to funkcja ta zostanie wywołana tylko raz. Jest to ważne z uwagi na to, że zależności w FastAPI mogą być zagnieżdżane. Wyobraźmy sobie, że chcemy aby nasz endpoint zwracał jakieś zasoby z bazy danych, ale tylko te, które bieżący użytkownik może zobaczyć. Funkcję zwracającą obiekt użytkownika też można zdefiniować jako zależność, ale będzie ona potrzebowała połączenia z bazą danych dlatego funkcja zwracająca połączenie do bazy danych zostanie przekazana wprost do endpointa, ale też do funkcji zwracającej dane użytkownika.

from fastapi import Depends

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

async def get_user(
	token: str = Depends(oauth2_scheme),
	db: Session = Depends(get_db)
):
    # Get user data by token
    ...
    return user

@app.get("/items/")
async def read_items(
	current_user: User = Depends(get_user),
	db: Session = Depends(get_db)
):
    return db.query(Item).filter(Item.owner_id == current_user.id).all()

Zarówno funkcja get_user, jak i red_items otrzymają tę samą instancję sesji bazy danych i nie trzeba w tym celu posiłkować się singletonem. Zasób tworzony i niszczony w kontekście pojedynczego żądania jest najczęściej stosowanym przypadkiem. Serwis nie jest obciążony kosztami utrzymania takich zasobów i nie musi zarządzać ich czasem życia pomiędzy żądaniami.

Zasoby, których największym kosztem jest inicjalizacja, lub też takie, które chcemy współdzielić pomiędzy żądaniami mogą być tworzone w chwili startu aplikacji, a rolą funkcji zależności w tym wypadku jest jedynie dostarczenie instancji tego zasobu.

from fastapi import FastAPI, Depends
from contextlib import asynccontextmanager

class Analyzer()
		
	async def start(self):
	    pass

	async def register(self, data):
		pass

	async def save(self):
		pass

analyzer = Analyzer()

def get_analyzer():
    return analyzer

@asynccontextmanager
async def lifespan(app: FastAPI):
    await analyzer.start()
    yield
    await analyzer.save()

app = FastAPI(lifespan=lifespan)

@app.get("/examples")
async def get_examples(analyzer: Analyzer = Depends(get_analyzer)):
    analyzer.register("some data")

Zilustrowany powyżej mechanizm lifespan w FastAPI umożliwia wykonywanie zadań podczas uruchamiania i zamykania aplikacji i służy nie tylko do współdzielenie zasobu między żądaniami, ale pozwala też na ciągłe podtrzymywanie jego pracy jeśli taki jest jego charakter dlatego nadaje się np. do:

Funkcje zależności mogą też zwracać callback-i czyli klasy lub funkcje, których wynik zależeć będzie z jakimi parametrami zostaną wykonane już w miejscu konsumującym dany zasób.

Teardown

Podobnie jak Pytest, FastAPI używa generatorów do wydzielania instrukcji sprzątających po wykonaniu żądania, lub też zamykania całej aplikacji. Naturalnie dobrą praktyką jest obfite korzystanie z instrukcji try, finally w celu upewnienia się, że ewentualne błędy nie zablokują instrukcji zwalniających zasoby.

Podmienianie zależności

Mechanizm Depends, przynosi w tym względzie pewne usprawnienie - app.dependency_overrides.

Bazując na projekcie opisanym w artykule "Asynchroniczne FastAPI w Dockerze", pokuśmy się o napisanie prostych testów jednostkowych do utworzonych tam przykładowych endpointów, tym samym prezentując w praktyce zarówno mechanizm fixtur biblioteki Pytest, jak i to co oferuje Dependency Injection w FastAPI w zakresie wsparcia w pisaniu unit testów.

W pliku ./app/conftest.py utwórzmy kilka fixtur, które umożliwią wykonanie testów na testowej bazie danych.

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

from app.core.config import get_settings
from app.core.db import Base, get_db
from app.main import app


@pytest_asyncio.fixture(scope="session")
async def settings():
	return get_settings()


@pytest_asyncio.fixture(scope="session")
async def prepare_db(settings):
    create_db_engine = create_async_engine(settings.postgres_dsn, isolation_level="AUTOCOMMIT")
    async with create_db_engine.begin() as conn:
        await conn.execute(
            text(f"DROP DATABASE IF EXISTS {settings.test_postgres_db};"),
        )
        await conn.execute(
            text(f"CREATE DATABASE {settings.test_postgres_db};"),
        )


@pytest.fixture(scope="function")
def engine(settings):
    engine = create_async_engine(settings.test_postgres_dsn)
    yield engine
    engine.sync_engine.dispose()


@pytest_asyncio.fixture(scope="function")
async def test_db(prepare_db, engine):
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)

    TestingSessionLocal = sessionmaker(
        autocommit=False,
        autoflush=False,
        expire_on_commit=False,
        bind=engine,
        class_=AsyncSession,
    )

    async with TestingSessionLocal() as test_session:
        yield test_session
        await test_session.flush()
        await test_session.rollback()


@pytest.fixture(scope="function")
def override_get_db(test_db):
    async def _override_get_db():
        yield test_db
    return _override_get_db


@pytest_asyncio.fixture(scope="function")
async def client(override_get_db):
    app.dependency_overrides[get_db] = override_get_db
    try:
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
            yield client
    finally:
        app.dependency_overrides.clear()

Na pierwszy rzut oka kod wydaje się trudny do zrozumienia z uwagi na wyraźną tendencję do mnożenia fixtur. W rzeczywistości jeśli przejdziemy przez poszczególne funkcje zaczynając od ostatniej wszystko stanie się jasne, ale jest to dowód, że nadmierna modularyzacja nie ułatwia czytania kodu.

Potrzebujemy asynchronicznego klienta http, dzięki któremu będziemy mogli wywołać żądania do endpointów, które są celem testu. Chcemy aby wszelkie operacje na bazie danych wykonywane w trakcie tego żądania odbywały się - nie na bieżącej bazie danych ale - na bazie testowej. W tym celu fixtura client, korzysta z fixtury override_get_db, która dostarcza generatora będącego zamiennikiem funkcji get_db zwracającej połączenie do bazy danych. Jak można się się domyślić nadpisanie następuje w linijce app.dependency_overrides[get_db] = override_get_db, a jest wycofywane w sekcji finally.

Fixtura override_get_db naturalnie dostarcza połączenia do testowej bazy danych, ale utworzeniem tego połączenia, i wycofaniem zmian w bazie po zakończeniu każdego testu zajmuje się fixtura test_db. Ta z kolei potrzebuje silnika bazy danych engine oraz musi być poprzedzona utworzeniem testowej bazy danych czym defacto zajmuje się fixtura prepare_db.

Obie fixtury tj. engine i prepare_db potrzebują dostępu do danych konfiguracyjnych i w ten sposób docieramy do ostatniej z fixtur settings. Znamiennym jest, że fixtury settings oraz prepare_db wykonywane są raz na całą sesję testową (scope="session"), z kolei pozostałe fixtury są już wywoływane dla każdego pojedynczego testu.

Mając komplet fixtur napisanie testów to już formalność.

W pliku ./app/tests/test_example.py

import pytest
import pytest_asyncio
from fastapi import status
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models import Example


@pytest_asyncio.fixture
async def sample_example(test_db: AsyncSession):
    example = Example(name="Some name")
    test_db.add(example)
    await test_db.commit()
    await test_db.refresh(example)
    yield example


@pytest.mark.asyncio
async def test_create_example(client: AsyncClient, test_db: AsyncSession):
    name = "Some name"
    request_data = {"name": name}
    response = await client.post("/v1/examples/", json=request_data)
    examples = await test_db.execute(select(func.count(Example.id)))
    assert examples.scalar_one() == 1
    assert response.status_code == status.HTTP_201_CREATED
    result = response.json()
    assert result["id"] is not None
    assert result["name"] == name


@pytest.mark.asyncio
async def test_get_examples(client: AsyncClient, sample_example: Example):
    response = await client.get("/v1/examples/")
    assert response.status_code == status.HTTP_200_OK
    result = response.json()
    assert result == [{"id": sample_example.id, "name": sample_example.name}]


@pytest.mark.asyncio
async def test_get_example(client: AsyncClient, sample_example: Example):
    response = await client.get(f"/v1/examples/{sample_example.id}")
    assert response.status_code == status.HTTP_200_OK
    result = response.json()
    assert result == {"id": sample_example.id, "name": sample_example.name}


@pytest.mark.asyncio
async def test_get_not_existent_example(client: AsyncClient):
    response = await client.get("/v1/examples/1")
    assert response.status_code == status.HTTP_404_NOT_FOUND


@pytest.mark.asyncio
async def test_update_example(client: AsyncClient, sample_example: Example):
    request_data = {
        "name": "Other name",
    }
    response = await client.put(f"/v1/examples/{sample_example.id}", json=request_data)
    assert response.status_code == status.HTTP_200_OK
    result = response.json()
    assert result == {"id": sample_example.id, "name": "Other name"}


@pytest.mark.asyncio
async def test_delete_example(client: AsyncClient, sample_example: Example):
    response = await client.delete(f"/v1/examples/{sample_example.id}")
    assert response.status_code == status.HTTP_204_NO_CONTENT
    result = response.text
    assert result == ""

Przed uruchomieniem testów warto dodać jeszcze plik ./app/pyproject.toml z prostą konfiguracją.

[project]  
requires-python = ">=3.12"  

  
[tool.pytest.ini_options]  
minversion = "8.0"  
addopts = "-ra -q"  
pythonpath = [  
    '/',  
]

Wywołanie testów

docker compose run --rm fastapi pytest

W rzeczywistości wartość usprawnienia jakie daje mechanizm Depends w FastAPI w zakresie nadpisywania zależności w testach jednostkowych jest naprawdę niewielka i ogranicza się jedynie do testów wywołań samych endpointów. Jeśli testy sprawdzają jedynie jakąś funkcję biznesową - zapytanie sql-owe wykonywane na modelu, albo mechanizm integracji z systemem zewnętrznym - i nie zaczynają się od wykonania żądania do API, to wciąż jesteśmy zmuszeni do stosowania starych i sprawdzonych technik podmiany obiektu, czy funkcji w trakcie wykonywania (monkeypatch).

Podsumowanie DI w FastAPI

Mechanizm Depends w FastAPI wymaga zapoznania się z dokumentacją i przykładami w celu prawidłowego zrozumienia zastosowania i zakresu w jakim powinien być używany. Jest pythoniczny i pod tym względem bardziej przypadł mi do gustu niż rozwiązanie zastosowane w Pytest. Z drugiej jednak strony jest bardziej wyspecjalizowany, a sens jego użycia ogranicza się do funkcji endpointów gdzie następuje rozwiązanie zależności. Wszystko co dzieje się już po wywołaniu endpointa musi dostać zależność z góry, lub też samodzielnie ją utworzyć. Nie zauważyłem aby implementacja Dependency Injection ograniczała funkcjonalność edytora albo utrudniała debugowanie, ale też jestem trochę zawiedziony benefitami jakie rozwiązanie to daje w zakresie testowania.

FastAPI Dependency Injection system został doceniony i doczekał się wydzielenia do osobnego projektu FastDepends, który umożliwia pisanie bibliotek świetnie dających się integrować z frameworkiem, ale które mogą też funkcjonować jako zupełnie niezależne narzędzia uruchamiane poza kontekstem FastAPI.

Akceptuję Ta strona zapisuje niewielkie pliki tekstowe, nazywane ciasteczkami (ang. cookies) na Twoim urządzeniu w celu lepszego dostosowania treści oraz dla celów statystycznych. Możesz wyłączyć możliwość ich zapisu, zmieniając ustawienia Twojej przeglądarki. Korzystanie z tej strony bez zmiany ustawień oznacza zgodę na przechowywanie cookies w Twoim urządzeniu.