В этом руководстве я планирую показать, как протестировать использование внешнего API с помощью Python моков.

Интеграция со сторонними приложениями — отличный способ расширить функциональность любого продукта. Однако дополнительные возможности продукта сопряжены с определенными препятствиями. Если вы не являетесь владельцем внешней библиотеки, вы не можете контролировать серверы, на которых она размещена, код, составляющий ее логику, или данные, которые передаются между ней и приложением. Кроме того, пользователи постоянно воздействуют на данные при взаимодействии с библиотекой.

Если вы собрались расширить возможности приложения с помощью API стороннего разработчика, то вы должны быть уверены, что эти две системы будут хорошо работать вместе. Необходимо проверить, что взаимодействие двух приложений происходит слаженно и предсказуемо, причем тесты должны выполняться в контролируемой среде.

На первый взгляд может показаться, что над сторонними приложениями у вас нет никакого контроля. Многие из них не предлагают серверы для тестирования. Вы не можете тестировать данные в реальном времени, и даже если бы вы могли, тесты выдавали бы ненадежные результаты по мере обновления данных в процессе использования. Кроме того, вы точно не захотите, чтобы автоматизированные тесты подключались к внешнему серверу. Ошибка на их стороне может привести к остановке разработки, если релиз кода зависит от того, прошли ли тесты. К счастью, существует способ протестировать реализацию API стороннего разработчика в контролируемой среде без необходимости подключаться к внешнему источнику данных. Решение заключается в имитации функциональности внешнего кода с помощью так называемых моков.

Мок — это фиктивный объект, который выглядит и действует как настоящий. Вы меняете его местами с реальным объектом и обманываете систему, заставляя ее думать, что мок — это настоящие данные. Использование мока напоминает мне классический киносюжет, в котором герой хватает человека в форме, переодевается в его униформу и вступает в строй марширующих врагов. Никто не замечает самозванца, и все продолжают двигаться — всё как обычно.

Сторонняя аутентификация, такая как OAuth, является хорошим кандидатом для использования моков. OAuth требует, чтобы приложение взаимодействовало с внешним сервером, в нем используются реальные данные пользователя, и ваше приложение полагается на его успех, чтобы получить доступ к своим API. Имитация аутентификации позволяет протестировать систему в качестве авторизованного пользователя без необходимости проходить через реальный процесс обмена учетными данными. В данном случае вы не хотите проверять, успешно ли система аутентифицирует пользователя; вы хотите проверить, как ведут себя функции приложения после прохождения аутентификации.

Примечание: в этом руководстве используется версия Python v3.5.1.

Первые шаги

Начните с создания новой среды разработки для хранения кода проекта. Создайте новую виртуальную среду, а затем установите следующие библиотеки:

$ pip install nose requests

Вот краткое описание каждой устанавливаемой библиотеки, на случай, если вы никогда с ними не сталкивались:

  • Библиотека mock используется для тестирования кода на Python путем замены частей системы моками. Примечание: Библиотека mock является частью unittest, в случае если вы используете версию Python 3.3 или выше. Если вы используете более старую версию, установите библиотеку backport mock.

  • Библиотека nose расширяет встроенный модуль Python unittest для облегчения тестирования. Вы можете использовать unittest или другие сторонние библиотеки, такие как pytest, для достижения тех же результатов, но я предпочитаю nose.

  • Библиотека requests значительно упрощает вызовы HTTP в Python.

В этом руководстве вы будете взаимодействовать с фиктивным онлайн API, созданным для тестирования — JSON Placeholder. Прежде чем писать какие-либо тесты, вам нужно знать, чего ожидать от API.

Во-первых, вы должны ожидать, что API, к которому вы обращаетесь, действительно возвращает ответ, когда вы посылаете ему запрос. Подтвердите это предположение, вызвав эндпоинт с помощью cURL:

$ curl -X GET 'http://jsonplaceholder.typicode.com/todos'

