Pytest에서 pytest-mock를 이용한 외부 종속성 시뮬레이션
Wenhao Wang
Dev Intern · Leapcell

소개
현대 소프트웨어 개발에서 애플리케이션은 거의 독립적으로 작동하지 않습니다. REST API, 서드파티 라이브러리, 데이터베이스와 같은 외부 서비스와 자주 상호 작용합니다. 이러한 종속성은 애플리케이션 기능에 중요하지만, 단위 및 통합 테스트에 상당한 어려움을 초래합니다. 테스트 중에 실제 외부 시스템에 의존하는 것은 테스트 속도를 늦추고, 네트워크 문제나 서비스 중단으로 인한 불안정성을 유발하며, API 사용 비용을 발생시킬 수도 있습니다. 이럴 때 '모킹(Mocking)'이라는 개념이 중요해집니다. 이러한 외부 종속성을 시뮬레이션함으로써 우리는 제어 가능하고 예측 가능하며 빠른 테스트 환경을 만들 수 있습니다. 이 글에서는 Pytest의 강력한 플러그인인 pytest-mock를 활용하여 외부 API 및 데이터베이스 호출을 효과적으로 모킹하는 방법을 탐구하고, Python 애플리케이션의 견고하고 효율적인 테스트를 보장합니다.
핵심 개념 이해
pytest-mock를 사용한 실제 구현에 들어가기 전에 몇 가지 기본 개념을 명확히 합시다.
- 모킹 (Mocking): 테스트에서 모킹은 실제 객체나 함수를 실제 객체의 동작을 시뮬레이션하는 대체 객체로 교체하는 것을 포함합니다. "모크 객체" 또는 단순히 "모크"라고 불리는 이 대체 객체를 사용하면 실제 코드를 호출하지 않고도 반환 값 제어, 예외 발생, 상호 작용 모니터링을 할 수 있습니다.
- 스텁빙 (Stubbing): 모킹과 자주 혼용되지만, 스텁빙은 특히 테스트 중에 발생하는 메소드 호출에 대해 미리 프로그래밍된 응답을 제공하는 것을 의미합니다. 스텁의 주요 목적은 테스트 중에 발생하는 동작을 검증하는 것이 아니라, 미리 정해진 답변을 반환하는 것입니다.
- 스파잉 (Spying): 스파잉은 실제 객체나 함수를 그대로 두고 원래의 동작을 허용하면서 해당 객체나 함수를 감싸는 것을 포함합니다. 그런 다음 스파이는 래핑된 객체와의 모든 상호 작용을 기록하여 특정 메소드가 특정 인수로 호출되었는지 확인할 수 있습니다. pytest-mock는 주로 모킹에 중점을 두지만, 상호 작용을 관찰하는 데까지 기능을 확장할 수 있습니다.
- 단위 테스트 (Unit Testing): 소프트웨어 애플리케이션의 개별 단위 또는 구성 요소를 격리하여 테스트하는 데 중점을 둡니다. 여기서 모킹은 테스트 대상 단위를 종속성으로부터 격리하는 데 중요합니다.
- 통합 테스트 (Integration Testing): 애플리케이션의 서로 다른 단위 또는 구성 요소 간의 상호 작용을 검증합니다. 통합 테스트는 일부 실제 종속성을 포함할 수 있지만, 변동성이 매우 크거나 비용이 많이 드는 외부 서비스에 모킹을 사용할 수 있습니다.
pytest-mock는 Python의 내장 unittest.mock 라이브러리를 편리하게 감싸는 Pytest fixture입니다. Pytest의 강력한 테스트 프레임워크와 원활하게 통합되어 테스트 내에서 모크 객체를 관리하는 깔끔하고 직관적인 방법을 제공합니다.
pytest-mock를 이용한 외부 종속성 시뮬레이션
pytest-mock 사용의 핵심 원칙은 외부 서비스와 상호 작용하는 실제 객체나 함수를 제어되는 모크 객체로 교체하는 것입니다. 이는 일반적으로 pytest-mock fixture가 제공하는 mocker.patch() 메소드를 사용하여 수행됩니다.
실용적인 예를 들어 보겠습니다. 원격 API에서 사용자 데이터를 가져오는 Python 함수와 데이터베이스에 데이터를 저장하는 또 다른 함수가 있다고 가정해 보겠습니다.
애플리케이션 코드 (예: app.py):
import requests import sqlite3 class User: def __init__(self, user_id, name, email): self.user_id = user_id self.name = name self.email = email def __repr__(self): return f"User(id={self.user_id}, name={self.name}, email={self.email})" def fetch_user_from_api(user_id): """Placeholder API에서 사용자 데이터를 가져옵니다.""" api_url = f"https://jsonplaceholder.typicode.com/users/{user_id}" try: response = requests.get(api_url) response.raise_for_status() # 잘못된 상태 코드에 대해 예외 발생 data = response.json() return User(data['id'], data['name'], data['email']) except requests.exceptions.RequestException as e: print(f"사용자 가져오기 오류: {e}") return None def save_user_to_db(user): """SQLite 데이터베이스에 사용자 데이터를 저장합니다.""" conn = None try: conn = sqlite3.connect('users.db') cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL ) ''') cursor.execute("INSERT INTO users (id, name, email) VALUES (?, ?, ?)", (user.user_id, user.name, user.email)) conn.commit() return True except sqlite3.Error as e: print(f"DB에 사용자 저장 오류: {e}") return False finally: if conn: conn.close() def get_and_save_user(user_id): """사용자를 가져와 데이터베이스에 저장합니다.""" user = fetch_user_from_api(user_id) if user: return save_user_to_db(user) return False
이제 jsonplaceholder.typicode.com에 실제로 연결하거나 실제 users.db 파일을 생성하지 않고 get_and_save_user에 대한 테스트를 작성해 봅시다.
pytest-mock를 사용한 테스트 (예: test_app.py):
먼저 pytest와 pytest-mock를 설치했는지 확인하세요:
pip install pytest pytest-mock requests
import pytest from unittest.mock import MagicMock import app # 애플리케이션 코드 # 테스트 케이스 1: 성공적인 가져오기 및 저장 def test_get_and_save_user_success(mocker): # requests.get 메소드 모킹 mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'id': 1, 'name': 'Leanne Graham', 'email': 'sincere@april.biz' } mocker.patch('requests.get', return_value=mock_response) # sqlite3.connect 메소드 및 관련 커서 함수 모킹 mock_conn = MagicMock() # commit 및 close가 호출 가능하지만 모크에서는 아무 작업도 하지 않도록 보장 mock_conn.commit.return_value = None mock_conn.close.return_value = None mock_conn.cursor.return_value.execute.return_value = None # CREATE TABLE 및 INSERT용 mocker.patch('sqlite3.connect', return_value=mock_conn) # 테스트 대상 함수 호출 result = app.get_and_save_user(1) # 단언 assert result is True # requests.get이 호출되었는지 확인 mocker.patch.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # sqlite3.connect가 호출되었는지 확인 mock_conn.cursor.assert_called_once() mock_conn.cursor.return_value.execute.assert_any_call( ''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL ) ''' ) mock_conn.cursor.return_value.execute.assert_any_call( "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", (1, 'Leanne Graham', 'sincere@april.biz') ) mock_conn.commit.assert_called_once() mock_conn.close.assert_called_once() # 테스트 케이스 2: API 호출 실패 (예: 네트워크 오류) def test_get_and_save_user_api_failure(mocker): # requests.get이 예외를 발생하도록 모킹 mocker.patch('requests.get', side_effect=requests.exceptions.RequestException("Network error")) mocker.patch('sqlite3.connect') # API가 실패하더라도 DB가 건드려지지 않도록 보장 result = app.get_and_save_user(1) assert result is False mocker.patch.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # sqlite3.connect가 호출되지 않았는지 단언 app.sqlite3.connect.assert_not_called() # 테스트 케이스 3: 데이터베이스 저장 실패 def test_get_and_save_user_db_failure(mocker): # 성공적인 API 응답 모킹 mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { 'id': 1, 'name': 'Leanne Graham', 'email': 'sincere@april.biz' } mocker.patch('requests.get', return_value=mock_response) # commit 중에 예외가 발생하도록 sqlite3.connect 모킹 mock_conn = MagicMock() mock_conn.cursor.return_value.execute.return_value = None mock_conn.commit.side_effect = sqlite3.Error("Database write error") mocker.patch('sqlite3.connect', return_value=mock_conn) result = app.get_and_save_user(1) assert result is False mocker.patch.call_args_list[0].assert_called_once_with('https://jsonplaceholder.typicode.com/users/1') # DB 연결 및 commit 시도 확인 mock_conn.cursor.assert_called_once() mock_conn.commit.assert_called_once() mock_conn.close.assert_called_once() # 오류가 발생해도 연결이 닫히도록 보장
코드 예제 설명:
- mockerfixture:- pytest-mock는 모크 객체의 설정 및 해제를 자동으로 처리하는- mockerfixture를 제공합니다. 테스트가 완료되면- mocker로 생성된 모든 패치는 자동으로 해제되어 테스트 오염을 방지합니다.
- mocker.patch('module.object', ...): 이것은 모킹을 위한 주요 메소드입니다.- 첫 번째 인수인 ('requests.get')은 모킹하려는 객체에 대한 완전한 경로를 나타내는 문자열입니다. 객체가 정의된 곳이 아니라 객체가 조회되는 곳을 패치하는 것이 중요합니다.app.py에서requests.get을 직접 호출하므로requests.get을 패치합니다.
- 데이터베이스의 경우 sqlite3.connect가 호출됩니다. 초기 모크가 반환하는 객체에 대한 모크를 연결하는 것이 중요합니다. 예를 들어,mock_conn.cursor.return_value.execute는connect가 반환할cursor객체의execute메소드를 모킹할 수 있게 합니다.
 
