← Назад к вопросам

Если unit-тест вызывает сторонний ресурс, какие действия предпринять

1.8 Middle🔥 191 комментариев
#Тестирование

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Если unit-тест вызывает сторонний ресурс — что делать

Это критическая ошибка в тестировании. Unit-тесты должны быть изолированы, быстры и детерминированы. Вызов сторонних ресурсов нарушает все эти принципы.

Почему это проблема

# ❌ ПЛОХО: unit-тест вызывает реальный API
import requests

def test_get_user_from_api():
    response = requests.get('https://api.github.com/users/guido')  # Реальный запрос!
    assert response.status_code == 200
    assert response.json()['login'] == 'guido'

# Проблемы:
# 1. Медленно (сетевая задержка 100-500ms вместо 1ms)
# 2. Нестабильно (API может быть down, интернет отключен)
# 3. Не изолировано (зависит от внешнего сервиса)
# 4. Дорого (API quotas, rate limits)
# 5. Трудно тестировать edge cases (ошибки, timeout, rate limits)
# 6. CI/CD может упасть из-за внешних факторов

Решение: Mocking и Stubbing

Основной принцип: Тестируй логику, не интеграцию. Заменяй (mock'ируй) внешние зависимости.

Вариант 1: unittest.mock (встроенный)

from unittest.mock import patch, Mock
import requests

# ✅ ХОРОШО: mock'ируем requests.get
@patch('requests.get')
def test_get_user_from_api(mock_get):
    # Настраиваем mock
    mock_get.return_value.status_code = 200
    mock_get.return_value.json.return_value = {'login': 'guido'}
    
    # Вызываем функцию
    response = requests.get('https://api.github.com/users/guido')
    
    # Проверяем результат
    assert response.status_code == 200
    assert response.json()['login'] == 'guido'
    
    # Проверяем, что mock был вызван правильно
    mock_get.assert_called_once_with('https://api.github.com/users/guido')

# Плюсы: встроено, нет зависимостей
# Минусы: verbose, сложно для сложных сценариев

Вариант 2: pytest-mock (для pytest)

import pytest
from unittest.mock import Mock
import requests

@pytest.fixture
def mock_github_api(mocker):
    """Fixture для мокирования GitHub API"""
    mock = mocker.patch('requests.get')
    mock.return_value.status_code = 200
    mock.return_value.json.return_value = {'login': 'guido'}
    return mock

def test_get_user(mock_github_api):
    response = requests.get('https://api.github.com/users/guido')
    assert response.json()['login'] == 'guido'

# Плюсы: чище, чем unittest.mock, pytest интеграция

Вариант 3: VCR.py (запись и воспроизведение)

Для сложных сценариев: запиши реальный запрос один раз, потом воспроизводи.

import pytest
import requests
from vcr import VCR

my_vcr = VCR(
    cassette_library_dir='tests/fixtures/cassettes',
    record_mode='once',  # Запись один раз, потом из файла
)

@pytest.mark.vcr  # Или @my_vcr.use_cassette('github_api.yaml')
def test_get_user_with_vcr():
    response = requests.get('https://api.github.com/users/guido')
    assert response.json()['login'] == 'guido'
    assert response.status_code == 200

# Первый запуск: записывает в cassettes/fixtures/cassettes/
# Последующие: воспроизводит из файла (очень быстро)
# Преимущества:
# - Первый запуск реальный (не мокируешь неправильно)
# - Потом очень быстро
# - Невозможно случайно сломать тест неправильным mock'ом

Файл cassettes/github_api.yaml:

interactions:
- request:
    body: null
    headers: {}
    method: GET
    uri: https://api.github.com/users/guido
  response:
    body: {string: '{"login": "guido", ...}'}
    headers:
      content-type: [application/json]
    status: {code: 200, message: OK}

Вариант 4: Dependency Injection (правильная архитектура)

Лучший подход: Разработай код так, чтобы легко подменять зависимости.

# ❌ ПЛОХО: жёсткая зависимость на requests
class UserService:
    def get_user(self, username):
        response = requests.get(f'https://api.github.com/users/{username}')
        return response.json()

def test_user_service():
    # Сложно mock'ировать
    pass

# ✅ ХОРОШО: зависимость injected
class UserService:
    def __init__(self, http_client=None):
        self.http_client = http_client or requests
    
    def get_user(self, username):
        response = self.http_client.get(f'https://api.github.com/users/{username}')
        return response.json()

def test_user_service():
    mock_client = Mock()
    mock_client.get.return_value.json.return_value = {'login': 'guido'}
    
    service = UserService(http_client=mock_client)
    user = service.get_user('guido')
    
    assert user['login'] == 'guido'
    mock_client.get.assert_called_once_with('https://api.github.com/users/guido')

Вариант 5: Fake implementation (для сложных зависимостей)

# Interface/Protocol
from abc import ABC, abstractmethod

class DatabaseClient(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

# Реальная реализация
class PostgresClient(DatabaseClient):
    def query(self, sql: str):
        return psycopg2.connect(...).execute(sql)

# Fake для тестов (in-memory)
class FakeDatabaseClient(DatabaseClient):
    def __init__(self):
        self.data = {}
    
    def query(self, sql: str):
        # In-memory simulation
        if 'SELECT * FROM users' in sql:
            return [{'id': 1, 'name': 'Guido'}]
        return []

# Тест
def test_with_fake_db():
    db = FakeDatabaseClient()
    service = UserService(db=db)
    users = service.get_all_users()
    assert len(users) == 1
    assert users[0]['name'] == 'Guido'

# Плюсы: контролируемо, быстро, не требует mock'ирования

Вариант 6: Responses library (специально для HTTP)

import responses
import requests

@responses.activate  # Перехватывает все HTTP запросы
def test_with_responses():
    # Регистрируем mock'ированный ответ
    responses.add(
        responses.GET,
        'https://api.github.com/users/guido',
        json={'login': 'guido'},
        status=200,
    )
    
    response = requests.get('https://api.github.com/users/guido')
    assert response.json()['login'] == 'guido'

# Или с матчингом:
@responses.activate
def test_with_matcher():
    def request_callback(request):
        if 'guido' in request.url:
            return (200, {}, json.dumps({'login': 'guido'}))
        return (404, {}, '')
    
    responses.add_callback(
        responses.GET,
        'https://api.github.com/users/',
        callback=request_callback,
    )

Практическое руководство

Шаг 1: Определи тип сторонней зависимости

- HTTP API → mock requests или responses или VCR
- База данных → fixture с test database или fake
- Файловая система → tempfile, fake filesystem
- Email → mock smtp
- 3rd party SDK → mock the SDK

Шаг 2: Выбери инструмент

Просто и быстро? → unittest.mock
Внешний HTTP? → responses или VCR
Сложная логика БД? → Fake implementation
Microservice? → Contract testing (Pact)

Шаг 3: Пиши тест

import pytest
from unittest.mock import Mock, patch

@patch('myapp.external_service.api_call')
def test_something(mock_api):
    mock_api.return_value = {'success': True}
    
    result = my_function()
    
    assert result == expected
    mock_api.assert_called_once()

Типичные ошибки

# ❌ Неправильный путь для patch
@patch('external_service.api_call')  # НЕПРАВИЛЬНО
def test(mock):
    pass

# ✅ Правильно: patch где используется
@patch('myapp.module.api_call')  # Где api_call вызывается
def test(mock):
    pass

# ❌ Mock слишком сложный
mock = Mock()
mock.response.data[0].user.profile.settings = {}

# ✅ Mock должен быть простым и понятным
mock = Mock(return_value={'user_id': 123})

Чеклист для unit-тестов

  • ✅ Тест работает за < 1 мс (без network/IO)
  • ✅ Тест повторяемый (один и тот же результат)
  • ✅ Тест не зависит от интернета
  • ✅ Тест не требует credentials/API ключей
  • ✅ Тест не вызывает реальные сервисы
  • ✅ Тест не пишет на диск (или использует tempfile)
  • ✅ Тест не меняет системное состояние

Интеграционные тесты (это другое!)

# ИНТЕГРАЦИОННЫЕ тесты (не unit!) могут вызывать реальные сервисы
@pytest.mark.integration
def test_integration_with_real_api():
    # Это OK для интеграционных тестов
    response = requests.get('https://api.github.com/users/guido')
    assert response.status_code == 200

# Но их:
# 1. Запускают отдельно (не в CI/CD каждый раз)
# 2. Помечают @pytest.mark.integration
# 3. Требуют активного интернета
# 4. Медлительнее unit-тестов

Вывод

Unit-тесты НЕ должны вызывать сторонние ресурсы. Если тест вызывает:

  • HTTP API → Mock'ируй с unittest.mock, responses или VCR
  • БД → Используй test database или fake
  • Файлы → Используй tempfile или fake filesystem
  • Сторонние SDK → Mock'ируй SDK

Это даёт:

  • ⚡ Быстрые тесты (ms вместо ms)
  • 🎯 Надёжные тесты (не зависят от внешних факторов)
  • 🔄 Повторяемые тесты (одинаковый результат каждый раз)
  • 🐛 Легко найти баги (изолированная логика)

Профессиональный код = изолированные unit-тесты + отдельные интеграционные тесты.

Если unit-тест вызывает сторонний ресурс, какие действия предпринять | PrepBro