Этот вызов должен вернуть JSON-сериализованный список элементов todo. Обратите внимание на структуру данных todo в ответе. Вы должны увидеть список объектов с ключами userId, id, title и completed. Теперь вы готовы сделать второе предположение — вы знаете, как должны выглядеть данные. Эндпоинт API жив и функционирует. Вы доказали это, вызвав его из командной строки. Теперь напишите nose тест, чтобы можно было подтвердить жизнь сервера в будущем. Вас должно волновать только то, возвращает ли сервер ответ OK.

project/tests/test_todos.py

# Сторонний импорт...
from nose.tools import assert_true
import requests


def test_request_response():
    # Отправка запроса на сервер API и сохранение ответа.
    response = requests.get('http://jsonplaceholder.typicode.com/todos')

    # Убеждаемся, что цикл запрос-ответ успешно завершен.
    assert_true(response.ok)

Запустите тест и посмотрите, как он пройдет:

$ nosetests --verbosity=2 project
test_todos.test_request_response ... ok

----------------------------------------------------------------------
Ran 1 test in 9.270s

OK

Рефакторинг кода в сервис

Велики шансы, что вы будете обращаться к внешнему API много раз на протяжении всего приложения. Кроме того, эти вызовы API, вероятно, будут включать в себя больше логики, чем просто выполнение HTTP-запроса, например, обработку данных, обработку ошибок и фильтрацию. Нужно провести рефакторинг кода теста и выделить служебные функции для работы с API.

Перепишите тест так, чтобы он ссылался на служебную функцию и тестировал новую логику.

project/tests/test_todos.py

# Сторонний импорт...
from nose.tools import assert_is_not_none

# Локальный импорт...
from project.services import get_todos


def test_request_response():
    # Вызов службы, которая отправит запрос на сервер.
    response = get_todos()

    # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
    assert_is_not_none(response)

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

project/services.py

# Импорт стандартной библиотеки...
try:
    from urllib.parse import urljoin
except ImportError:
    from urlparse import urljoin

# Сторонний импорт...
import requests

# Локальный импорт...
from project.constants import BASE_URL

TODOS_URL = urljoin(BASE_URL, 'todos')


def get_todos():
    response = requests.get(TODOS_URL)
    if response.ok:
        return response
    else:
        return None

project/constants.py

BASE_URL = 'http://jsonplaceholder.typicode.com'

Первый тест, который вы написали, ожидал, что ответ вернется со статусом OK. Вы перепроектировали логику, сделали служебную функцию, которая возвращает сам ответ, если запрос к серверу успешен. При неудачном запросе возвращается значение None. Теперь тест включает утверждение, подтверждающее, что функция не возвращает None.

Обратите внимание, как я проинструктировал вас создать файл constants.py, а затем заполнил его BASE_URL. Служебная функция расширяет BASE_URL для создания TODOS_URL, а поскольку все эндпоинты API используют одну и ту же базу, вы можете продолжать создавать новые без необходимости переписывать этот фрагмент кода. Если разместить BASE_URL в отдельном файле, это позволит редактировать его в одном месте, что пригодится, если на этот код будут ссылаться несколько модулей.

Запустите тест и посмотрите, как он пройдет.

$ nosetests --verbosity=2 project
test_todos.test_request_response ... ok

----------------------------------------------------------------------
Ran 1 test in 1.475s

OK

Ваш первый мок

Код работает так, как ожидалось. Вы знаете это, потому что тест проходит успешно. К сожалению, есть одна проблема — служебная функция все еще обращается к внешнему серверу напрямую. Когда вы вызываете get_todos(), ваш код делает запрос к эндпоинту API и возвращает результат, который зависит от того, работает ли этот сервер. Здесь я продемонстрирую, как отделить логику программы от реальной внешней библиотеки, заменив реальный запрос на фиктивный, который возвращает те же данные.

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import Mock, patch

# Сторонний импорт...
from nose.tools import assert_is_not_none

# Локальный импорт...
from project.services import get_todos


