본문 바로가기
프로그램 (PHP,Python)

Django, Flask 외부 API와 DB 호출 코드에 Mock 대체 테스트 자동화 구축

by 날으는물고기 2025. 7. 3.

Django, Flask 외부 API와 DB 호출 코드에 Mock 대체 테스트 자동화 구축

728x90

Advanced Mocking in pytest and Django/Flask

📚 목차

  1. Mock의 개념과 필요성
  2. Python Mock 기초
  3. pytest와 mock 고급 활용법
  4. 웹 프레임워크별 실전 적용
  5. 테스트 자동화 도구 완전 정복
  6. 보안과 베스트 프랙티스

1. Mock의 개념과 필요성

🎯 Mock이란?

Mock은 실제 객체의 동작을 모방하는 가짜 객체입니다. 테스트 환경에서 외부 의존성을 제거하고 독립적인 단위 테스트를 가능하게 합니다.

# 실제 코드 - 외부 API에 의존
import requests

def get_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

# Mock을 사용한 테스트
from unittest.mock import patch

@patch('requests.get')
def test_get_user_data(mock_get):
    # 가짜 응답 설정
    mock_get.return_value.json.return_value = {"id": 1, "name": "홍길동"}

    # 실제 API 호출 없이 테스트
    result = get_user_data(1)
    assert result["name"] == "홍길동"

🚀 Mock 사용의 장점

장점 설명
속도 향상 네트워크 호출, DB 접근 없이 밀리초 단위 실행
안정성 외부 서비스 장애와 무관하게 테스트 가능
비용 절감 API 호출 비용, DB 부하 없음
재현성 동일한 조건에서 항상 같은 결과
엣지케이스 테스트 에러, 타임아웃 등 특수 상황 시뮬레이션
300x250

2. Python Mock 기초

📦 기본 Mock 객체들

from unittest.mock import Mock, MagicMock, patch

# 1. Mock - 기본 mock 객체
mock_obj = Mock()
mock_obj.method.return_value = "결과값"
print(mock_obj.method())  # "결과값"

# 2. MagicMock - 매직 메서드 지원
magic_mock = MagicMock()
magic_mock.__len__.return_value = 5
print(len(magic_mock))  # 5

# 3. side_effect - 연속 호출 또는 예외 처리
mock_func = Mock()
mock_func.side_effect = [1, 2, ValueError("에러!")]
print(mock_func())  # 1
print(mock_func())  # 2
# mock_func()  # ValueError 발생

🔧 patch 데코레이터 활용

# 모듈 레벨 함수 패치
@patch('module.function_name')
def test_something(mock_func):
    mock_func.return_value = "mocked"
    # 테스트 코드

# 컨텍스트 매니저로 사용
def test_with_context():
    with patch('os.path.exists') as mock_exists:
        mock_exists.return_value = True
        # 파일이 존재한다고 가정한 테스트

# 여러 개 동시 패치
@patch('module.func1')
@patch('module.func2')
def test_multiple(mock_func2, mock_func1):
    # 순서 주의: 아래부터 위로
    pass

3. pytest와 mock 고급 활용법

🧪 pytest-mock 플러그인

pip install pytest-mock
# conftest.py - 공통 fixture 정의
import pytest
from unittest.mock import Mock

@pytest.fixture
def mock_database():
    db = Mock()
    db.get_user.return_value = {"id": 1, "name": "테스터"}
    return db

# test_service.py
def test_user_service(mocker, mock_database):
    # mocker는 pytest-mock이 제공하는 fixture
    mock_logger = mocker.patch('logging.Logger')

    # 서비스 테스트
    from services import UserService
    service = UserService(mock_database)
    user = service.get_user_info(1)

    assert user["name"] == "테스터"
    mock_logger.info.assert_called_once()

🎭 고급 Mock 패턴

# 1. 속성 접근 체인 mock
def test_chained_calls(mocker):
    mock = mocker.Mock()
    mock.client.api.users.get.return_value = {"status": "ok"}

    result = mock.client.api.users.get()
    assert result["status"] == "ok"

