[Python] unittest.mockを試してみる

こんにちは、福岡オフィスのyoshihitohです。最近は仕事でAPI GatewayやAWS IoTなど、サーバーレスよりのサービスを触り始めてPythonを書く機会が増えました。

Pythonの単体テストで unittest.mock を使う機会があったので、公式ドキュメントのチュートリアルを読みつつ動きを試してみました。

動作環境

  • macOS Mojave 10.14.6
  • Python 3.7.4
  • Pipenv 2018.11.26
  • pytest 5.1.0

概要

チュートリアルによると、Mockは以下の用途で使われることが多いようです。

一般的な Mock の使い方の中には次のものがあります:

  • メソッドにパッチを当てる
  • オブジェクトに対するメソッド呼び出しを記録する

unittest.mock --- 入門 — Python 3.7.4 ドキュメント

チュートリアルではオブジェクトのメソッドに対するモック/パッチについて詳細に説明されていますが、今回は自作の関数と、標準ライブラリの関数を対象として試してみます。

やりたいこと

例えば以下のようなコードがあって、テストを追加するとします。

from collections import namedtuple
import json


class Book(namedtuple("Book", ["id", "title"])):
    def as_json(self):
        return json.dumps(dict(id=self.id, title=self.title))


def _read_books():
    return [Book(id=1, title="DevelopersIO")]


def create_json_lines():
    return [b.as_json() for b in _read_books()]

こういう作りだと create_json_lines() の内部で _read_books() に依存していてテストしにくいですよね。コードを修正できる場合はデータの読み込みとシリアライズを分離してやれば良いんですが、色々な制約でコードを変更できない場合もあると思います。

そこで mock.patch を使って、 _read_books() の挙動を書き換えてみましょう。

本エントリの後半で json.dumps のパッチも試してみます。詳細は後述します。

環境構築

Pipenvで環境を構築します。ローカルにPython3.7.4を導入済みの前提です。筆者は pyenv でPythonのバージョン管理しています。

$ pipenv install --python 3.7.4
$ pipenv install --dev 'pytest~=5.1'

テストを追加する

初期実装

まずテストケースを書いてみます。普段は pytest を使うことが多いらしく、練習のためpytest前提の作りを試してみます。

from book.book import create_json_lines, Book


class TestBook(object):
    # (1) 実際の挙動をテスト
    def test_create_json_lines(self):
        assert create_json_lines() == ['{"id": 1, "title": "DevelopersIO"}']

    # (2) データのパターンを増やしてテスト
    def test_mock(self, mock_read_books):
        # NOTE: json.dumpsはensure_asciiオプションの指定を省略すると
        #       ASCII範囲外の文字をユニコードポイントに置き換えるため `u6843` のような形式になる
        assert create_json_lines() == [
            # タイトル: 桃太郎
            '{"id": 1, "title": "\\u6843\\u592a\\u90ce"}',
            # タイトル: きんたろう
            '{"id": 2, "title": "\\u304d\\u3093\\u305f\\u308d\\u3046"}',
            # タイトル: Urashima Tarō
            '{"id": 3, "title": "Urashima Tar\\u014d"}',
            # タイトル: (なし)
            '{"id": 4, "title": ""}',
        ]

(1)はそのままの挙動でテストできますが、(2)は _read_books() の挙動を変えないとテストできません。試しにこの状態でテストを実行してみます。

$ python -m pytest tests/test_book.py
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.4, pytest-5.1.0, py-1.8.0, pluggy-0.12.0

...

================================================================= FAILURES =================================================================
____________________________________________________________ TestBook.test_mock ____________________________________________________________

self = <test_book.TestBook object at 0x1080adb90>

    def test_mock(self):
>       assert create_json_lines() == [
            # タイトル: 桃太郎
            '{"id": 1, "title": "\\u6843\\u592a\\u90ce"}',
            # タイトル: きんたろう
            '{"id": 2, "title": "\\u304d\\u3093\\u305f\\u308d\\u3046"}',
            # タイトル: Urashima Tarō
            '{"id": 3, "title": "Urashima Tar\\u014d"}',
            # タイトル: (なし)
            '{"id": 4, "title": ""}',
        ]
E       assert ['{"id": 1, "...velopersIO"}'] == ['{"id": 1, "..."title": ""}']
E         At index 0 diff: '{"id": 1, "title": "DevelopersIO"}' != '{"id": 1, "title": "\\u6843\\u592a\\u90ce"}'
E         Right contains 3 more items, first extra item: '{"id": 2, "title": "\\u304d\\u3093\\u305f\\u308d\\u3046"}'
E         Use -v to get the full diff

tests/test_book.py:27: AssertionError
======================================================= 1 failed, 1 passed in 0.04s ========================================================

テストが失敗しました。実際の _read_books() メソッドそのままなので正しい挙動です。今度はこのパターンでテストできるようにするため、 mock.patch を使って挙動を書き換えてみます。

自作関数 (_read_books)をモックする

ファイルの先頭に以下のimport文を追加します。

from unittest import mock