@patch('project.services.requests.get')
def test_getting_todos(mock_get):
    # Настройка мока на возврат ответа с кодом состояния OK.
    mock_get.return_value.ok = True

    # Вызов службы, которая отправит запрос на сервер.
    response = get_todos()

    # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
    assert_is_not_none(response)

Обратите внимание, я вообще не изменил служебную функцию. Единственной частью кода, которую я отредактировал, был сам тест. Сначала я импортировал функцию patch() из библиотеки mock. Затем я изменил тестовую функцию с функцией patch() в качестве декоратора, передав ссылку на project.services.requests.get. В самой функции я передал параметр mock_get, а затем в теле тестовой функции добавил строку mock_get.return_value.ok = True.

Отлично. Что же на самом деле происходит при выполнении теста? Прежде чем я расскажу об этом, вам нужно кое-что понять о том, как работает библиотека requests. Когда вы вызываете функцию requests.get(), она скрытно выполняет HTTP-запрос, а затем возвращает HTTP-ответ в виде объекта Response. Сама функция get() взаимодействует с внешним сервером, вот почему нужно нацелиться на нее. Помните образ героя, который меняется местами с врагом, надевая при этом его форму? Вам нужно так одеть мок, чтобы он выглядел и действовал как функция requests.get().

При запуске тестовой функции она находит модуль, в котором объявлена библиотека requests, project.services, и заменяет целевую функцию requests.get() на мок. Тест также указывает моку вести себя так, как ожидает от него служебная функция. Если вы посмотрите на get_todos(), то увидите, что успех функции зависит от того, возвращает ли response.ok: значение True. Именно это и делает строка mock_get.return_value.ok = True. Когда свойство ok будет вызвано на моке, он вернет True, как и реальный объект. Функция get_todos() вернет ответ мока, тест пройдет, потому что ответ не None.

Запустите тест, чтобы убедиться в его прохождении.

$ nosetests --verbosity=2 project

Другие способы внедрения тестового кода

Использование декоратора — это лишь один из нескольких способов подменить функцию на вызов мока. В следующем примере я явно подменяю функцию в блоке кода, используя менеджер контекста. Оператор with связывает функцию, используемую любым кодом в блоке кода. Когда блок кода завершается, исходная функция восстанавливается. Оператор with и декоратор достигают одной и той же цели: оба метода подменяют project.services.request.get.

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import patch

# Сторонний импорт...
from nose.tools import assert_is_not_none

# Локальный импорт...
from project.services import get_todos


def test_getting_todos():
    with patch('project.services.requests.get') as mock_get:
        # Настройка мока на возврат ответа с кодом состояния OK.
        mock_get.return_value.ok = True

        # Вызов службы, которая отправит запрос на сервер.
        response = get_todos()

    # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
    assert_is_not_none(response)

Запустите тесты, чтобы убедиться, что они по-прежнему проходят.

Другой способ подмены функции — использовать патчер. Здесь я определяю исходную функцию для подмены, а затем явным образом начинаю использовать мок. Замена функции не прекращается, пока я явно не скажу системе прекратить использование мока.

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import patch

# Сторонний импорт...
from nose.tools import assert_is_not_none

# Локальный импорт...
from project.services import get_todos


def test_getting_todos():
    mock_get_patcher = patch('project.services.requests.get')

    # Запуск корректировки `requests.get`.
    mock_get = mock_get_patcher.start()

    # Настройка мока на возврат ответа с кодом состояния OK.
    mock_get.return_value.ok = True

    # Вызов службы, которая отправит запрос на сервер.
    response = get_todos()

    # Остановка корректировки `requests.get`.
    mock_get_patcher.stop()

    # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
    assert_is_not_none(response)

Запустите тесты снова, чтобы получить тот же успешный результат.

