UpNext2 開発記録#2 交通情報API-GET〜ファイル保存の実装とpytest-mock化


UpNext2 開発記録の続きです。今回は、前回のVSCodeでのPython CI環境に、実際のコードとテストを書いてみます。特に、pytestでのmock_open利用についての記述例がネットにあまりなく、苦労しましたので、参考になれば幸いです。

本記事は、pytestとpytest-mockを利用したAPI呼び出しのモック化、mock_openによるファイル書き込みのモック化、mark.parametrizeによる複数の条件分岐のカバー、side_effectによる例外処理テスト、さらには相対インポートにまつわるハマりどころの対策、などのトピックスを含みます。テスト対象のコードは、東京公共交通オープンデータチャレンジの実際のAPI呼び出しを行うものです。

なお、この記事の前提環境は
Python 3.8.3
Pytest 5.4.2 ; plugins: cov-2.9.0, mock-3.1.1
VSCode 1.46.0 ; Python extention v2020.5.86806
です。

今回作成したコードは https://github.com/toast-uz/UpNext2/tree/develop の2020年6月14日のコミットです。

1. プロジェクト構成

前回作成した環境を元に、odpt_dump.py とそのテストを作成します。odpt_dump.pyは、東京公共交通オープンデータチャレンジのdump APIを使って、いくつかの交通情報ファイルをダウンロードし、それをそのままローカルのlocal_data/odpt_dumpフォルダ内に保存します。

2. メインモジュールの概要

以下がメインモジュールの主要ソースです。解説コメントの部分をそれぞれ解説します。

odpt_dump.py
import requests

try:  # 解説a1
    from . import config_secret
except ImportError:
    import config_secret

query_string = ('https://api-tokyochallenge.odpt.org/api/v4/odpt:{}.json'
                '?acl:consumerKey={}')   # 解説a2
save_path = 'local_data/odpt_dump/{}.json'


def get_and_save(rdf_type):
    url = query_string.format(rdf_type, config_secret.apikey)
    print('Getting {}...'.format(rdf_type), end='', flush=True)
    try:
        response = requests.get(url)
        response.raise_for_status()  # 解説a3
        with open(save_path.format(rdf_type), 'wb') as save_file:
            save_file.write(response.content)  # 解説a4
        print('done.')
    except Exception as e:  # 解説a5
        print('fail, due to: {}'.format(e))
        raise


if __name__ == '__main__':
    for rdf_type in [  # 解説a6
            'Calendar',
            'Operator',
            'Station',
            'StationTimetable',
            'TrainTimetable',
            'TrainType',
            'RailDirection',
            'Railway']:
        get_and_save(rdf_type)

解説a1: Pythonの相対インポートのハマりどころの対策

odpt_dump.pyと同じフォルダのconfig_secret.pyには、開発者個人に与えられる東京公共交通オープンデータのAPIキーが定義されています。このプロジェクト構成は比較的標準的なものだと思いますが、プログラム実行時とテスト実行時で、importの方法により成功失敗が食い違ってしまいます。

実行方法 .付きimport .なしimport
ファイルとしてプログラム実行 失敗*1 成功
モジュールとしてプログラム実行 成功 失敗*2
テスト実行 成功 失敗*2

*1: ImportError: attempted relative import with no known parent package
*2: ModuleNotFoundError: No module named 'config_secret'

なお、モジュールとしてプログラム実行をさせる場合は、preprocess.src.odpt_dump と指定します。

王道としては、.付きimportに統一し、プログラム実行時にモジュールでの実行を選択するものと思います。しかし、ファイルとしてプログラム実行をさせる方が楽なので、少し邪道ですが複数のimport方法を並べて、エラーをキャッチしたら切り替えるように実装しました。

解説a2: 1行79文字制限をエレガントに回避する()での文字列分割

