Hej, hej... Programisto, to kolejny artykuł dla Ciebie! Druga część artykułu na temat wzorców projektowych. Poznaj Adapter oraz Memento.
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
.
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.
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
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!