Теперь, когда вы познакомились с тремя способами подмены функции моком, возникает вопрос — когда следует использовать каждый из них? Короткий ответ: это зависит только от вас. Каждый из способов подмены является правильным. Тем не менее, я обнаружил, что определенные паттерны кодирования особенно хорошо работают с определенными методами подмены.

  1. Используйте декоратор, когда весь код в теле тестовой функции использует мок.

  2. Используйте менеджер контекста, когда часть кода в тестовой функции использует мок, а другая часть кода ссылается на настоящую функцию.

  3. Используйте патчер в тех случаях, когда нужно явно запустить и остановить мокинг функции в нескольких тестах (например, функции setUp() и tearDown() в тестовом классе).

В этом руководстве я использую каждый из этих методов.

Моделирование полного поведения сервиса

В предыдущих примерах вы реализовали базовый мок и протестировали простое утверждение — возвращает ли функция get_todos() значение None. Функция get_todos() вызывает внешний API и получает ответ. При успешном вызове функция возвращает объект response, который содержит JSON-сериализованный список todos. Если запрос не удался, get_todos() возвращает None. В следующем примере я продемонстрирую, как можно сымитировать всю функциональность get_todos(). В начале этого руководства при первоначальном обращении к серверу с помощью cURL вы получили JSON-сериализованный список словарей, которые представляли собой элементы todo. В этом примере мы рассмотрим, как подменить эти данные.

Вспомните, как работает patch(): вы указываете ей путь к функции, которую хотите подменить. Функция найдена, patch() создает объект Mock, и реальная функция временно заменяется моком. Когда тест вызывает get_todos(), функция использует mock_get так же, как она использовала бы реальный метод get(). Это означает, что она вызывает mock_get как функцию и ожидает, что он вернет объект-ответ.

В данном случае объект-ответ — это объект Response библиотеки requests, который имеет несколько атрибутов и методов. Вы сымитировали одно из этих свойств, ok, в предыдущем примере. Объект Response также имеет функцию json(), которая преобразует его строковое содержимое в формате JSON в тип данных Python (например, list или dict).

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import Mock, patch

# Сторонний импорт...
from nose.tools import assert_is_none, assert_list_equal

# Локальный импорт...
from project.services import get_todos


@patch('project.services.requests.get')
def test_getting_todos_when_response_is_ok(mock_get):
    todos = [{
        'userId': 1,
        'id': 1,
        'title': 'Make the bed',
        'completed': False
    }]

    # Настройка мока на возврат ответа с кодом состояния OK. Кроме того, в моке должен быть
    # метод `json()`, который возвращает список todos.
    mock_get.return_value = Mock(ok=True)
    mock_get.return_value.json.return_value = todos

    # Вызов службы, которая отправит запрос на сервер.
    response = get_todos()

    # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
    assert_list_equal(response.json(), todos)


@patch('project.services.requests.get')
def test_getting_todos_when_response_is_not_ok(mock_get):
    # Настройка мока, чтобы он не возвращал ответ с кодом состояния OK.
    mock_get.return_value.ok = False

    # Вызов службы, которая отправит запрос на сервер.
    response = get_todos()

    # Если ответ содержит ошибку, мы не должны получить todos.
    assert_is_none(response)

В предыдущем примере я упоминал, что когда вы запускали функцию get_todos(), которая была подменена моком, функция возвращала мок-объект "response". Вы могли заметить закономерность: всякий раз, когда значение return_value добавляется в мок, этот мок модифицируется для запуска в качестве функции, и по умолчанию он возвращает другой мок-объект. В этом примере я сделал это немного более понятным, явно объявив Mock, mock_get.return_value = Mock(ok=True). mock_get() является зеркальным отражением requests.get(), и requests.get() возвращает Response, тогда как mock_get() возвращает Mock. 

Объект Response имеет свойство ok, поэтому вы добавили свойство ok в Mock.