# 2. 호출 검증
def test_call_verification(mocker):
    mock_func = mocker.Mock()

    # 함수 호출
    mock_func(1, 2, key="value")

    # 다양한 검증 방법
    mock_func.assert_called_once_with(1, 2, key="value")
    assert mock_func.call_count == 1
    assert mock_func.call_args.args == (1, 2)
    assert mock_func.call_args.kwargs == {"key": "value"}

# 3. 복잡한 반환값 시뮬레이션
def test_complex_return(mocker):
    mock_api = mocker.Mock()

    # 호출할 때마다 다른 값 반환
    mock_api.get_data.side_effect = [
        {"page": 1, "data": [1, 2, 3]},
        {"page": 2, "data": [4, 5, 6]},
        StopIteration  # 더 이상 데이터 없음
    ]

4. 웹 프레임워크별 실전 적용

🌐 Django 테스트 자동화

# models.py
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    view_count = models.IntegerField(default=0)

# views.py
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
import requests

def article_detail(request, article_id):
    article = get_object_or_404(Article, pk=article_id)

    # 외부 API로 조회수 기록
    requests.post("https://analytics.example.com/track", json={
        "article_id": article_id,
        "timestamp": datetime.now().isoformat()
    })

    article.view_count += 1
    article.save()

    return JsonResponse({
        "id": article.id,
        "title": article.title,
        "content": article.content,
        "views": article.view_count
    })

# tests.py
from django.test import TestCase, Client
from unittest.mock import patch, Mock
from .models import Article

class ArticleViewTest(TestCase):
    def setUp(self):
        self.client = Client()
        self.article = Article.objects.create(
            title="테스트 글",
            content="내용",
            view_count=0
        )

    @patch('requests.post')
    def test_article_detail_with_tracking(self, mock_post):
        # 외부 API 응답 mock
        mock_post.return_value.status_code = 200

        response = self.client.get(f'/articles/{self.article.id}/')

        # 응답 검증
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(data['title'], '테스트 글')
        self.assertEqual(data['views'], 1)

        # API 호출 검증
        mock_post.assert_called_once()
        call_args = mock_post.call_args
        self.assertEqual(call_args.args[0], "https://analytics.example.com/track")

⚡ Flask 테스트 자동화

# app.py
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
import redis

app = Flask(__name__)
db = SQLAlchemy(app)
redis_client = redis.Redis()

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True)
    email = db.Column(db.String(120))

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    # 캐시 확인
    cached = redis_client.get(f"user:{user_id}")
    if cached:
        return jsonify(json.loads(cached))

    # DB 조회
    user = User.query.get_or_404(user_id)
    user_data = {
        "id": user.id,
        "username": user.username,
        "email": user.email
    }

    # 캐시 저장
    redis_client.setex(f"user:{user_id}", 3600, json.dumps(user_data))

    return jsonify(user_data)

# test_app.py
import pytest
from unittest.mock import Mock, patch
import json

@pytest.fixture
def client():
    app.config['TESTING'] = True
    with app.test_client() as client:
        yield client

def test_get_user_from_cache(client, mocker):
    # Redis mock
    mock_redis = mocker.patch('app.redis_client')
    cached_data = json.dumps({"id": 1, "username": "cached_user", "email": "cached@test.com"})
    mock_redis.get.return_value = cached_data

    response = client.get('/api/users/1')

    assert response.status_code == 200
    data = response.get_json()
    assert data['username'] == 'cached_user'

    # DB 조회가 일어나지 않았는지 확인
    mock_query = mocker.patch('app.User.query')
    mock_query.get_or_404.assert_not_called()

def test_get_user_from_db(client, mocker):
    # Redis에 캐시 없음
    mock_redis = mocker.patch('app.redis_client')
    mock_redis.get.return_value = None

    # DB 사용자 mock
    mock_user = Mock()
    mock_user.id = 1
    mock_user.username = "db_user"
    mock_user.email = "db@test.com"

    mock_query = mocker.patch('app.User.query')
    mock_query.get_or_404.return_value = mock_user

    response = client.get('/api/users/1')

    assert response.status_code == 200
    data = response.get_json()
    assert data['username'] == 'db_user'

    # 캐시 저장 확인
    mock_redis.setex.assert_called_once()

5. 테스트 자동화 도구 완전 정복

📊 pytest-cov: 테스트 커버리지 분석

pip install pytest-cov
# 커버리지 실행
pytest --cov=myproject --cov-report=html --cov-report=term

