Имитация внешних API в Python

Оглавление

Интеграция со сторонними приложениями - отличный способ расширить функциональность вашего продукта.

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

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

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

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

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

ПРИМЕЧАНИЕ: В данном учебном пособии используется Python версии 3.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

# Third-party imports...
from nose.tools import assert_true
import requests


def test_request_response():
    # Send a request to the API server and store the response.
    response = requests.get('http://jsonplaceholder.typicode.com/todos')

    # Confirm that the request-response cycle completed successfully.
    assert_true(response.ok)

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

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

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

OK

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

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

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

project/tests/test_todos.py

# Third-party imports...
from nose.tools import assert_is_not_none

# Local imports...
from project.services import get_todos


def test_request_response():
    # Call the service, which will send a request to the server.
    response = get_todos()

    # If the request is sent successfully, then I expect a response to be returned.
    assert_is_not_none(response)

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

project/services.py

# Standard library imports...
try:
    from urllib.parse import urljoin
except ImportError:
    from urlparse import urljoin

# Third-party imports...
import requests

# Local imports...
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

Ваш первый mock

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

project/tests/test_todos.py

# Standard library imports...
from unittest.mock import Mock, patch

# Third-party imports...
from nose.tools import assert_is_not_none

# Local imports...
from project.services import get_todos


@patch('project.services.requests.get')
def test_getting_todos(mock_get):
    # Configure the mock to return a response with an OK status code.
    mock_get.return_value.ok = True

    # Call the service, which will send a request to the server.
    response = get_todos()

    # If the request is sent successfully, then I expect a response to be returned.
    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(), то видно, что успех функции зависит от того, что if 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

# Standard library imports...
from unittest.mock import patch

# Third-party imports...
from nose.tools import assert_is_not_none

# Local imports...
from project.services import get_todos


def test_getting_todos():
    with patch('project.services.requests.get') as mock_get:
        # Configure the mock to return a response with an OK status code.
        mock_get.return_value.ok = True

        # Call the service, which will send a request to the server.
        response = get_todos()

    # If the request is sent successfully, then I expect a response to be returned.
    assert_is_not_none(response)

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

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

project/tests/test_todos.py

# Standard library imports...
from unittest.mock import patch

# Third-party imports...
from nose.tools import assert_is_not_none

# Local imports...
from project.services import get_todos


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

    # Start patching `requests.get`.
    mock_get = mock_get_patcher.start()

    # Configure the mock to return a response with an OK status code.
    mock_get.return_value.ok = True

    # Call the service, which will send a request to the server.
    response = get_todos()

    # Stop patching `requests.get`.
    mock_get_patcher.stop()

    # If the request is sent successfully, then I expect a response to be returned.
    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 как функцию и ожидает, что та вернет объект ответа.

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

project/tests/test_todos.py

# Standard library imports...
from unittest.mock import Mock, patch

# Third-party imports...
from nose.tools import assert_is_none, assert_list_equal

# Local imports...
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
    }]

    # Configure the mock to return a response with an OK status code. Also, the mock should have
    # a `json()` method that returns a list of todos.
    mock_get.return_value = Mock(ok=True)
    mock_get.return_value.json.return_value = todos

    # Call the service, which will send a request to the server.
    response = get_todos()

    # If the request is sent successfully, then I expect a response to be returned.
    assert_list_equal(response.json(), todos)


@patch('project.services.requests.get')
def test_getting_todos_when_response_is_not_ok(mock_get):
    # Configure the mock to not return a response with an OK status code.
    mock_get.return_value.ok = False

    # Call the service, which will send a request to the server.
    response = get_todos()

    # If the response contains an error, I should get no todos.
    assert_is_none(response)

В предыдущем примере я упоминал, что при выполнении пропатченной mock-функции get_todos() функция возвращала mock-объект "response". Вы могли заметить закономерность: всякий раз, когда к mock'у добавляется return_value, этот mock модифицируется для запуска в качестве функции, и по умолчанию он возвращает другой mock-объект. В данном примере я сделал это более понятным, явно объявив объект 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() и затем фильтрует результаты, чтобы вернуть только те пункты, которые были выполнены. Нужно ли вам снова издеваться над requests.get()? Нет, в этом случае вы высмеиваете функцию get_todos() напрямую! Помните, что при имитации функции вы заменяете реальный объект его имитацией, и вам остается только беспокоиться о том, как сервисная функция взаимодействует с этой имитацией. В случае с get_todos() известно, что она не принимает никаких параметров и возвращает ответ с функцией json(), которая возвращает список объектов todo. Вам не важно, что происходит под капотом; вам важно только, чтобы макет get_todos() возвращал то, что вы ожидаете от настоящей функции get_todos().

