Тестирование внешних API с помощью фиктивных серверов

Тестирование внешних API с помощью фиктивных серверов

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

  • Цикл запрос-ответ может занять несколько секунд. Поначалу это может показаться не таким уж большим, но время зависит от каждого теста. Представьте, что вы вызываете API 10, 50 или даже 100 раз при тестировании всего приложения.

  • В API могут быть установлены ограничения скорости.

  • Сервер API может быть недоступен. Может быть, сервер не работает для обслуживания? Возможно, произошел сбой с ошибкой, и команда разработчиков работает над тем, чтобы он снова заработал> Вы действительно хотите, чтобы успех ваших тестов зависел от работоспособности сервера, который вы не контролируете?

Ваши тесты не должны оценивать, работает ли сервер API; они должны проверить, работает ли ваш код должным образом.

Вprevious tutorial мы представили концепцию фиктивных объектов, продемонстрировали, как вы можете использовать их для тестирования кода, который взаимодействует с внешними API. This tutorial builds on the same topics, but here we walk you through how to actually build a mock server rather than mocking the APIs. Имея фиктивный сервер, вы можете выполнять сквозные тесты. Вы можете использовать свое приложение и получать актуальную обратную связь от макета сервера в режиме реального времени.

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

Note
В этом руководстве используется Python v3.5.1.

Начиная

Начните с разделаFirst steps предыдущего поста. Или возьмите код изrepository. Убедитесь, что тест прошел, прежде чем двигаться дальше:

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

----------------------------------------------------------------------
Ran 1 test in 1.029s

OK

Тестирование Mock API

После завершения настройки вы можете запрограммировать свой фиктивный сервер. Напишите тест, который описывает поведение:

project/tests/test_mock_server.py

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


def test_request_response():
    url = 'http://localhost:{port}/users'.format(port=mock_server_port)

    # Send a request to the mock API server and store the response.
    response = requests.get(url)

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

Обратите внимание, что он выглядит почти идентично реальному тесту API. URL-адрес изменился и теперь указывает на конечную точку API наlocalhost, где будет запускаться фиктивный сервер.

Вот как создать фиктивный сервер в Python:

project/tests/test_mock_server.py

# Standard library imports...
from http.server import BaseHTTPRequestHandler, HTTPServer
import socket
from threading import Thread

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


class MockServerRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Process an HTTP GET request and return a response with an HTTP 200 status.
        self.send_response(requests.codes.ok)
        self.end_headers()
        return


def get_free_port():
    s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM)
    s.bind(('localhost', 0))
    address, port = s.getsockname()
    s.close()
    return port


class TestMockServer(object):
    @classmethod
    def setup_class(cls):
        # Configure mock server.
        cls.mock_server_port = get_free_port()
        cls.mock_server = HTTPServer(('localhost', cls.mock_server_port), MockServerRequestHandler)

        # Start running mock server in a separate thread.
        # Daemon threads automatically shut down when the main process exits.
        cls.mock_server_thread = Thread(target=cls.mock_server.serve_forever)
        cls.mock_server_thread.setDaemon(True)
        cls.mock_server_thread.start()

    def test_request_response(self):
        url = 'http://localhost:{port}/users'.format(port=self.mock_server_port)

        # Send a request to the mock API server and store the response.
        response = requests.get(url)

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

Сначала создайте подклассBaseHTTPRequestHandler. Этот класс захватывает запрос и создает ответ для возврата. Переопределите функциюdo_GET() для создания ответа на HTTP-запрос GET. В этом случае просто верните статус OK. Затем напишите функцию, чтобы получить номер доступного порта для использования имитирующим сервером.

Следующий блок кода фактически настраивает сервер. Обратите внимание, как код создает экземплярHTTPServer и передает ему номер порта и обработчик. Next, create a thread, так что сервер может работать асинхронно, а ваш основной программный поток может связываться с ним. Сделайте поток демоном, который указывает потоку останавливаться при выходе из основной программы. Наконец, запустите поток, чтобы обслуживать фиктивный сервер навсегда (до завершения тестов).

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

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

$ nosetests --verbosity=2 project

Тестирование сервиса, который соответствует API

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

Рефакторинг вашего кода, чтобы преобразовать базовый URL-адрес API в константу. Добавьте эту переменную в файлconstants.py:

project/constants.py

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

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

project/services.py

# Standard library imports...
from urllib.parse import urljoin

# Third-party imports...
import requests

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

USERS_URL = urljoin(BASE_URL, 'users')


def get_users():
    response = requests.get(USERS_URL)
    if response.ok:
        return response
    else:
        return None

Переместите код фиктивного сервера из файла объектов в новый файл Python, чтобы его можно было легко использовать повторно. Добавьте условную логику в обработчик запросов, чтобы проверить, к какой конечной точке API относится HTTP-запрос. Увеличьте ответ, добавив некоторую простую информацию заголовка и базовую полезную нагрузку ответа. Код создания и запуска сервера может быть инкапсулирован в удобный методstart_mock_server().

project/tests/mocks.py

# Standard library imports...
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import re
import socket
from threading import Thread

# Third-party imports...
import requests