pep8の規約として1行79文字以下、というのがあり、これを満足しないとエラーが出てしまいます。その際、長い文字列を分割する方式はエスケープや+での連結を使わず、この()を使うのが、エレガントであるとのことです。なお、タプルではありませんので注意してください。

解説a3: requestsでのスマートな例外スロー

requestsのレスポンスとして、HTTP200系以外のHTTPエラーの何が来ても、一律に例外としてスローしてしまうのがスマートです。そのための組み込み関数 raise_for_status() が用意されています。

解説a4: with openは基本

ファイル読み書きのためのopenメソッドは、with付きで記述して、暗黙にクローズさせるのが基本です。

解説a5: まとめて例外処理

HTTPエラーだけでなく、ファイル系含めて、途中のあらゆる例外をここでキャッチします。すぐにraiseするので、無くても大差ありませんが、「ちゃんと処理している」感がありますよね。笑

解説a6: メインルーチンではループで繰り返す

mainでは、以上の処理get_and_saveを、複数のダウンロード対象ファイルに渡って繰り返します。for-in リスト、の形式は、Pythonのループの最も基本的な記述方式です。

実は、最初はモジュールに分けずに、mainにベタ書きしていました。しかし、テストのことを考えると、mainの記述は最小限にして、手頃なサイズのクラスやモジュールに分割することが重要です。

3. テストモジュールの概要

以下がテストモジュールです。解説コメントの部分をそれぞれ解説します。

test_odpt_dump.py
from preprocess.src import odpt_dump
import pytest
import requests

http404_msg = '404 Not Found'


def _mock_response(mocker, is_normal):
    mock_resp = mocker.Mock()  # 解説b1
    mock_resp.raise_for_status = mocker.Mock()
    if not is_normal:
        mock_resp.raise_for_status.side_effect = requests.exceptions.HTTPError(
            http404_msg)  # 解説b2
    mock_resp.status_code = 200 if is_normal else 404  # 解説b3
    mock_resp.content = b'TEST'
    return mock_resp


@pytest.mark.parametrize('is_normal', [   # 解説b4
    True,
    False,
])
def test_get_and_save(mocker, is_normal):
    mock_resp = _mock_response(mocker, is_normal)
    mocker.patch('requests.get').return_value = mock_resp  # 解説b5

    mock_file = mocker.mock_open()
    mocker.patch('builtins.open', mock_file)  # 解説b6

    with pytest.raises(Exception) as e:  # 解説b7
        odpt_dump.get_and_save('Dummy')
        raise

    if (not is_normal) and (str(e.value) is http404_msg):  # 解説b8
        return

    assert mock_file.call_count == 1   # 解説b9
    assert mock_file().write.call_args[0][0] == mock_resp.content


if __name__ == '__main__':
    pytest.main(['-v', __file__])

解説b1: requests.Responseオブジェクトのモック化

pytestではオブジェクトでも関数でもこの記述で、MagicMock相当のモック化ができます。テスト実行時に、対象メソッドの該当オブジェクトや関数は自動的にモックに置き換わり、事前に定義したモックに処理が移ります。筆者は今回初めてモックを使ったのですが、なんとも悪魔的に不思議なしくみだと思いました。概念的にはAPIのフックに似ています。

解説b2: side_effectによる例外処理の組み込み

オブジェクトをモックした場合、プロパティは単純に擬似実装が可能ですが、メソッドは関数としてさらに別のモックと結びつける必要があります。もちろん、実行コードで使わないプロパティやメソッドは、擬似実装する必要はありません。あくまでもモックなので、見えるところだけ作ってあればよいのです。

この場合はraise_for_status()がモック対象です。さらに、関数の処理の結果を単に返すだけでなく、処理の中で例外をスローさせる場合、side_effectを使います。

解説b3: パラメータによるモック動作の変更

is_normalはテストのパラメータですので、値をもとにモックオブジェクトのプロパティを変更することは容易です。これとは異なり、テスト実行時のモックの入力パラメータに基づいて動作を変更する際は、side_effectが必要になるようです。紛らわしいですが、別モノです。