Объект Response также содержит функцию json(), поэтому я добавил json в Mock и дополнил его значением return_value, поскольку он будет вызываться как функция. Функция json() возвращает список объектов todo. Обратите внимание, что тест теперь включает утверждение, которое проверяет значение response.json(). Вы хотите убедиться, что функция get_todos() возвращает список todos, как это делает настоящий сервер. Наконец, чтобы завершить тестирование функции get_todos(), я добавляю тест на отказ.

Запустите тесты и посмотрите, как они пройдут.

$ nosetests --verbosity=2 project
test_todos.test_getting_todos_when_response_is_not_ok ... ok
test_todos.test_getting_todos_when_response_is_ok ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.285s

OK

Мокинг встроенных функций

Примеры, которые я вам показал, были достаточно простыми, следующий будет несколько сложнее. Представьте себе сценарий, в котором вы создаете новую служебную функцию, которая вызывает get_todos(), а затем фильтрует результаты, чтобы вернуть только завершенные элементы todo. Придется ли снова имитировать requests.get()? Нет, в этом случае происходит подмена функции get_todos() напрямую! Помните, когда вы имитируете функцию, вы заменяете реальный объект моком, и вам нужно беспокоиться только о том, как служебная функция взаимодействует с этим моком. В случае с get_todos() вы знаете, что она не принимает никаких параметров и что она возвращает ответ с функцией json(), которая возвращает список объектов todo. Не важно, что происходит под капотом; важно, что мок get_todos() возвращает то, что вы ожидаете от настоящей функции get_todos().

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import Mock, patch

# Сторонний импорт...
from nose.tools import assert_list_equal, assert_true

# Локальный импорт...
from project.services import get_uncompleted_todos


@patch('project.services.get_todos')
def test_getting_uncompleted_todos_when_todos_is_not_none(mock_get_todos):
    todo1 = {
        'userId': 1,
        'id': 1,
        'title': 'Make the bed',
        'completed': False
    }
    todo2 = {
        'userId': 1,
        'id': 2,
        'title': 'Walk the dog',
        'completed': True
    }

    # Настройка мока на возврат ответа с JSON-сериализованным списком todos.
    mock_get_todos.return_value = Mock()
    mock_get_todos.return_value.json.return_value = [todo1, todo2]

    # Вызов службы, которая получит список todos, отфильтрованный по завершенности.
    uncompleted_todos = get_uncompleted_todos()

    # Подтверждение, что мок был вызван.
    assert_true(mock_get_todos.called)

    # Подтверждение, что ожидаемый отфильтрованный список todos был возвращен.
    assert_list_equal(uncompleted_todos, [todo1])


@patch('project.services.get_todos')
def test_getting_uncompleted_todos_when_todos_is_none(mock_get_todos):
    # Настройка мока на возврат None.
    mock_get_todos.return_value = None

    # Вызов службы, которая вернет пустой список.
    uncompleted_todos = get_uncompleted_todos()

    # Подтверждение, что мок был вызван.
    assert_true(mock_get_todos.called)

    # Подтверждение, что был возвращен пустой список.
    assert_list_equal(uncompleted_todos, [])

Обратите внимание, что теперь я подменяю тестовую функцию, чтобы найти и заменить project.services.get_todos на мок. Мок-функция должна вернуть объект, содержащий функцию json(). При вызове функция json() должна вернуть список объектов todo. Я также добавляю утверждение, подтверждающее, что функция get_todos() действительно вызывается. Это полезно для того, чтобы установить, что когда сервисная функция обратится к реальному API, будет выполнена настоящая функция get_todos(). Здесь я также включаю тест для проверки того, что если get_todos() возвращает None, то функция get_uncompleted_todos() возвращает пустой список. И снова я подтверждаю, что функция get_todos() вызывается.

Напишите тесты, запустите их, чтобы убедиться, что они завершаются ошибкой, а затем напишите код, необходимый для их прохождения.

project/services.py

def get_uncompleted_todos():
    response = get_todos()
    if response is None:
        return []
    else:
        todos = response.json()
        return [todo for todo in todos if todo.get('completed') == False]

Теперь тесты проходят.