# 특정 파일/폴더 제외
pytest --cov=myproject --cov-omit="*/tests/*,*/migrations/*"

# 최소 커버리지 강제 (80% 미만 시 실패)
pytest --cov=myproject --cov-fail-under=80
# .coveragerc 설정 파일
[run]
source = .
omit = 
    */site-packages/*
    */tests/*
    */migrations/*
    */venv/*

[report]
precision = 2
show_missing = True
skip_covered = False

[html]
directory = htmlcov

🔄 tox: 다중 환경 테스트 자동화

pip install tox
# tox.ini
[tox]
envlist = py{38,39,310,311}, lint, docs
skipsdist = True

[testenv]
deps =
    pytest
    pytest-cov
    pytest-mock
    -r requirements.txt
commands =
    pytest --cov=myproject {posargs}

[testenv:lint]
deps =
    flake8
    black
    isort
    mypy
commands =
    flake8 myproject tests
    black --check myproject tests
    isort --check-only myproject tests
    mypy myproject

[testenv:docs]
deps =
    sphinx
    sphinx-rtd-theme
commands =
    sphinx-build -b html docs docs/_build

📼 vcrpy: HTTP 상호작용 기록/재생

pip install vcrpy
import vcr
import requests

# 실제 API 호출을 기록하고 재생
@vcr.use_cassette('fixtures/weather_api.yaml')
def test_weather_api():
    # 첫 실행: 실제 API 호출하고 응답을 yaml 파일에 저장
    # 이후 실행: yaml 파일에서 응답을 읽어옴
    response = requests.get('https://api.weather.com/seoul')
    assert response.status_code == 200
    assert 'temperature' in response.json()

# 고급 설정
my_vcr = vcr.VCR(
    serializer='json',
    cassette_library_dir='tests/fixtures/cassettes',
    record_mode='once',  # new_episodes, none, all
    match_on=['uri', 'method', 'body'],
    filter_headers=['authorization'],  # 민감정보 제거
    filter_post_data_parameters=['password', 'api_key']
)

with my_vcr.use_cassette('test.json'):
    # 테스트 코드
    pass

🏭 Factory Boy: 테스트 데이터 생성

pip install factory-boy
import factory
from factory.django import DjangoModelFactory
from myapp.models import User, Article

class UserFactory(DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f'user{n}')
    email = factory.LazyAttribute(lambda obj: f'{obj.username}@example.com')
    first_name = factory.Faker('first_name', locale='ko_KR')
    last_name = factory.Faker('last_name', locale='ko_KR')

class ArticleFactory(DjangoModelFactory):
    class Meta:
        model = Article

    title = factory.Faker('sentence', nb_words=5)
    content = factory.Faker('text')
    author = factory.SubFactory(UserFactory)
    published = factory.Faker('date_time_this_year')

# 사용 예시
def test_article_list():
    # 10개의 테스트 데이터 생성
    articles = ArticleFactory.create_batch(10)

    response = client.get('/api/articles/')
    assert len(response.json()['results']) == 10

🚦 Hypothesis: 속성 기반 테스트

pip install hypothesis
from hypothesis import given, strategies as st
import re

# 이메일 검증 함수
def is_valid_email(email):
    pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
    return re.match(pattern, email) is not None

# 속성 기반 테스트
@given(st.emails())
def test_email_validation(email):
    assert is_valid_email(email)

# 복잡한 데이터 생성
@given(
    username=st.text(min_size=3, max_size=20, alphabet=st.characters(blacklist_characters=" ")),
    age=st.integers(min_value=0, max_value=150),
    tags=st.lists(st.text(min_size=1), min_size=0, max_size=5)
)
def test_user_creation(username, age, tags):
    user = User(username=username, age=age, tags=tags)
    assert len(user.username) >= 3
    assert 0 <= user.age <= 150
    assert isinstance(user.tags, list)

📝 pytest 플러그인 생태계

# 유용한 pytest 플러그인들
pip install pytest-xdist  # 병렬 실행
pip install pytest-timeout  # 테스트 타임아웃
pip install pytest-benchmark  # 성능 벤치마크
pip install pytest-asyncio  # 비동기 테스트
pip install pytest-env  # 환경변수 설정
# pytest.ini 설정
[tool:pytest]
minversion = 6.0
addopts = 
    -ra
    --strict-markers
    --cov=myproject
    --cov-branch
    --cov-report=term-missing:skip-covered
    --cov-fail-under=80
    -n auto  # xdist 병렬 실행
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# 환경변수 설정 (pytest-env)
env = 
    DATABASE_URL=sqlite:///:memory:
    REDIS_URL=redis://localhost:6379/0
    SECRET_KEY=test-secret-key

6. 보안과 베스트 프랙티스

🔒 보안 중심 테스트 설계

# 1. 민감정보 마스킹
from unittest.mock import Mock, patch

class SecureAPIClient:
    def __init__(self, api_key):
        self.api_key = api_key

    def make_request(self, endpoint):
        headers = {"Authorization": f"Bearer {self.api_key}"}
        return requests.get(endpoint, headers=headers)

def test_secure_api_client(mocker):
    # API 키를 노출하지 않고 테스트
    mock_requests = mocker.patch('requests.get')
    mock_requests.return_value.json.return_value = {"status": "success"}

    client = SecureAPIClient("FAKE_API_KEY")
    result = client.make_request("https://api.example.com/data")

    # 호출 검증 시 헤더 확인
    call_args = mock_requests.call_args
    assert "Bearer FAKE_API_KEY" in call_args.kwargs['headers']['Authorization']

# 2. SQL 인젝션 방지 테스트
def test_sql_injection_prevention(mocker):
    mock_db = mocker.Mock()

    # 위험한 입력
    malicious_input = "'; DROP TABLE users; --"

    # 파라미터화된 쿼리 사용 확인
    def safe_query(user_id):
        return mock_db.execute("SELECT * FROM users WHERE id = ?", (user_id,))

    safe_query(malicious_input)

    # 안전한 쿼리 실행 확인
    mock_db.execute.assert_called_with(
        "SELECT * FROM users WHERE id = ?", 
        ("'; DROP TABLE users; --",)
    )

✅ 테스트 자동화 CI/CD 통합

# .github/workflows/test.yml
name: Test Suite

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8, 3.9, 3.10, 3.11]

    steps:
    - uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}

    - name: Install dependencies
      run: |
        pip install -r requirements.txt
        pip install tox tox-gh-actions

    - name: Run tests with tox
      run: tox

    - name: Upload coverage reports
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml
        fail_ci_if_error: true

📋 테스트 체크리스트

단위 테스트

  • 모든 함수/메서드에 대한 테스트 작성
  • 정상 케이스와 예외 케이스 모두 포함
  • Mock을 사용한 외부 의존성 격리
  • 테스트 커버리지 80% 이상 유지

통합 테스트

  • API 엔드포인트 전체 테스트
  • 데이터베이스 트랜잭션 테스트
  • 외부 서비스 연동 시나리오 (vcrpy 활용)
  • 인증/권한 플로우 검증

보안 테스트

  • 인증 우회 시도 방어 확인
  • SQL 인젝션 방지 검증
  • XSS 공격 방어 확인
  • 민감정보 노출 방지

성능 테스트

  • 응답 시간 벤치마크 (pytest-benchmark)
  • 메모리 사용량 모니터링
  • 동시성 테스트 (pytest-xdist)
  • 부하 테스트 시나리오

 

완벽한 테스트 자동화는 단순히 테스트를 작성하는 것이 아니라, 체계적인 전략과 적절한 도구의 조합으로 이루어집니다.

  1. Mock 활용: 외부 의존성을 제거하고 빠르고 안정적인 테스트 구축
  2. 도구 조합: pytest + mock + coverage + tox + vcrpy 등을 통한 완성도 높은 테스트 환경
  3. 자동화 통합: CI/CD 파이프라인에 통합하여 지속적인 품질 관리
  4. 보안 우선: 테스트 단계부터 보안을 고려한 설계

이러한 접근 방식을 통해 100% 자동화된 테스트 환경을 구축하고, 안정적이고 확장 가능한 소프트웨어를 개발할 수 있습니다. 테스트는 단순한 검증이 아닌, 더 나은 코드를 위한 설계 도구임을 잊지 마세요!

728x90
그리드형(광고전용)

댓글