단위 테스트: 애플리케이션의 개별 컴포넌트를 테스트하는 절차, 개별 컴포넌트의 기능을 검증하기 위해 수행된다.
개별 라우트가 적절한 응답을 반환하는지 테스트하기 위해 단위 테스트를 도입할 수 있다.
pytest: 파이썬 테스트 라이브러리
pip install pytest
테스트 파일을 한 곳에 관리.
mkdir tests && cd tests
touch __init__.py
테스트 파일을 만들 때는 파일명 앞에 'test_'를 붙여야 한다.
그럼 해당 파일이 테스트 파일이라는 것을 pytest 라이브러리가 인식해서 실행한다.
예시: 사칙연산 확인
touch test_aritmetic_operations.py
사칙연산 함수 정의
def add(a: int, b: int) -> int:
return a + b
def subtract(a: int, b: int) -> int:
return b - a
def multiply(a: int, b: int) -> int:
return a * b
def divide(a: int, b: int) -> int:
return b // a
위의 함수는 테스트 대상 함수이다.
테스트 대상 함수를 테스트하는 함수를 만들어야 한다.
테스트 함수는 계산 결과가 맞는지 검증하는 역할을 한다.
assert 키워드는 식의 왼쪽에 있는 값이 오른쪽에 있는 처리 결과와 일치하는지 검증할 때 사용된다.
테스트 함수 정의
def test_add() -> None:
assert add(1, 1) == 2
def test_subtract() -> None:
assert subtract(2, 5) == 3
def test_muliply() -> None:
assert muliply(10, 10) == 100
def test_divide() -> None:
assert divide(25, 100) == 4
일반적으로 테스트 파일이 아닌 별도의 파일에 테스트 대상 함수를 정의한다.
그런 다음 이 파일을 테스트 파일에 import하여 테스트를 수행한다.
테스트는 pytest 명령을 사용해 실행한다.
이 명령은 명령을 실행하는 위치에 있는 모든 테스트 파일을 실행한다.
테스트를 하나만 실행하려면 파일명을 인수로 지정해야 한다.
pytest test_arithmetic_operations.py
테스트가 모두 성공하면 통과된 테스트는 터미널 창에 초록색으로 표시된다.
테스트가 실패하면 터미널 창 위에 실패한 위치가 빨간색으로 표시된다.
예를 들어 test_add() 함수를 다음과 같이 변경하면 테스트에 실패한다.
def test_add() -> None:
assert add(1, 1) == 11
픽스처는 재사용할 수 있는 함수로, 테스트 함수에 필요한 데이터를 반환하기 위해 정의된다.
pytest.fixtrue 데코레이터를 사용해 픽스처를 정의할 수 있으며 API 라우트 테스트 시 애플리케이션 인스턴스를 반환하는 경우 등에 사용된다.
테스트 함수가 사용하는 애플리케이션 클라이언트를 픽스처로 정의할 수 있기 때문에 테스트할 때마다 애플리케이션 인스턴스를 다시 정의하지 않아도 된다.
import pytest
from models.events import EventUpdate
#픽스처 정의
@pytest.fixture
def event() -> EventUpdate:
return EventUpdate(
title = "FastAPI Book Launch"
image = "https://packt.com/fastapi.png",
description = "We will be discussing the contents of the FastAPI book in this event. Ensure to come with your own copy to win gifts!",
tags = ["python", "fastapi", "book", "launch"],
location = "Google Meet"
)
def test_event_name(event: EventUpdate) -> None:
assert event.title == "FastAPI Book Launch"
이 코드는 EventUpdate pydantic 모델의 인스턴스를 반환하는 픽스처를 정의한다. 이 픽스처는 test_event_name() 함수의 인수로 사용되며 이벤트 속성에 접근할 수 있게 해준다.
픽스처 데코레이터는 인수를 선택적으로 받을 수 있다.
예를 들어 scope 인수는 픽스처 함수의 유효 범위를 지정할 때 사용된다.
CRUD 처리용 라우트와 사용자 인증 테스트
비동기 API를 테스트하려면 httpx와 pytest-asyncio 라이브러리를 설치해야 한다.
pip install httpx pytest-asyncio
설치가 완료되면 pytest.ini라는 설정 파일을 만들어야 한다.
파일을 루트 폴더(main.py가 있는 폴더)에 생성한 후 코드 추가:
[pytest[
asyncio_mode = auto
pytest가 실행될 때 이 파일의 내용을 불러온다. 이 설정은 pytest가 모든 테스트를 비동기식으로 실행한다는 의미다.
설정 파일이 준비됐으니 tests 폴더 아래에 테스트 시작점이 될 conftest.py 파일을 만든다. conftest.py 파일은 테스트 파일이 필요로 하는 애플리케이션의 인스턴스를 만든다.
touch tests/conftest.py
conftest.py 파일에 의존 라이브러리를 import 한다.
import asyncio
import httpx
import pytest
from main import app
from database.connection import Settings
from models.events import Event
from models.users import User
asyncio 모듈은 활성 루프 세션을 만들어서 테스트가 단일 스레드로 실행되도록 한다.
httpx 테스트는 HTTP CRUD 처리를 실행하기 위한 비동기 클라이언트 역할을 한다. pytest 라이브러리는 픽스처 정의를 위해 사용된다. 또한 애플리케이션 인스턴스(app), Settings 클래스, 모델도 import 한다.
루프 세션 픽스처 정의:
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
loop.close()
Settings 클래스에서 새로운 데이터베이스 인스턴스를 만든다.
async def init_db():
test_settings = Settings()
test_settings.DATABASE_URL = "mongodb://localhost:27017/testdb"
await test_settings.initialize_database()
기본 클라이언트 픽스처를 정의:
이 픽스처는 httpx를 통해 비동기로 실행되는 애플리케이션 인스턴스를 반환한다.
@pytest.fixture(scope="session")
async def default_client():
await init_db()
async with httpx.AsyncClient(app=app, base_url="http://app") as client:
yield client
# 리소스 정리
await Event.find_all().delete()
await User.find_all().delete()
데이터베이스를 초기화한 후에 애플리케이션을 AsyncClient로 호출한다.
AsyncClient는 테스트 세션이 끝날 때까지 유지된다.
테스트 세션이 끝나면 이벤트(Event)와 사용자(User) 컬렉션의 데이터를 모두 삭제하여 테스트를 실행할 때마다 데이터베이스가 비어있도록 한다.
touch tests/test_login.py
import httpx
import pytest
라우트 테스트
pytest.mark.asyncio 데코레이터를 추가해서 비동기 테스트라는 것을 명시
@pytest.mark.asyncio
async def test_sign_new_user(default_client: httpx.AsyncClient) -> None:
payload = {
"email": "testuser@packt.com",
"password": "testpassword",
}
headers = {
"accept": "application/json"
"Content-Type": "application/json"
}
test_response = {
"message": "User created successfully"
}
response = await default_client.post("/user/signup", json=payload, headers=headers)
assert response.status_code == 200
assert response.json() == test_response
pytest tests/test_login.py
파이썬 버전에 따라 import문이 인식되지 않아서 실행되지 않을 수 있기에 이 경우에는 다음과 같이 실행한다:
python -m pytest tests/test_login.py
@pytest.mark.asyncio
async def test_sign_user_in(default_client: httpx.AsyncClient) -> None:
payload = {
"username": "testuser@packt.com",
"password": "testpassword"
}
headers = {
"accept": "applcation/json",
"Content-Type": "application/json"
}
response = await default_client.post("/user/signin", data=payload, headers=headers)
assert response.status_code == 200
assert response.json()["token_type"] == "Bearer"
pytest tests/test_login.py
touch tests/test_routes.py
import httpx
import pytest
from auth.jwt_handler import create_access_token
from models.events import Event
몇몇 라우트는 보안이 적용되므로 access token을 생성해야 한다. 따라서 새로운 픽스처를 만드로 access token을 반환하도록 한다. 이 픽스처는 module 범위를 갖는다.
테스트 파일이 실행될 때 한 번만 실행되고 다른 함수가 호출될 때에는 실행되지 않는다.
다음 코드를 test_routes.py 파일에 추가
@pytest.fixture(scope="module")
async def access_token() -> str:
return create_access_token("testuser@packt.com")
@pytest.fixture(scope="module")
async def mock_event() -> Event:
new_event = Event(
creator="testuser@packt.com",
title="FastAPI Book Launch",
image="https://linktomyimage.com/image.png",
description="We will be discussing the contents of the FastAPI book in this event. Ensure to come with your own copy to win gifts!",
tags=["python", "fastapi", "book", "launch"],
location="Google Meet"
)
await Event.insert_one(new_event)
yield new_event
@pytest.mark.asyncio
async def test_get_events(default_client: httpx.AsyncClient, mock_event: Event) -> None:
response = await default_client.get("/event/")
assert response.status_code == 200
assert response.json()[0]["_id"] == str(mock_event.id)
pytest tests/test_routes.py
/event/{id} 라우트 테스트:
@pytest.mark.asyncio
async def test_get_event(default_client: httpx.AsyncClient, mock_event: Event) -> None:
url = f"/event/{str(mock_event.id)}"
response = await default_client.get(url)
assert response.status_code == 200
assert response.json()["creator"] == mock_event.creator
assert response.json()["_id"] == str(mock_event.id)
위 코드는 단일 이벤트를 추출하는 데 사용되는 라우트를 테스트한다.
전달된 이벤트 ID는 mock_event 픽스처에서 추출되며 실행한 요청의 결과를 mock_event 픽스처에 저장된 데이터와 비교한다.
pytest tests/test_routes.py
픽스처를 사용해 access 토큰을 추출하고 테스트 함수를 정의한다. 이 함수는 서버로 전송될 요청 페이로드를 생성한다. 요청 페이로드에는 콘텐츠 유형과 인증 헤더가 포함된다.
테스트 응답도 정의되는데 요청이 실행되면 실제 결과와 응답이 비교된다.
@pytest.mark.asyncio
async def test_post_event(default_client: httpx.AsyncClient, access_token: str) -> None:
payload = {
"title": "FastAPI Book Launch",
"image": "https://linktomyimage.com/image.png",
"description": "We will be discussing the contents of the FastAPI book in this event. Ensure to come with your own copy to win gifts!",
"tags": ["python", "fastapi", "book", "launch"],
"location": "Google Meet",
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}"
}
test_response = {
"message": "Event created successfully."
}
response = await default_client.post("/event/new", json=payload, headers=headers)
assert response.status_code == 200
assert response.json() == test_response
테스트 실행
데이터베이스에 저장된 이벤트 개수를 확인하기 위한 테스트:
@pytest.mark.asyncio
async def test_get_events_count(default_client: httpx.AsyncClient) -> None:
response = await default_client.get("/event/")
events = response.json()
assert response.status_code == 200
assert len(events) == 2
위 코드는 JSON 응답을 events라는 변수에 저장하고 events의 길이가 예상한 값과 일치하는지 확인한다.
테스트 실행
@pytest.mark.asyncio
async def test_update_event(default_client: httpx.AsyncClient, mock_event: Event, access_token: str) -> None:
test_payload = {
"title": "FastAPI Book Launch"
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}"
}
url = f"/event/{str(mock_event.id)}"
response = await default_client.put(url, json=test_payload, headers=headers)
assert response.status_code == 200
assert response.json()["title"] == test_payload["title"]
위 코드는 mock_event fixture에서 추출한 ID를 사용해 데이터베이스에 저장된 해당 이벤트를 수정한다. 그런 다음 요청 페이로드와 헤더를 정의하고 response 변수에 요청 결과를 저장한다. 마지막으로 이 결과가 예상한 값과 일치하는지 확인한다.
테스트 실행
mock_event fixture는 문서(이벤트)를 데이터베이스에 추가할 때마다 고유한 문서 ID를 생성해주기 때문이다.
@pytest.mark.asyncio
async def test_delete_event(default_client: httpx.AsyncClient, mock_event: Event, access_token: str) -> None:
test_response = {
"message": "Evnet deleted successfully."
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}"
}
url = f"/event/{str(mock_event.id)}"
response = await default_client.delete(url, headers=headers)
assert response.status_code == 200
assert response.json() == test_response
@pytest.mark.asyncio
async def test_get_event_again(default_client: httpx.AsyncClient, mock_event: Event, access_token: str) -> None:
url = f"/event/{str(mock_event.id)}"
response = awiat default_client.get(url)
assert response.status_code == 200
assert response.json()["creator"] == mock_event.creator
assert response.json()["_id"} == str(mock_event.id)
이 테스트는 삭제됐는지 확인하기 위한 것이므로 실패하는 것이 맞다.
애플리케이션의 전체 테스트 실행:
pytest
테스트 커버리지 보고서는 테스트가 전체 애플리케이션 코드 중 어느 정도 비율의 코드를 테스트하는지 정량화해서 보여준다.
coverage 모듈을 설치해서 우리가 만든 API가 적절하게 테스트되고 있는지 확인
pip install coverage
다음 명령을 실행해서 테스트 커버리지 보고서를 생성:
coverage run -m pytest
보고서는 터미널과 HTML로 생성된 웹 페이지 이렇게 두 가지 형식으로 볼 수 있다.
터미널상에 표시된 보고서를 볼 수 있다.
coverage report
이 보고서는 테스트를 통해 실행된(테스트에 사용된) 코드의 비율을 보여준다.
HTML 보고서 보기:
사용된 코드 블록까지 보여준다
coverage html
htmlcov 폴더에 생성된 index.html 파일을 브라우저로 열어보면 웹 페이지 형식의 보고서를 볼 수 있다.