Рефакторинг тестов для использования классов

Вероятно, вы заметили, что некоторые тесты принадлежат к одной группе. У вас есть два теста, которые нацелены на функцию get_todos(). Два других теста нацелены на get_uncompleted_todos(). Всякий раз, когда я начинаю замечать тенденции и сходство между тестами, я провожу рефакторинг, выделяю тестовый класс. Такой рефакторинг помогает достичь нескольких целей:

  1. Перенос общих тестовых функций в класс позволяет легче тестировать их вместе как группу. Можно указать nose целиться на список функций, но проще нацелить их на один класс.

  2. Общие тестовые функции часто требуют одинаковых шагов для инициализации и деинициализации данных, которые используются в каждом тесте. Эти шаги могут быть заключены в функции setup_class() и teardown_class() соответственно, чтобы выполнить код на соответствующих этапах.

  3. Вы можете создавать служебные функции в классе, чтобы повторно использовать логику, которая повторяется в тестовых функциях. Представьте, что вам придется вызывать одну и ту же логику создания данных в каждой функции по отдельности. Какая боль!

Обратите внимание, что я использую технику патчера, чтобы сымитировать определенные функции в тестовых классах. Как я уже говорил, эта техника отлично подходит для создания мока, который охватывает несколько функций. Код в методе teardown_class() явно восстанавливает исходный код после завершения тестов.

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest.mock import Mock, patch

# Сторонний импорт...
from nose.tools import assert_is_none, assert_list_equal, assert_true

# Локальный импорт...
from project.services import get_todos, get_uncompleted_todos


class TestTodos(object):
    @classmethod
    def setup_class(cls):
        cls.mock_get_patcher = patch('project.services.requests.get')
        cls.mock_get = cls.mock_get_patcher.start()

    @classmethod
    def teardown_class(cls):
        cls.mock_get_patcher.stop()

    def test_getting_todos_when_response_is_ok(self):
        # Configure the mock to return a response with an OK status code.
        self.mock_get.return_value.ok = True

        todos = [{
            'userId': 1,
            'id': 1,
            'title': 'Make the bed',
            'completed': False
        }]

        self.mock_get.return_value = Mock()
        self.mock_get.return_value.json.return_value = todos

        # Вызов службы, которая отправит запрос на сервер.
        response = get_todos()

        # Если запрос отправлен успешно, то я ожидаю возвращения ответа.
        assert_list_equal(response.json(), todos)

    def test_getting_todos_when_response_is_not_ok(self):
        # Настройка мока, чтобы он не возвращал ответ с кодом состояния OK.
        self.mock_get.return_value.ok = False

        # Вызов службы, которая отправит запрос на сервер.
        response = get_todos()

        # Если ответ содержит ошибку, то мы не должны получить никаких todos.
        assert_is_none(response)


class TestUncompletedTodos(object):
    @classmethod
    def setup_class(cls):
        cls.mock_get_todos_patcher = patch('project.services.get_todos')
        cls.mock_get_todos = cls.mock_get_todos_patcher.start()

    @classmethod
    def teardown_class(cls):
        cls.mock_get_todos_patcher.stop()

    def test_getting_uncompleted_todos_when_todos_is_not_none(self):
        todo1 = {
            'userId': 1,
            'id': 1,
            'title': 'Make the bed',
            'completed': False
        }
        todo2 = {
            'userId': 2,
            'id': 2,
            'title': 'Walk the dog',
            'completed': True
        }

        # Настройте мок на возврат ответа с JSON-сериализованным списком todos.
        self.mock_get_todos.return_value = Mock()
        self.mock_get_todos.return_value.json.return_value = [todo1, todo2]

        # Вызов службы, которая получит список todos, отфильтрованный по завершенности.
        uncompleted_todos = get_uncompleted_todos()

        # Подтверждение, что мок был вызван.
        assert_true(self.mock_get_todos.called)

        # Подтверждение, что был возвращен ожидаемый отфильтрованный список todos.
        assert_list_equal(uncompleted_todos, [todo1])

    def test_getting_uncompleted_todos_when_todos_is_none(self):
        # Настройка мока на возврат None.
        self.mock_get_todos.return_value = None

        # Вызов службы, которая вернет пустой список.
        uncompleted_todos = get_uncompleted_todos()

        # Подтверждение, что мок был вызван.
        assert_true(self.mock_get_todos.called)

        # Подтверждение, что был возвращен пустой список.
        assert_list_equal(uncompleted_todos, [])