project/tests/test_todos.py

# Standard library imports...
from unittest.mock import Mock, patch

# Third-party imports...
from nose.tools import assert_list_equal, assert_true

# Local imports...
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
    }

    # Configure mock to return a response with a JSON-serialized list of todos.
    mock_get_todos.return_value = Mock()
    mock_get_todos.return_value.json.return_value = [todo1, todo2]

    # Call the service, which will get a list of todos filtered on completed.
    uncompleted_todos = get_uncompleted_todos()

    # Confirm that the mock was called.
    assert_true(mock_get_todos.called)

    # Confirm that the expected filtered list of todos was returned.
    assert_list_equal(uncompleted_todos, [todo1])


@patch('project.services.get_todos')
def test_getting_uncompleted_todos_when_todos_is_none(mock_get_todos):
    # Configure mock to return None.
    mock_get_todos.return_value = None

    # Call the service, which will return an empty list.
    uncompleted_todos = get_uncompleted_todos()

    # Confirm that the mock was called.
    assert_true(mock_get_todos.called)

    # Confirm that an empty list was returned.
    assert_list_equal(uncompleted_todos, [])

Обратите внимание, что теперь я исправляю тестовую функцию, чтобы найти и заменить project.services.get_todos на mock. Функция mock должна возвращать объект, содержащий функцию 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. Для повторного использования логики, которая повторяется в тестовых функциях, можно создавать служебные функции на классе. Представьте себе, что одну и ту же логику создания данных приходится вызывать в каждой функции по отдельности. Какая боль!

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

project/tests/test_todos.py

# Standard library imports...
from unittest.mock import Mock, patch

# Third-party imports...
from nose.tools import assert_is_none, assert_list_equal, assert_true

# Local imports...
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

        # Call the service, which will send a request to the server.
        response = get_todos()

        # If the request is sent successfully, then I expect a response to be returned.
        assert_list_equal(response.json(), todos)

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

        # Call the service, which will send a request to the server.
        response = get_todos()

        # If the response contains an error, I should get no 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
        }

        # Configure mock to return a response with a JSON-serialized list of todos.
        self.mock_get_todos.return_value = Mock()
        self.mock_get_todos.return_value.json.return_value = [todo1, todo2]

        # Call the service, which will get a list of todos filtered on completed.
        uncompleted_todos = get_uncompleted_todos()

        # Confirm that the mock was called.
        assert_true(self.mock_get_todos.called)

        # Confirm that the expected filtered list of todos was returned.
        assert_list_equal(uncompleted_todos, [todo1])

    def test_getting_uncompleted_todos_when_todos_is_none(self):
        # Configure mock to return None.
        self.mock_get_todos.return_value = None

        # Call the service, which will return an empty list.
        uncompleted_todos = get_uncompleted_todos()

        # Confirm that the mock was called.
        assert_true(self.mock_get_todos.called)

        # Confirm that an empty list was returned.
        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():
    # Call the service to hit the actual API.
    actual = get_todos()
    actual_keys = actual.json().pop().keys()

    # Call the service to hit the mocked 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()

    # An object from the actual API and an object from the mocked API should have
    # the same data structure.
    assert_list_equal(list(actual_keys), list(mocked_keys))

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

Условно тестовые сценарии

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

project/tests/test_todos.py

# Standard library imports...
from unittest import skipIf

# Local imports...
from project.constants import SKIP_REAL


@skipIf(SKIP_REAL, 'Skipping tests that hit the real API server.')
def test_integration_contract():
    # Call the service to hit the actual API.
    actual = get_todos()
    actual_keys = actual.json().pop().keys()

    # Call the service to hit the mocked 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()

    # An object from the actual API and an object from the mocked API should have
    # the same data structure.
    assert_list_equal(list(actual_keys), list(mocked_keys))

project/constants.py

# Standard-library imports...
import os


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

 

$ export SKIP_REAL=True

Запустите тесты и обратите внимание на выходные данные. Один тест был проигнорирован, и в консоли появилось сообщение: "Skip tests that hit the real API server." Отлично!

$ 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 стороннего разработчика с помощью mocks. Теперь, когда вы знаете, как подойти к решению этой задачи, вы можете продолжить практику, написав сервисные функции для других конечных точек API в JSON Placeholder (например, posts, comments, users).

Вернуться на верх