PythonでUnitTestする際に副作用をモッキングするテクニック

2021年05月24日

前回の続き。

概要

テストコードを運用するにあたり、外部サービスとのAPI連携やデータベース接続などの「副作用」 を伴う制御のハンドリングが悩ましい課題となります。本記事では、副作用をモッキングしてテストコードの冪等性を担保する方法を紹介します。

副作用のコード例

オープンな天気予報API(無料)を呼び出すコードを書きます。 サンプルコードで呼び出しているAPI URLをブラウザで開くとJSONレスポンスが確認できます。 佐賀県の今日の天気を標準出力するシンプルなクラスです。

import requests


class WeatherFetchError(Exception):
    '''API呼び出しが何らかの要因でエラー'''
    pass


class SagaWeather(object):
    '''佐賀県の天気データ取得クライアント'''

    def get_todays_forecast(self):
        '''今日の天気を取得する'''
        try:
            return self._fetch()['forecasts'][0]['telop']
        except Exception:
            raise WeatherFetchError()

    def _fetch(self):
        return requests.\
            get('https://weather.tsukumijima.net/api/forecast/city/410010').\
            json()

if __name__ == '__main__':
    print(SagaWeather().get_todays_forecast())

天気は毎日変動するし、外部サービスAPIを利用する形になるので、同じAPIを同じ呼び方をしても毎日レスポンス内容が変動します。SagaWeatherクラスのget_todays_forecastメソッドはAPIのレスポンスに応じて異なる返却値を返すことになります。

副作用 とはこのように、外部リソースに依存して返却内容が一定にならない処理のことを言います。

$ python mytest2.py 
曇のち晴

上記のコードスニペットを mytest2.py という名前で保存して実行してみます。 今日の佐賀県の天気が標準出力されました。ただ、明日実行すると「雨」になるかもしれないし、「晴」となるかもしれません。天気次第で結果が変わります。

テストコードを書くときの課題

テストコードを書くときには、副作用が非常に厄介な課題となります。マズくなるポイントを実際にテストケースを書いてチェックしてみましょう。SagaWeatherクラスを定義したファイルにテストケースを追記します。

from unittest import TestCase


class TestSagaWeather(TestCase):
    def setUp(self):
        self._saga_weather = SagaWeather()

    def test_get_todays_forecast(self):
        '''今日の佐賀県が「曇のち晴」であることをテストする'''
        self.assertEqual(
            self._saga_weather.get_todays_forecast(),
            '曇のち晴',
        )

さきほど SagaWeather クラスの get_todays_forecast メソッドが「曇のち晴」を返却したので、その通りのテストを書きました。

$ python -m unittest mytest2
.
----------------------------------------------------------------------
Ran 1 test in 0.246s

OK

このクラスを実装した同日にテストを実行するとOKです。天気予報APIは特定の時間になると天気データが更新されるので、現時点では問題ありませんね!

翌日佐賀県が晴れの日に実行してみます。

$ python -m unittest mytest2

======================================================================
FAIL: test_get_todays_forecast (mytest2.TestSagaWeather)
今日の佐賀県が「曇のち晴」であることをテストする
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/lockhart/pywork/pyunit/mytest2.py", line 31, in test_get_todays_forecast
    self.assertEqual(
AssertionError: '曇のち晴' != '晴'
- 曇のち晴
+ 晴


----------------------------------------------------------------------
Ran 1 test in 0.204s

FAILED (failures=1)

get_todays_forecast メソッドが「晴」を返却するので、「曇のち晴」ではないとテストが失敗します。これではテストコードを毎日書き換えなければならず、テストコードの「運用」観点ではNGなテストケース設計になってしまっています。

副作用をモッキングする

この副作用を克服するテクニックとして、モッキングを利用するのです。

Mockとは、定義されたオブジェクトをダミーのものに差し替えて、オブジェクトの振る舞いをシミュレートするものです。言葉では分かりにくいですね。要するに、今回のケースでは、APIを呼び出して結果を返却する _fetch メソッドを固定値を返すようにしてしまえば良いというわけです。

コードを見てみましょう。

from unittest import TestCase, mock

class TestSagaWeather(TestCase):
    def setUp(self):
        self._saga_weather = SagaWeather()

    @mock.patch.object(SagaWeather, '_fetch', return_value={
        'forecasts': [{
            'telop': '曇のち晴'
        }]
    })
    def test_get_todays_forecast(self, mock_fetch):
        '''今日の佐賀県が「曇のち晴」であることをテストする'''
        self.assertEqual(
            self._saga_weather.get_todays_forecast(),
            '曇のち晴',
        )

テストメソッド test_get_todays_forecast メソッドを @mock.patch.object でデコレートします。これはオブジェクトモッキングをするためのAPIで、unittestパッケージに標準で含まれるAPIとなります。これでこのテストメソッドに限り、SagaWeatherクラスの _fetch メソッドはモッキングされ、実際に天気予報APIを呼び出さずに、return_value で指定した固定値を返却するダミーのメソッドに置き換えられます。

API呼び出し部分をモッキングしたことで、「副作用」 に依存しないテストコードになりました!雨の日でも、晴天の日でも、このテストコードは必ず成功します。

unittestでカバーできないこと

モッキングを利用するとテストの一貫性が保証できます。しかし、仮にこの天気予報APIが仕様変更してしまったらどうなるでしょうか?

残念ながらモッキングをするときはAPIのレスポンス内容を「想定」して返却値を定義するので、APIのレスポンス仕様の変更を検知する術はありません。

APIドキュメントやリリースマイルストーンをチェックして、仕様変更がないかエンジニアがチェックするしかないのです。Facebook や Google が提供するAPIも仕様は変わります(後方互換性はなるべく維持してくれますが・・)。このアップデートに追従するにはAPIの呼び出し処理とテストコード両方をメンテナンスしていかなければなりません。

「それならテストなんて書かない方がメンテコストが減るじゃないか」

と思った方もいるかもしれませんが、騙されたと思ってテストコードを書いてください。

何万行も膨れ上がったソースコードを経験と勘だけで属人的に管理していくことに比べれば、大した労力じゃないことに気づきます。

まとめ

今回は副作用を伴うコードの unittest を実践しました。いかがでしたでしょうか。

ビジネス環境や開発フェーズによっては、テストにかける時間を短縮し、品質を多少犠牲にしてでもプロダクトリリースを優先することもあります。

逆にプロダクトが決済機能を持つ、権限によって公開機能が変化するような性質を持つ場合は、バグがダイレクトに信頼失墜につながることがあります。

Twitterでフォロワー数表記が多少間違えて表示されてしまうのと、ECで決済したのに商品が届かないのでは障害影響が全然違いますよね。

現実の開発プロジェクトではテストコードの運用について、保証する内容や質をどう定義するか、開発チームでよくよく話し合って決めることになります。

コードカバレッジ100% を目指してプロダクトのリリースが大幅に遅延するような極端なことはしてはならないし、テストコードを一切書かないまま機能追加していくと半年後にはプログラム修正の影響範囲が全然分からなくなって機能追加どころではなくなります。

テストとどう向き合うかはエンジニアにとって重要なテーマです。永遠のテーマと言っても良いかもしれない。。

次回以降、より実践的なテスト運用について書きたいと思います。

それでは、良きPythonライフを!


Web系エンジニアでPython好き。バックエンド/フロントエンド問わずマルチな方面でエンジニアリングしています。