Запустите тесты. Всё должно пройти, потому что вы не вводили никакой новой логики, а просто переместили код.

$ nosetests --verbosity=2 project
test_todos.TestTodos.test_getting_todos_when_response_is_not_ok ... ok
test_todos.TestTodos.test_getting_todos_when_response_is_ok ... ok
test_todos.TestUncompletedTodos.test_getting_uncompleted_todos_when_todos_is_none ... ok
test_todos.TestUncompletedTodos.test_getting_uncompleted_todos_when_todos_is_not_none ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.300s

OK

Тестирование обновлений данных API

На протяжении всего этого руководства я показывал, как имитировать данные, которые возвращаются сторонним API. Эти имитируемые данные основаны на предположении, что реальные данные используют тот же контракт данных, что и те, которые вы имитируете. Первым шагом было обращение к реальному API и наблюдение за возвращаемыми данными. Вы можете быть достаточно уверены, что структура данных не изменилась за то короткое время, что вы работали над этими примерами, однако не стоит быть уверенным, что данные останутся неизменными навсегда. Любая хорошая внешняя библиотека регулярно обновляется. Хотя разработчики стремятся сделать новый код обратно совместимым, рано или поздно наступает момент, когда код устаревает.

Полностью полагаться на фиктивные данные может быть опасно. Поскольку вы тестируете код без связи с реальным сервером, есть риск стать излишне уверенным в силе своих тестов. Когда придет время использовать приложение с реальными данными, всё рухнет. Следующая стратегия должна использоваться для подтверждения того, что данные, которые вы ожидаете получить от сервера, соответствуют данным, которые вы тестируете. Цель здесь — сравнить структуру данных (например, ключи в объекте), а не реальные данные.

Обратите внимание, как я использую технику подмены функции с использованием контекстного менеджера. Здесь нужно вызвать реальный сервер, и его нужно сымитировать отдельно.

project/tests/test_todos.py

def test_integration_contract():
    # Вызов службы, чтобы обратиться к реальному API.
    actual = get_todos()
    actual_keys = actual.json().pop().keys()

    # Вызов службы, чтобы вызвать подменный API.
    with patch('project.services.requests.get') as mock_get:
        mock_get.return_value.ok = True
        mock_get.return_value.json.return_value = [{
            'userId': 1,
            'id': 1,
            'title': 'Make the bed',
            'completed': False
        }]

        mocked = get_todos()
        mocked_keys = mocked.json().pop().keys()

    # Объект из реального API и объект из подменного API должны иметь
    # одинаковую структуру данных.
    assert_list_equal(list(actual_keys), list(mocked_keys))

Тесты должны пройти. Сымитированная структура данных совпадает со структурой данных из реального API.

Условный запуск тестовых сценариев

Теперь, когда у вас есть тест для сравнения реальных контрактов данных с сымитированными, вам нужно знать, когда его запускать. Тест, который ориентирован на реальный сервер, не нужно автоматизировать, потому что ошибка не обязательно означает, что проблема в коде. У вас может не быть возможности подключиться к реальному серверу во время выполнения тест-сьюта по десятку причин, которые находятся вне вашего контроля. Выполняйте этот тест отдельно от автоматических тестов, но достаточно часто. Одним из способов выборочного пропуска тестов является использование переменной окружения в качестве переключателя. В приведенном ниже примере все тесты выполняются, если переменная окружения SKIP_REAL не установлена в True. Когда переменная SKIP_REAL включена, любой тест с декоратором @skipIf(SKIP_REAL) будет пропущен.