- 첫 번째 인수인 (
- return_value: 모크 객체의 이 속성은 호출 시 반환해야 하는 값을 지정합니다.- mock_response.json의 경우 API의 JSON 응답을 모방하는 사전으로- return_value를 설정했습니다.
- side_effect:- return_value대신- side_effect를 사용하여 모크를 호출할 때 예외를 발생시키거나 함수를 호출하도록 할 수 있습니다. 이는- test_get_and_save_user_api_failure및- test_get_and_save_user_db_failure에서 보여주는 것처럼 오류 조건을 시뮬레이션하는 데 유용합니다.
- MagicMock:- unittest.mock의 다용도 모크 클래스로, 액세스되는 즉시 속성과 메소드를 자동으로 생성하는 객체를 만듭니다. 이는 HTTP 응답이나 데이터베이스 연결과 같이 모든 속성이나 메소드에 신경 쓰지 않아도 되는 복잡한 객체를 시뮬레이션하는 데 매우 유용합니다.
- 모크에 대한 단언: 모킹된 함수를 호출한 후 모크 객체가 제공하는 단언 메소드를 사용하여 동작을 검증할 수 있습니다.
- mock.assert_called_once_with(...): 모크가 특정 인수로 정확히 한 번 호출되었는지 단언합니다.
- mock.assert_any_call(...): 모크가 특정 인수로 최소 한 번 호출되었는지 단언합니다.
- mock.assert_not_called(): 모크가 전혀 호출되지 않았는지 단언합니다.
- mock.call_args_list: 모크에 대한 모든 호출 목록을 제공합니다.
 
고급 시나리오 및 모범 사례
- 클래스 패치: 모크 인스턴스를 반환하도록 전체 클래스를 패치할 수 있습니다. 예를 들어, mocker.patch('app.User', return_value=MagicMock())를 사용하면app.User()가MagicMock객체를 반환합니다.
- 컨텍스트 관리자: with문 내에서 세분화된 모킹을 위해mocker.patch.object()를 사용할 수 있으며, 종종 임시 패치를 위해with블록 내에서 사용됩니다.
- Fixture 범위: Pytest의 fixture 범위를 기억하세요. mocker는 일반적으로 함수 범위이므로 각 테스트 후에 패치가 해제됩니다. 더 영구적인 모크(예: 전체 모듈용)가 필요한 경우mocker를 사용하는 fixture에autouse=True를 적용하거나 더 전역적으로 구성하는 것을 고려할 수 있습니다. 그러나 격리를 위해 함수 범위 모크가 일반적으로 선호됩니다.
- 언제 모킹하고 언제 하지 않을까: 모킹은 외부, 예측 불가능하거나 비용이 많이 드는 종속성에 가장 좋습니다. 내부의 안정적인 코드를 위해서는 통합 문제를 포착하기 위해 실제 구현으로 테스트하는 것이 종종 더 좋습니다.
- 과도한 모킹: 애플리케이션을 과도하게 모킹하지 않도록 주의하세요. 너무 많이 모킹하면 테스트는 통과할 수 있지만, 모크가 실제 동작을 정확하게 반영하지 않거나 로직이 아닌 모크를 테스트하고 있기 때문에 실제 애플리케이션은 여전히 실패할 수 있습니다. 종속성을 효과적으로 시뮬레이션하는 것은 견고하고 효율적인 테스트를 작성하는 데 필수적입니다.
결론
pytest-mock는 테스트 중에 Python 코드를 외부 종속성으로부터 격리하기 위한 우아하고 효과적인 솔루션을 제공합니다. mocker.patch()를 전략적으로 사용하는 방법을 배우고 unittest.mock 객체의 기능을 이해함으로써 테스트 스위트의 속도, 신뢰성 및 유지 관리성을 크게 향상시킬 수 있습니다. 모킹을 채택하면 테스트가 검사 대상 단위의 로직에만 집중할 수 있어 더 견고하고 품질이 높은 소프트웨어를 개발할 수 있습니다. 종속성을 효과적으로 시뮬레이션하는 것은 복원력 있고 효율적인 테스트를 작성하는 데 핵심입니다.