解説b4: @pytest.mark.parametrizeによるテストパラメータの切り替え

@pytest.mark.parametrizeによって、テストパラメータを切り替えて、テスト実行を繰り返すことができます。1つのテスト内でループやifによって切り替えを実装するよりもスマートで、かつテスト実行も別の独立したテストとして認識されるため、VSCodeの中で扱いやすくなります。

なお、パラメータをタプルで記述することで、複数のパラメータセットとして切り替えることも可能です。

解説b5: requests.getへのパッチ適用

requests.getの呼び出しにフックをかけて、返り値であるrequests.Responseオブジェクトをモックと入れ替えます。

解説b6: openへのパッチ適用

openメソッドを特殊モックであるmock_openと入れ替えます。ここで、openメソッドを、'__main__.open'と表現している例が多いのですが、これではうまくいかず、'builtins.open'と表現する必要があります。

解説b7: pytestにおける例外処理

pytestはコード実行時に例外が発生すると、そこでテストは中止して、テスト成功とみなす、という仕様になっています。例外で停止するのはコードとしては正しい動きである、という解釈のようです。そのため、例外が意図通りにスローされたのか、意図に無い例外が発生したのかは、pytest側で例外処理を明示的に記載して判定する必要があります。

with pytest.raises文を記述して、そのwithスコープ内で、例外を発生させるテスト対象コードを実行させる必要があります。

解説b8: 例外発生時の後処理

例外が意図通りに発生したのかどうかを、withの外側で判定します。ここでは、テストパラメータやmockで設定した404エラーの例外が、意図通りに発生しているかを検査します。

解説b9: 正常時の結果チェック

例外が発生していない状況で、ファイルへの書き込み(Mockに入れ替え済)が正常に行われているかを確認します。平易なassert文でテスト結果を確認できることが、pytestの特徴です。

4. .vscode/setting.jsonの概要

主としてテスト設定が記述されているsetting.jsonについて解説します。pytestArgsがVSCodeからpytest起動時のコマンドラインオプションです。

setting.json
{
    "python.pythonPath": ".venv/bin/python",
    "python.testing.pytestArgs": [
        "-o",
        "junit_family=xunit1",
        "--cov=preprocess/src",
        "--cov-branch",
        "--cov-report=term-missing",
        "preprocess",
    ],
    "python.testing.unittestEnabled": false,
    "python.testing.nosetestsEnabled": false,
    "python.testing.pytestEnabled": true,
    "python.linting.flake8Enabled": true,
    "python.linting.enabled": true
}

-o junit_family=xunit1 は、pytest v5系で必ず?出るアラートを抑止します。--covはカバレッジを表示させる設定です。また、--cov-branch により条件分岐カバレッジも確認でき、--cov-report=term-missing により未テスト箇所を行番号単位で明確にします。

このあたりの細かいオプションは、VSCodeの設定UIには組み込まれておらず、何かの拍子にVSCodeの設定UIでpytest設定を変えた際には、ファイルを直接編集して設定を記述する必要があります。

5. テスト結果

odpt_dump.pyのimportの例外箇所、メインルーチンを除き、全てカバレッジされたテストが実行され、成功しています。

============================= test session starts ==============================
(snip)
collected 2 items

preprocess/tests/test_odpt_dump.py ..                                    [100%]

(snip)
---------- coverage: platform darwin, python 3.8.3-final-0 -----------
Name                              Stmts   Miss Branch BrPart  Cover   Missing
-----------------------------------------------------------------------------
preprocess/src/__init__.py            0      0      0      0   100%
preprocess/src/config_secret.py       1      0      0      0   100%
preprocess/src/odpt_dump.py          22      4      4      1    73%   13-14, 35->36, 36-45
-----------------------------------------------------------------------------
TOTAL                                23      4      4      1    74%

============================== 2 passed in 0.46s ===============================

(snip)は途中省略の意味