FastAPI – czyli jak napisać proste REST API w Pythonie? – część 3

Autor Autor:
Zespół Innokrea
Data publikacji: 2024-08-14
Kategorie: Programowanie

Hej, dzisiaj zapraszamy Was do ostatniej części artykułów o API w Pythonie, w której do naszego projektu dodamy testy napisane w PyTest razem z bazą danych uruchamianą w pamięci oraz nową wersję pliku Dockerfile zdolną uruchomić te testy. Jeśli jesteście ciekawi, to zapraszamy do lektury.

 

Testy

Aby dodać testy do naszego projektu, zaczniemy od skonfigurowania bazy danych mongomock wykonywanej w pamięci. Robimy to po to, aby przetestować nasz kod mając pod spodem prawdziwe połączenie do bazy danych bez inicjalizacji osobnej instancji czy kontenera. Stworzymy bazę w sposób programowy z wykorzystaniem biblioteki mongomock.

W tym celu w folderze tests utworzymy moduł conftest, a w nim pliki __init__.py oraz conftest.py:

conftest.py:

from pymongo.database import Database
from typing import Any, Callable
from pymongo.collection import Collection
import pytest
from fastapi.testclient import TestClient
from mongomock import MongoClient as MockMongoClient
import os
from app.app import app

client = TestClient(app)
API_AUTHENTICATION_PREFIX:str = os.getenv('API_AUTHENTICATION_PREFIX','/api')

@pytest.fixture()
def inmemory_database_creation_function() -> Callable[[], Database[Any]]:
    def db_creation() -> Database[Any]:
        client = MockMongoClient()  
        db: Database[Any] = client['shop']
        collection: Collection[Any] = db['users']
        collection.insert_one({'email': 'aaa@aaa.com',    "role":"user", 'password_hash': '9c520caf74cff9b9a891be3694b20b3586ceb17f2891ceb1d098709c1e0969a3'})
        collection.insert_one({'email': 'bbb@bbb.com',    "role":"user", 'password_hash': '77cd27bc3de668c18ed6be5f5c2909ffdacdf67705c30d132003ad5a89085deb'})
        return db
    return db_creation

 

Teraz napiszmy dwa przykładowe testy dla naszych endpoint’ów /api/register oraz /api/login. Utworzymy w folderze tests plik test_auth.py, a w nim dwa poniższe testy:

from httpx import Response
from fastapi import status
from app import app
from pymongo.database import Database
from typing import Any, Callable
import os
from app.database.connector import Connector
from tests.conftest.conftest import client, app, inmemory_database_creation_function, API_AUTHENTICATION_PREFIX

envs: dict[str, str] = {
    'JWT_ACCESS_TOKEN_SECRET_KEY': 'accesstokenkey',
    'JWT_ACCESS_TOKEN_EXPIRE_MINUTES': '10080',
    'JWT_TOKEN_ALG': 'HS256',
}

def test_given_existing_account_when_logging_in_then_response_parameters_are_ok(
    inmemory_database_creation_function: Callable[[], Database[Any]],
    monkeypatch
) -> None:
    # Update ENV variables
    monkeypatch.setattr(os, 'environ', envs)
    # Mock DB
    app.dependency_overrides[Connector.get_db] = inmemory_database_creation_function

    # Given
    user_data: dict[str, str] = {
        "email": "aaa@aaa.com",
        "password": "aaa@aaa.com",
        "role": "user"
    }
   
    # When
    response: Response = client.post(API_AUTHENTICATION_PREFIX+"/login", json=user_data)
    response_json = response.json()

    # Then
    assert response.status_code == status.HTTP_200_OK
    assert "access_token" in response_json
    assert response.headers["Token-Type"] == "Bearer"

def test_given_proper_user_when_registering_the_user_then_created_request_is_returned(
    inmemory_database_creation_function: Callable[[], Database[Any]],
) -> None:
    # Mock DB
    app.dependency_overrides[Connector.get_db] = inmemory_database_creation_function
   
    # Given
    user_data: dict[str, str] = {
        "email": "test@test.com",
        "password": "password123",
        "role":"user"
    }
    # When
    response: Response = client.post(API_AUTHENTICATION_PREFIX+"/register", json=user_data)

    # Then
    assert response.status_code == status.HTTP_201_CREATED
    assert response.json() == {"email": "test@test.com", "role":"user"}

 

Dodane testy są testami E2E, gdzie testujemy funkcjonalność od wysłania żądania do uzyskania odpowiedzi przez wszystkie warstwy abstrakcji, włącznie z bazą danych (zainicjalizowaną w pamięci). Pierwszy test sprawdza poprawność logowania, gdy użytkownik loguje się danymi dostępnymi w bazie danych (tzn. poprawnymi), a drugi poprawność rejestracji. Oba scenariusze reprezentują tzw. ‘happy path’, czyli poprawne żądanie klienta.

Na tym etapie można uruchomić już testy z użyciem komendy python -m pytest. Może być także wymagana instalacja biblioteki pytest z poziomu lokalnego środowiska (nie skonteneryzowanego). Wtedy należy użyć komendy pip install pytest.

 

FastAPI - uruchomienie testów lokalnie

Rysunek 1 – uruchomienie testów lokalnie

 

Użycie dockera do uruchomienia przetestowanego oprogramowania