project/tests/test_todos.py

# Импорт стандартной библиотеки...
from unittest import skipIf

# Локальный импорт...
from project.constants import SKIP_REAL


@skipIf(SKIP_REAL, 'Skipping tests that hit the real API server.')
def test_integration_contract():
    # Вызов службы, чтобы обратиться к реальному API.
    actual = get_todos()
    actual_keys = actual.json().pop().keys()

    # Вызов службы, чтобы обратиться к подменному API.
    with patch('project.services.requests.get') as mock_get:
        mock_get.return_value.ok = True
        mock_get.return_value.json.return_value = [{
            'userId': 1,
            'id': 1,
            'title': 'Make the bed',
            'completed': False
        }]

        mocked = get_todos()
        mocked_keys = mocked.json().pop().keys()

    # Объект из реального API и объект из подменного API должны иметь
    # одинаковую структуру данных.
    assert_list_equal(list(actual_keys), list(mocked_keys))

project/constants.py

# Импорт стандартной библиотеки...
import os


BASE_URL = 'http://jsonplaceholder.typicode.com'
SKIP_REAL = os.getenv('SKIP_REAL', False)
$ export SKIP_REAL=True

Запустите тесты и обратите внимание на вывод. Один тест был проигнорирован, и консоль вывела сообщение: «Пропускаем тесты, которые нацелены на реальный сервер API». Отлично!

$ nosetests --verbosity=2 project
test_todos.TestTodos.test_getting_todos_when_response_is_not_ok ... ok
test_todos.TestTodos.test_getting_todos_when_response_is_ok ... ok
test_todos.TestUncompletedTodos.test_getting_uncompleted_todos_when_todos_is_none ... ok
test_todos.TestUncompletedTodos.test_getting_uncompleted_todos_when_todos_is_not_none ... ok
test_todos.test_integration_contract ... SKIP: Skipping tests that hit the real API server.

----------------------------------------------------------------------
Ran 5 tests in 0.240s

OK (SKIP=1)

Следующие шаги

Итак, мы посмотрели, как протестировать интеграцию приложения со сторонним API при помощи моков. Теперь, когда вы знаете, как подойти к решению проблемы, вы можете продолжить практику, написав служебные функции для других эндпоинтов API в JSON Placeholder (например, посты, комментарии, пользователи).

Код из статьи лежит в этом репозитории.


Приглашаем тестировщиков-автоматизаторов на бесплатное занятие, на котором познакомимся с фреймворком pytest и посмотрим, как он используется для написания автоматизированных тестов. Также поработаем с основным инструментом pytest — фикстурами. Обсудим, как грамотно писать фикстуры, чтобы тесты были стабильными и легко поддерживаемыми. Записаться на открытый урок можно на странице курса "Python QA Engineer".

А уже завтра пройдет открытый урок, посвященный теме тестирования API с использованием библиотеки requests. На этом занятии разберем пирамиду тестирования и вспомним, зачем она нужна. Также познакомимся с функционалом библиотек requests и pytest, научимся отправлять HTTP-запросы и параметризовать тесты. В ходе занятия напишем первые тесты для API. Записывайтесь по ссылке.

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


  1. Chilango
    14.06.2023 15:43
    +1

    Спасибо за статью. Есть ли возможность делать подобные моки с помощью pytest? У меня есть ощущение, что "весь прогрессивный мир" уже давно перешел от unittest на более продвинутые методы тестирования.


    1. Andrey_Solomatin
      14.06.2023 15:43

      Оригинальной статье 7 лет уже, хотя тогда уже pytest вовсю использовали.

      Pytest вполне совместим с unittest.mock

      Ну и встроенная фикстура вполне удобна. https://docs.pytest.org/en/7.1.x/how-to/monkeypatch.html#how-to-monkeypatch-mock-modules-and-environments