class MockServerRequestHandler(BaseHTTPRequestHandler):
    USERS_PATTERN = re.compile(r'/users')

    def do_GET(self):
        if re.search(self.USERS_PATTERN, self.path):
            # Add response status code.
            self.send_response(requests.codes.ok)

            # Add response headers.
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.end_headers()

            # Add response content.
            response_content = json.dumps([])
            self.wfile.write(response_content.encode('utf-8'))
            return


def get_free_port():
    s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM)
    s.bind(('localhost', 0))
    address, port = s.getsockname()
    s.close()
    return port


def start_mock_server(port):
    mock_server = HTTPServer(('localhost', port), MockServerRequestHandler)
    mock_server_thread = Thread(target=mock_server.serve_forever)
    mock_server_thread.setDaemon(True)
    mock_server_thread.start()

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

project/tests/test_real_server.py

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

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


def test_request_response():
    response = get_users()

    assert_dict_contains_subset({'Content-Type': 'application/json; charset=utf-8'}, response.headers)
    assert_true(response.ok)
    assert_is_instance(response.json(), list)

project/tests/test_mock_server.py

# Third-party imports...
from unittest.mock import patch
from nose.tools import assert_dict_contains_subset, assert_list_equal, assert_true

# Local imports...
from project.services import get_users
from project.tests.mocks import get_free_port, start_mock_server


class TestMockServer(object):
    @classmethod
    def setup_class(cls):
        cls.mock_server_port = get_free_port()
        start_mock_server(cls.mock_server_port)

    def test_request_response(self):
        mock_users_url = 'http://localhost:{port}/users'.format(port=self.mock_server_port)

        # Patch USERS_URL so that the service uses the mock server URL instead of the real URL.
        with patch.dict('project.services.__dict__', {'USERS_URL': mock_users_url}):
            response = get_users()

        assert_dict_contains_subset({'Content-Type': 'application/json; charset=utf-8'}, response.headers)
        assert_true(response.ok)
        assert_list_equal(response.json(), [])

Обратите внимание на новую технику, используемую в кодеtest_mock_server.py. Строкаresponse = get_users() заключена в функциюpatch.dict() из библиотекиmock.

Что делает это утверждение?

Помните, вы переместили функциюrequests.get() из логики функции в служебную функциюget_users(). Внутреннеget_users() вызываетrequests.get(), используя переменнуюUSERS_URL. Функцияpatch.dict() временно заменяет значение переменнойUSERS_URL. Фактически, это происходит только в рамках оператораwith. После выполнения этого кода переменнаяUSERS_URL восстанавливается до исходного значения. Этот кодpatches URL для использования фиктивного адреса сервера.

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

$ nosetests --verbosity=2
test_mock_server.TestMockServer.test_request_response ... 127.0.0.1 - - [05/Jul/2016 20:45:30] "GET /users HTTP/1.1" 200 -
ok
test_real_server.test_request_response ... ok
test_todos.test_request_response ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.871s

OK

Пропуск тестов, которые попали в реальный API

Мы начали этот урок с описания преимуществ тестирования фиктивного сервера вместо реального, однако ваш код в настоящее время тестирует оба. Как вы настраиваете тесты, чтобы игнорировать реальный сервер? Библиотека Python «unittest» предоставляет несколько функций, которые позволяют пропустить тесты. Вы можете использовать функцию условного пропуска «skipIf» вместе с переменной среды для включения и выключения реальных тестов сервера. В следующем примере мы передаем имя тега, которое следует игнорировать:

$ export SKIP_TAGS=real

project/constants.py

# Standard-library imports...
import os


BASE_URL = 'http://jsonplaceholder.typicode.com'
SKIP_TAGS = os.getenv('SKIP_TAGS', '').split()

project/tests/test_real_server.py

# Standard library imports...
from unittest import skipIf

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

# Local imports...
from project.constants import SKIP_TAGS
from project.services import get_users


@skipIf('real' in SKIP_TAGS, 'Skipping tests that hit the real API server.')
def test_request_response():
    response = get_users()

    assert_dict_contains_subset({'Content-Type': 'application/json; charset=utf-8'}, response.headers)
    assert_true(response.ok)
    assert_is_instance(response.json(), list)

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

$ nosetests --verbosity=2 project
test_mock_server.TestMockServer.test_request_response ... 127.0.0.1 - - [05/Jul/2016 20:52:18] "GET /users HTTP/1.1" 200 -
ok
test_real_server.test_request_response ... SKIP: Skipping tests that hit the real API server.
test_todos.test_request_response ... ok

----------------------------------------------------------------------
Ran 3 tests in 1.196s

OK (SKIP=1)

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

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

Попробуйте следующие упражнения для повышения уровня:

  • Вернуть ответ со статусом HTTP 404 (не найден), если запрос отправлен с неизвестным путем.

  • Вернуть ответ со статусом HTTP 405 (метод не разрешен), если запрос отправлен с методом, который не разрешен (POST, DELETE, UPDATE).

  • Вернуть фактические данные пользователя для действительного запроса в/users.

  • Напишите тесты, чтобы захватить эти сценарии.

Возьмите код изrepo.

Related