モックサーバーを使用した外部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.モックサーバーを配置すると、エンドツーエンドのテストを実行できます。 アプリケーションを使用して、モックサーバーから実際のフィードバックをリアルタイムで取得できます。

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

Note
このチュートリアルでは、Pythonv3.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が変更され、モックサーバーが実行される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のサブクラスを作成します。 このクラスは要求をキャプチャし、返す応答を作成します。 do_GET()関数をオーバーライドして、HTTPGET要求の応答を作成します。 この場合、OKステータスを返すだけです。 次に、使用するモックサーバーで使用可能なポート番号を取得する関数を作成します。

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

テストクラスを作成し、テスト関数をそれに移動します。 追加のメソッドを追加して、テストを実行する前にモックサーバーを起動する必要があります。 この新しいコードは、特別なクラスレベルの関数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変数は元の値に復元されます。 このコード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(not found)のステータスで応答を返します。

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

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

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

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