Teraz, w celu uruchomienia przetestowanego kodu w Dockerze użyjemy tzw. 2-stage-build oraz dwóch wersji plików requirements – jednego z bibliotekami do testów, a drugiego do uruchomienia – bez zbędnych bibliotek. W tym celu utworzymy pliki Dockerfile.prod, docker-compose-prod.yml, requirements.prod, które wszystkie utworzymy w folderze głównym.

Dockerfil.prod:

FROM python:3.11-slim as tester
WORKDIR /app
COPY requirements.dev .
RUN apt-get update && \
apt-get install -y python3-pip && \
 pip3 install pytest && \
 pip3 install --no-cache-dir -r requirements.dev
WORKDIR /app
COPY . .
RUN [ "python", "-m", "pytest", "--junit-xml", "/app/test.xml"]

FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
WORKDIR /app
COPY requirements.prod .
RUN apt-get update && \
 apt-get install -y python3-pip && \
 pip3 install pytest && \
 pip3 install --no-cache-dir -r requirements.prod
COPY --from=tester /app/test.xml .
COPY ./app /app/app
WORKDIR /app
EXPOSE 8000
CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000","--reload"]

 

Widzimy tutaj 2-stage-build, gdzie w pierwszej części mamy testowanie z użyciem pytest, a w drugiej instalacja ograniczonej wersji wymagań oraz kopiowanie wyniku testów w postaci xml do kontenera.

docker-compose-prod.yml:

version: "3.9"
services:
  rest-service:
    build:
      context: .
      # Prod version of Dockerfile
      dockerfile: Dockerfile.prod
    container_name: rest-service
    restart: always
    environment:
      - WATCHFILES_FORCE_POLLING=true
      - DB_HOSTNAME=db
      - DB_USERNAME=root
      - DB_PASSWORD=root
      - DB_PORT=27017
      - DB_NAME=db

      # JWT CONF
      - JWT_TOKEN_ALG=HS256
      - JWT_ACCESS_TOKEN_SECRET_KEY=accesssecret
      - JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080

    ports:
      - "8000:8000"
    networks:
      - network

  db:
    image: bitnami/mongodb:7.0.7-debian-12-r0
    container_name: db
    restart: always
    environment:
      - MONGODB_REPLICA_SET_MODE=primary
      - MONGODB_REPLICA_SET_KEY=123456
      - ALLOW_EMPTY_PASSWORD=yes
      - MONGODB_ROOT_USER=root
      - MONGODB_ROOT_PASSWORD=root
    healthcheck:
      test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/db --quiet
      interval: 10s
      timeout: 10s
      retries: 3
      start_period: 20s
    volumes:
      - ./db-init.js:/docker-entrypoint-initdb.d/initialize.js
    networks:
      - network
networks:
  network:

 

Plik compose w wersji prod nie posiada bind mount, a całość kodu jest kopiowana z użyciem Dockerfile.prod. Wyrzuciliśmy także panel administratora, bo nie jest on potrzebny w tej wersji.

requirements.prod:

annotated-types==0.6.0
pydantic==2.4.2
uvicorn==0.23.2
fastapi==0.103.1
pymongo==4.6.2
pyjwt==2.8.0
passlib==1.7.4
httpx==0.27.0
aiokafka==0.8.0
asyncio==3.4.3

 

Widzimy, że w pliku jest mniej bibliotek, ponieważ usuwamy te związane z testami.

 

FastAPI - ostateczna struktura projektu

Rysunek 2 – ostateczna struktura projektu

 

Uruchomienie

Aby uruchomić wersję prod z testami w kontenerze możemy wykonać komendę:

docker-compose -f docker-compose-prod.yml up --build

 

Po uruchomieniu, jeśli wszystko poszło zgodnie z planem, powinniśmy uzyskać plik xml z wynikami testów, który możemy uzyskać na własny komputer z użyciem komendy:

docker cp rest-service:/app/test.xml

 

FastAPI - pobranie z kontenera pliku testowego i odzyskanie pliku z wynikami testów

Rysunek 3 – pobranie z kontenera pliku testowego i odzyskanie pliku z wynikami testów

 

Podsumowanie

To już wszystko co dla Was przygotowaliśmy w ramach tej serii. Mamy nadzieję, że dzięki naszemu artykułowi uda Wam się stworzyć dobrze zorganizowany projekt w FastAPI i poprawnie skonfigurować do niego testy. Zachęcamy także do zapoznania się z naszymi artykułami na temat Docker’a. Do usłyszenia!

 

Kod do pobrania na naszym gitlabie!

Zobacz więcej na naszym blogu:

Wzorce projektowe – część 2

Wzorce projektowe – część 2

Hej, hej... Programisto, to kolejny artykuł dla Ciebie! Druga część artykułu na temat wzorców projektowych. Poznaj Adapter oraz Memento.

Programowanie

Wzorce projektowe – część 1

Wzorce projektowe – część 1

Programisto, to artykuł dla Ciebie! Łap garść przydatnych informacji na temat wzorców projektowych.

Programowanie

Blockchain – płatności w świecie kryptowalut

Blockchain – płatności w świecie kryptowalut

Blockchainie - poznaj świat transakcji, kryptowalut i elektronicznych płatności.

BezpieczeństwoFinanse