モックサーバーを使用した外部APIのテスト

モックサーバーを使用した外部APIのテスト

非常に便利であるにもかかわらず、https://realpython.com/api-integration-in-python/[external APIs]はテストするのが面倒です。 実際のAPIにアクセスすると、テストは外部サーバーに左右されるため、次の問題が発生する可能性があります。

  • 要求と応答のサイクルには数秒かかる場合があります。 最初はそれほどでもないように思えるかもしれませんが、時間はテストごとに複雑になります。 アプリケーション全体をテストするときに、APIを10、50、または100回呼び出すことを想像してください。

  • APIにはレート制限が設定されている場合があります。 *APIサーバーに到達できない可能性があります。 メンテナンスのためにサーバーがダウンしている可能性がありますか? エラーが発生して失敗した可能性があり、開発チームは再び機能するように取り組んでいます>テストの成功を、あなたが制御できないサーバーの健全性に依存させたいですか?

テストでは、APIサーバーが実行されているかどうかを評価すべきではありません。コードが期待どおりに動作しているかどうかをテストする必要があります。

前のチュートリアルでは、モックオブジェクトの概念を紹介し、それらを使用して外部APIとやり取りするコードをテストする方法を示しました。* このチュートリアルは同じトピックに基づいていますが、ここではAPIをモックするのではなく、実際にモックサーバーを構築する方法を説明します。*モックサーバーを配置すると、エンドツーエンドのテストを実行できます。 アプリケーションを使用して、モックサーバーから実際のフィードバックをリアルタイムで取得できます。

次の例の作業を終えると、基本的な模擬サーバーと2つのテストをプログラミングできます。1つは実際のAPIサーバーを使用し、もう1つは模擬サーバーを使用します。 両方のテストは、ユーザーのリストを取得するAPIである同じサービスにアクセスします。

_ NOTE This tutorial uses Python v3.5.1. _

入門

前の投稿のhttps://realpython.com/testing-third-party-apis-with-mocks/#first-steps[First steps]セクションから始めてください。 または、https://github.com/realpython/python-mock-server/releases/tag/v1 [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が変更され、モックサーバーが実行される_localhost_のAPIエンドポイントをポイントするようになりました。

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 `のサブクラスを作成します。 このクラスは要求をキャプチャし、返す応答を作成します。 HTTP GETリクエストに対する応答を作成するには、 ` do_GET()+`関数をオーバーライドします。 この場合、OKステータスを返すだけです。 次に、使用するモックサーバーで使用可能なポート番号を取得する関数を作成します。

次のコードブロックは、実際にサーバーを構成します。 コードが `+ HTTPServer +`インスタンスをインスタンス化し、ポート番号とハンドラーを渡す方法に注目してください。 次に、スレッドを作成を使用して、サーバーを非同期で実行し、メインプログラムスレッドがそれと通信できるようにします。 スレッドをデーモンにします。デーモンは、メインプログラムの終了時にスレッドに停止するよう指示します。 最後に、(テストが終了するまで)スレッドを開始して、モックサーバーに永久にサービスを提供します。

テストクラスを作成し、テスト関数をそれに移動します。 追加のメソッドを追加して、テストを実行する前にモックサーバーを起動する必要があります。 この新しいコードは、特別なクラスレベルの関数、 `+ setup_class()+`内に存在することに注意してください。

テストを実行し、それらが合格するのを確認します。

$ nosetests --verbosity=2 project

APIにヒットするサービスのテスト

おそらく、コード内で複数のAPIエンドポイントを呼び出す必要があります。 アプリを設計するときに、APIにリクエストを送信し、何らかの方法でレスポンスを処理するためのサービス関数を作成します。 おそらく、応答データをデータベースに保存します。 または、データをユーザーインターフェイスに渡します。

コードをリファクタリングして、ハードコーディングされたAPIベースURLを定数にプルします。 この変数を_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ファイルに移動します。 要求ハンドラに条件ロジックを追加して、HTTP要求が対象としているAPIエンドポイントを確認します。 簡単なヘッダー情報と基本的な応答ペイロードを追加して、応答を強化します。 サーバーの作成とキックオフのコードは、便利なメソッド `+ 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()`行は、_mock_ライブラリの ` patch.dict()+`関数でラップされています。

この声明は何をしますか?

`+ requests.get()`関数を機能ロジックから ` get_users()`サービス関数に移動したことを思い出してください。 内部的に、 ` get_users()`は ` USERS_URL `変数を使用して ` requests.get()`を呼び出します。 ` patch.dict()`関数は、一時的に ` USERS_URL `変数の値を置き換えます。 実際、 ` with `ステートメントのスコープ内でのみそうします。 そのコードの実行後、 ` USERS_URL +`変数は元の値に復元されます。 このコードは、模擬サーバーのアドレスを使用する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(not found)のステータスで応答を返します。

  • 要求が許可されていないメソッド(POST、DELETE、UPDATE)で送信された場合、HTTP 405(メソッドは許可されていません)のステータスで応答を返します。

  • 有効なリクエストの実際のユーザーデータを `+/users +`に返します。

  • これらのシナリオをキャプチャするテストを作成します。

repoからコードを取得します。