Pytest를 활용하여 FastAPI 및 Flask API 테스트 가속화하기
Lukas Schneider
DevOps Engineer · Leapcell

소개
빠르게 변화하는 백엔드 개발 세계에서 강력하고 안정적인 API를 제공하는 것은 매우 중요합니다. FastAPI로 마이크로서비스를 구축하든 Flask로 기존 웹 애플리케이션을 구축하든, 다양한 조건에서 코드가 예상대로 작동하도록 보장하는 것은 필수적입니다. 이것이 바로 효과적인 테스트가 중요한 이유입니다. 많은 개발자가 테스트의 중요성을 이해하고 있지만, 좋고 유지보수 가능하며 효율적인 테스트를 작성하는 것은 종종 지루한 작업으로 느껴질 수 있습니다. 이 글은 강력하고 유연한 테스트 프레임워크인 Pytest를 활용하여 FastAPI 및 Flask 애플리케이션을 위한 고품질 단위 테스트를 작성하는 과정을 명확히 하는 것을 목표로 합니다. 코드 품질을 향상시킬 뿐만 아니라 개발 워크플로를 간소화할 도구와 기술을 살펴보겠습니다.
효과적인 API 테스트를 위한 핵심 개념
실제 사례를 살펴보기 전에, 논의의 중심이 되는 몇 가지 주요 용어에 대한 공통된 이해를 확립해 봅시다.
단위 테스트
단위 테스트는 일반적으로 개별 함수 또는 메서드와 같이 애플리케이션의 가장 작은 테스트 가능한 부분을 다른 구성 요소와 격리하여 테스트하는 데 중점을 둡니다. 목표는 코드의 각 단위가 의도한 기능을 올바르게 수행하는지 확인하는 것입니다.
통합 테스트
통합 테스트는 애플리케이션 내의 다른 모듈 또는 서비스가 서로 올바르게 상호 작용하는지 확인합니다. API의 경우, 이는 데이터베이스 상호 작용이나 외부 API 호출을 포함하여 전체 요청-응답 주기를 테스트하는 것을 의미할 수 있습니다.
Pytest
Pytest는 Python에서 인기 있고 성숙한 테스트 프레임워크입니다. 간단함, 확장성, 그리고 테스트 작성 및 실행을 매우 효율적으로 만드는 픽스처, 매개변수화 및 플러그인 아키텍처와 같은 강력한 기능으로 알려져 있습니다.
픽스처
Pytest 픽스처는 테스트를 실행할 기본 환경을 설정하고 테스트가 끝난 후 정리할 수 있는 함수입니다. 테스트 클라이언트, 데이터베이스 연결 또는 모의 객체를 제공하는 것과 같이 테스트 종속성을 관리하는 강력한 방법입니다.
Monkeypatching
Monkeypatching은 런타임에 클래스 또는 모듈을 동적으로 수정하는 것입니다. 테스트에서 종종 시스템의 일부를 모의 객체 또는 단순화된 구현으로 교체하여 테스트 중인 단위를 외부 종속성으로부터 격리하는 데 사용됩니다.
Mocking
Mocking은 실제 객체의 동작을 흉내 내는 시뮬레이션된 객체를 만드는 것을 포함합니다. Mock은 일반적으로 외부 서비스, 데이터베이스 또는 격리된 테스트 환경에서 제어하기 어려운 복잡한 구성 요소를 대체하는 데 사용됩니다.
FastAPI 및 Flask 애플리케이션 테스트 원칙
웹 애플리케이션, 특히 단위 테스트를 사용하여 테스트하는 기본 원칙은 테스트 중인 로직을 격리하는 것입니다. FastAPI 및 Flask의 경우, 이는 실행 중인 서버와 독립적으로 라우트 핸들러, 비즈니스 로직 및 유틸리티 함수를 테스트하는 것을 의미합니다. Pytest는 이를 달성하기 위한 훌륭한 도구를 제공합니다.
FastAPI 애플리케이션 테스트
FastAPI 애플리케이션은 Starlette을 기반으로 구축되었으며, Starlette은 라이브 서버를 실행하지 않고도 애플리케이션에 동기식 및 비동기식 HTTP 요청을 보낼 수 있는 APITestClient
를 제공합니다. 이 클라이언트는 API 엔드포인트와 상호 작용하는 통합 스타일의 단위 테스트에 이상적입니다.
간단한 FastAPI 애플리케이션을 고려해 봅시다:
# main.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import List, Dict app = FastAPI() class Item(BaseModel): name: str price: float db: Dict[str, Item] = {} @app.post("/items/", response_model=Item) async def create_item(item: Item): if item.name in db: raise HTTPException(status_code=400, detail="Item already exists") db[item.name] = item return item @app.get("/items/{item_name}", response_model=Item) async def read_item(item_name: str): if item_name not in db: raise HTTPException(status_code=404, detail="Item not found") return db[item_name] @app.get("/items/", response_model=List[Item]) async def read_all_items(): return list(db.values())
이제 이에 대한 몇 가지 Pytest 테스트를 작성해 봅시다:
# test_main.py import pytest from fastapi.testclient import TestClient from main import app, db, Item # 각 테스트 전에 데이터베이스를 비웁니다. @pytest.fixture(autouse=True) def clear_db(): db.clear() yield @pytest.fixture(scope="module") def client(): with TestClient(app) as c: yield c def test_create_item(client): response = client.post("/items/", json={"name": "apple", "price": 1.99}) assert response.status_code == 200 assert response.json() == {"name": "apple", "price": 1.99} assert "apple" in db assert db["apple"].dict() == {"name": "apple", "price": 1.99} def test_create_existing_item(client): client.post("/items/", json={"name": "apple", "price": 1.99}) response = client.post("/items/", json={"name": "apple", "price": 2.99}) assert response.status_code == 400 assert response.json() == {"detail": "Item already exists"} def test_read_item(client): client.post("/items/", json={"name": "banana", "price": 0.79}) response = client.get("/items/banana") assert response.status_code == 200 assert response.json() == {"name": "banana", "price": 0.79} def test_read_non_existent_item(client): response = client.get("/items/grape") assert response.status_code == 404 assert response.json() == {"detail": "Item not found"} def test_read_all_items(client): client.post("/items/", json={"name": "orange", "price": 0.50}) client.post("/items/", json={"name": "pear", "price": 0.90}) response = client.get("/items/") assert response.status_code == 200 assert len(response.json()) == 2 assert {"name": "orange", "price": 0.50} in response.json() assert {"name": "pear", "price": 0.90} in response.json()
여기서 client
픽스처는 TestClient
인스턴스를 제공하여 시뮬레이션된 브라우저 역할을 합니다. clear_db
픽스처는 각 테스트에 대해 인메모리 데이터베이스가 깨끗하도록 보장하여 테스트 간의 간섭을 방지합니다.
Flask 애플리케이션 테스트
Flask 또한 애플리케이션에 대한 요청을 시뮬레이션할 수 있는 테스트 클라이언트를 제공합니다.
기본 Flask 애플리케이션을 고려해 봅시다:
# app.py from flask import Flask, jsonify, request, abort app = Flask(__name__) db = {} # 인메모리 데이터베이스 @app.route("/items", methods=["POST"]) def create_item(): item_data = request.get_json() name = item_data.get("name") price = item_data.get("price") if not name or not price: abort(400, "Name and price are required") if name in db: abort(409, "Item already exists") # Conflict db[name] = {"name": name, "price": price} return jsonify(db[name]), 201 @app.route("/items/<string:item_name>", methods=["GET"]) def get_item(item_name): item = db.get(item_name) if not item: abort(404, "Item not found") return jsonify(item), 200 @app.route("/items", methods=["GET"]) def get_all_items(): return jsonify(list(db.values())), 200 if __name__ == "__main__": app.run(debug=True)
그리고 해당 Pytest 테스트:
# test_app.py import pytest from app import app, db @pytest.fixture(autouse=True) def clear_db(): db.clear() yield @pytest.fixture(scope="module") def client(): app.config['TESTING'] = True with app.test_client() as client: yield client def test_create_item(client): response = client.post("/items", json={"name": "laptop", "price": 1200.00}) assert response.status_code == 201 assert response.json == {"name": "laptop", "price": 1200.00} assert "laptop" in db assert db["laptop"] == {"name": "laptop", "price": 1200.00} def test_create_item_missing_data(client): response = client.post("/items", json={"name": "keyboard"}) assert response.status_code == 400 assert "Name and price are required" in response.get_data(as_text=True) def test_create_item_existing(client): client.post("/items", json={"name": "mouse", "price": 25.00}) response = client.post("/items", json={"name": "mouse", "price": 30.00}) assert response.status_code == 409 assert "Item already exists" in response.get_data(as_text=True) def test_get_item(client): client.post("/items", json={"name": "monitor", "price": 300.00}) response = client.get("/items/monitor") assert response.status_code == 200 assert response.json == {"name": "monitor", "price": 300.00} def test_get_non_existent_item(client): response = client.get("/items/webcam") assert response.status_code == 404 assert "Item not found" in response.get_data(as_text=True) def test_get_all_items(client): client.post("/items", json={"name": "desk", "price": 150.00}) client.post("/items", json={"name": "chair", "price": 75.00}) response = client.get("/items") assert response.status_code == 200 assert len(response.json) == 2 assert {"name": "desk", "price": 150.00} in response.json assert {"name": "chair", "price": 75.00} in response.json
Flask의 client
픽스처는 app.test_client()
를 사용하여 테스트 클라이언트를 가져오고, app.config['TESTING'] = True
는 Flask를 테스트 모드로 설정하여 오류 처리 및 기타 동작에 영향을 줄 수 있습니다. clear_db
픽스처는 FastAPI 예제와 동일한 목적을 수행합니다.
Mocking 및 의존성 주입
더 복잡한 시나리오, 특히 API가 외부 서비스(데이터베이스, 타사 API, 메시지 큐)와 상호 작용하는 경우, 모킹은 진정한 단위 테스트에 매우 중요합니다. Pytest는 unittest.mock
(Python 표준 라이브러리의 일부)과 결합하여 강력한 모킹 기능을 제공합니다.
데이터베이스와 상호 작용하는 서비스 계층을 고려해 봅시다:
# services.py class Database: def get_user(self, user_id: str): # 여기에 복잡한 데이터베이스 로직이 있다고 상상해 보세요. if user_id == "123": return {"id": "123", "name": "Alice"} return None def create_user(self, user_data: dict): # 여기에 데이터베이스 삽입 로직이 있다고 상상해 보세요. return {"id": "new_id", **user_data} db_service = Database() # FastAPI/Flask 앱에서: # from .services import db_service # @app.get("/users/{user_id}") # async def get_user_route(user_id: str): # user = db_service.get_user(user_id) # if not user: # raise HTTPException(status_code=404, detail="User not found") # return user
실제 데이터베이스를 사용하지 않고 get_user_route
를 효과적으로 테스트하려면:
# test_services.py from unittest.mock import MagicMock import pytest from main import app # main.py가 db_service를 가져온다고 가정합니다. from services import db_service # 앱이 db_service를 사용한다고 가정합니다. @pytest.fixture def mock_db_service(monkeypatch): mock = MagicMock() monkeypatch.setattr("main.db_service", mock) # main.py의 가져온 인스턴스를 패치합니다. return mock def test_get_user_route_exists(client, mock_db_service): mock_db_service.get_user.return_value = {"id": "456", "name": "Bob"} response = client.get("/users/456") assert response.status_code == 200 assert response.json() == {"id": "456", "name": "Bob"} mock_db_service.get_user.assert_called_once_with("456") def test_get_user_route_not_found(client, mock_db_service): mock_db_service.get_user.return_value = None response = client.get("/users/unknown") assert response.status_code == 404 assert response.json() == {"detail": "User not found"}
이 예제에서는 monkeypatch
를 사용하여 main
모듈 내의 db_service
인스턴스를 MagicMock
으로 교체합니다. 이를 통해 db_service
메서드의 반환 값을 제어하고 올바르게 호출되었는지 확인할 수 있습니다.
결론
FastAPI 및 Flask 애플리케이션에 대한 효율적인 단위 테스트 작성은 버그를 잡는 것뿐만 아니라 코드에 대한 확신을 구축하고, 리팩토링을 촉진하며, 개발을 가속화하는 것입니다. 픽스처와 같은 Pytest의 강력한 기능과 테스트 클라이언트 및 모킹 기술을 활용함으로써 API의 안정성과 정확성을 보장하는 강력한 테스트 스위트를 만들 수 있습니다. 궁극적으로 잘 테스트된 백엔드는 복원력 있고 유지보수 가능한 백엔드입니다.