次に、(2)のテストメソッドでモックを有効にするため @mock.patch デコレータを使います。

    # (a) book.book._read_books をモック指定する
    @mock.patch("book.book._read_books")
    def test_mock(self, mock_read_books):  # mock_read_books がモックオブジェクト
        # (b) モックオブジェクトの戻り値を設定する
        mock_read_books.return_value = [
            Book(id=1, title="桃太郎"),  # 漢字
            Book(id=2, title="きんたろう"),  # ひらがな
            Book(id=3, title="Urashima Tarō"),  # ローマ字
            Book(id=4, title=""),  # 空文字列
        ]

        # NOTE: json.dumpsはASCII範囲外の文字をユニコードポイントに置き換えるため `u6843` のような形式になる
        assert create_json_lines() == [
            # タイトル: 桃太郎
            '{"id": 1, "title": "\\u6843\\u592a\\u90ce"}',
            # タイトル: きんたろう
            '{"id": 2, "title": "\\u304d\\u3093\\u305f\\u308d\\u3046"}',
            # タイトル: Urashima Tarō
            '{"id": 3, "title": "Urashima Tar\\u014d"}',
            # タイトル: (なし)
            '{"id": 4, "title": ""}',
        ]

ハイライトした箇所がモックの設定です。(a)で _read_books() をモックで差し替えることを宣言し、テストメソッドの引数 mock_read_books にモックオブジェクトを受け取ります。

(b)でモックオブジェクトの戻り値を設定してやると、 create_json_lines() の結果が設定した内容を反映する形に置き換わる想定です。

意図した動きになるか試してみます。

$ python -m pytest tests/test_book.py
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.4, pytest-5.1.0, py-1.8.0, pluggy-0.12.0
rootdir: /Users/yoshihitoh/workspace/projects/blog/python/mock-tutorial
collected 2 items

tests/test_book.py ...                                                                                                               [100%]

============================================================ 2 passed in 0.02s =============================================================

今度はテスト成功しました。 read_books() のモック化がうまく動いたようです。

現状のテストケースだと、日本語を含むテストデータの実行結果の確認でユニコードポイントを指定しています。この方式だと視認性が悪いので標準モジュールの json.dumps の挙動も書き換えてみます。

標準ライブラリの関数(json.dumps)をモックする

標準ライブラリのjson.dumps のモックは基本的な動作はそのままに、 ensure_ascii の指定を True→Falseに変更したいです。この場合の対応はチュートリアルの 部分的なモック を参考にします。

元のモジュールやクラスを事前にimportしておき、モック処理で必要な情報を付加して元の処理に委譲してやるようです。

早速試してみます。まずテストコードを日本語で比較するように変更します。

    @mock.patch("book.book._read_books")
    def test_mock(self, mock_read_books):
        mock_read_books.return_value = [
            Book(id=1, title="桃太郎"),  # 漢字
            Book(id=2, title="きんたろう"),  # ひらがな
            Book(id=3, title="Urashima Tarō"),  # ローマ字
            Book(id=4, title=""),  # 空文字列
        ]

        assert create_json_lines() == [
            '{"id": 1, "title": "桃太郎"}',
            '{"id": 2, "title": "きんたろう"}',
            '{"id": 3, "title": "Urashima Tarō"}',
            '{"id": 4, "title": ""}',
        ]

この状態でテストを実行すると失敗することを確認します。

$ python -m pytest tests/test_book.py
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.4, pytest-5.1.0, py-1.8.0, pluggy-0.12.0

...

================================================================= FAILURES =================================================================
____________________________________________________________ TestBook.test_mock ____________________________________________________________

self = <test_book.TestBook object at 0x1095c7650>, mock_read_books = <MagicMock name='_read_books' id='4452021968'>

    @mock.patch("book.book._read_books")
    # @mock.patch("json.dumps", new=non_ascii_json_dumps)
    def test_mock(self, mock_read_books):
...

>       assert create_json_lines() == [
            '{"id": 1, "title": "桃太郎"}',
            '{"id": 2, "title": "きんたろう"}',
            '{"id": 3, "title": "Urashima Tarō"}',
            '{"id": 4, "title": ""}',
        ]
E       assert ['{"id": 1, "..."title": ""}'] == ['{"id": 1, "..."title": ""}']
E         At index 0 diff: '{"id": 1, "title": "\\u6843\\u592a\\u90ce"}' != '{"id": 1, "title": "桃太郎"}'
E         Use -v to get the full diff

tests/test_book.py:25: AssertionError
======================================================= 1 failed, 1 passed in 0.04s ========================================================

ちゃんと失敗しますね。

次に、json.dumpsのカスタマイズ版の関数を実装します。

from json import dumps


def non_ascii_json_dumps(*args, **kwargs):
    # NOTE: 明示的な指定がある場合はそっちを優先するため、固定値→kwargsの順にする
    return dumps(*args, **{"ensure_ascii": False, **kwargs})

最後に json.dumps を上記の関数で置き換えるようにパッチを当てます。テストケースを日本語で比較するように変更します。ハイライト部分が変更箇所です。

    @mock.patch("book.book._read_books")
    @mock.patch("json.dumps", new=non_ascii_json_dumps)
    def test_mock(self, mock_read_books):
      ...

動作確認してみます。

$ python -m pytest tests/test_book.py
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.4, pytest-5.1.0, py-1.8.0, pluggy-0.12.0
rootdir: /Users/yoshihitoh/workspace/projects/blog/python/mock-tutorial
collected 2 items

tests/test_book.py ..                                                                                                                [100%]

============================================================ 2 passed in 0.01s =============================================================

今度はテストが成功しました。 json.dumps の置き換えも問題なく動作しているようです。

おわりに

今回は unittest.mock モジュールを使って自作の関数と標準ライブラリの関数の挙動をモックしてみました。

他の言語のモックと近い間隔で使いやすかったのと、デコレータやwith文を使ってスコープを調整しやすいのがとても良いなと思いました。今回は標準ライブラリのモックのみ試しましたが、今後はサードパーティのライブラリも確認してみたいと思います。