UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

pytest-mock使ってハマったこと

こんにちは。

SPEEDA開発チームの掛川です。

現在、私が参画しているプロジェクトではPythonを使ってサービスの開発を行なっています。 私自身、Pythonを書くのは今回が初めてなのですが、 テストを書く際にハマったことについて記事にしていきたいと思います。

環境といろいろ

・環境
    OS     Mac OS X  10.14.5
    python 3.7.3
・ライブラリ
    pytest 5.1.2
    pytest-mock 1.11.1


私が今まで参画していたプロジェクトでは、モックライブラリはMockKを使用していた(使用言語はKotlinでした)のですが 「MockitoやMockKと同じように表現したい時にpytestではどうやって書いたらいいの?」 と疑問に思いハマったことの中から今回は以下の2つの内容にフォーカスしていきます。

  • classメソッドをモックしたい時
  • 引数に応じて返す値を変えたい時


classメソッドをモックしたい時

今回は請求書を発行するシステムを想定しました。

f:id:kkyki:20191011013258p:plain
請求書のイメージ

まずは、請求書の発行を行うためのusecaseとusecaseのテストを書いてみました。
このusecaseで行いたいことは4つです。

  1. 渡されたuser_idをもとに顧客情報(customer)を取得してくる
  2. 渡されたuser_idをもとに購入情報(Details)を取得してくる
  3. 購入情報(Details)から購入の合計金額を算出する
  4. 請求書(Invoice)の発行に必要な情報をControllerに返す
class InvoiceUseCase:

    @classmethod
    def publish(cls, user_id):
        # 1.顧客情報(customer)の取得
        customer = CustomerGateway.get(user_id)
        # 2.購入情報(Details)を取得
        details = SalesHistoriesGateway.get(user_id)
        # 4. 請求書情報(Invoice)を返却
        return Invoice(
            billing_date=datetime.date(2019, 1, 1),
            customer=customer,
            details=details,
            total=details.total())  # 3. 合計金額を算出
class TestInvoiceSheetUseCase:

    def test_publish(self, mocker):
        # Customerをモックする
        customer = mocker.Mock(Customer)
        customer_gateway_mock = mocker.patch.object(
            CustomerGateway,
            'get',
            return_value=customer)

        # Detailをモックする
        detail1 = mocker.Mock(Detail)
        detail2 = mocker.Mock(Detail)
        details = Details(details=[detail1, detail2])
        sales_histories_gateway_mock = mocker.patch.object(
            SalesHistoriesGateway,
            'get',
            return_value=details)

        expected = Invoice(
            billing_date=datetime.date(2019, 1, 1),
            customer=customer,
            details=details,
            total=details.total())

        assert InvoiceUseCase.publish('user_id') == expected

        # CustomerGatewayのgetメソッドが指定した引数'user_id'で1回呼ばれたことの検証
        customer_gateway_mock.assert_called_once_with('user_id')
        # SalesHistoriesGatewayのgetメソッドが指定した引数'user_id'で1回呼ばれたことの検証
        sales_histories_gateway_mock.assert_called_once_with('user_id')

MockitoやMockKでclassメソッドをモックしたい時には以下のように
when(モックしたいクラスオブジェクト.モックしたいメソッド).thenReturn(返したい値);
every { モックしたいクラスオブジェクト.モックしたいメソッド } return 返した値
と書くことができると思いますが、 pytest-mockでは
mocker.patch.object(モックしたいクラスオブジェクト, 'モックしたいメソッド', return_value=返したい値)
または
mocker.patch('モックしたいクラスオブジェクト.モックしたいメソッド', return_value=返したい値)
と書くことができます。

引数に応じて返す値を変えたい時

上記で書いていたものは全てreturn_value を使っており、 モックしたいメソッドは毎回固定の値を返すように設定していました。
ですが、メソッドに渡された引数に応じて返す値を変えたい時はないでしょうか?
そういう場合は、side_effect を使います。
side_effect を使うと、モックしたいメソッドの代わりに side_effect で定義したメソッドが呼び出され(引数はモックしたいメソッドと合わせる)、そのメソッドが返す値がモックの返す値として使われます。


次に、顧客情報を扱うgatewayとgatewayのテストを書いてみました。
このgatewayで行いたいことは以下です。

  • 複数のuser_idを受け取り、それに対するそれぞれの顧客情報の集合体(Customers)を返す
class CustomerGateway:

    @classmethod
    def get_customers(cls, user_ids):
        return Customers(list(map(
            lambda user_id: CustomerDriver.find_by(user_id), user_ids)))
    def test_get_customers(self, mocker):
        def find_by_id(user_id):
            if user_id == 'user_id1':
                return Customer(
                    id='user_id1',
                    name='Alice',
                    zip_code=77777,
                    address='Anaheim, CA',
                    number=123456789)
            elif user_id == 'user_id2':
                return Customer(
                    id='user_id2',
                    name='Bill',
                    zip_code=88888,
                    address='New York, NY',
                    number=111111111)
            else:
                self.fail('invalid user_id!!')

        mocker.patch.object(
            CustomerDriver,
            'find_by',
            side_effect=find_by_id)

        assert CustomerGateway.get_customers(['user_id1', 'user_id2']) == Customers([
            Customer(
                id='user_id1',
                name='Alice',
                zip_code=77777,
                address='Anaheim, CA',
                number=123456789),
            Customer(
                id='user_id2',
                name='Bill',
                zip_code=88888,
                address='New York, NY',
                number=111111111)
        ])


mockerについて

mockerはPythonの標準ライブラリであるmockライブラリの薄いラッパーで、unittestで提供しているモックパッケージと同じ引数をサポートしており、 テストメソッドに引数として渡すことで使うことができます。
また、mockerの便利なところはpatchしたクラスメソッドを初期化する必要がないところです。 mockerでモックしたインスタンスは実行対象のテストメソッドの実行後に自動的にリセットされます。

mocker.patch.object()mocker.patch() はどちらを使ってもモックすることは可能ですが、両者の違いは
mocker.patch.object() は外部から注入されたインスタンスに対してモックしますが、 mocker.patch() では渡された文字列から内部で対象のクラスとメソッドの参照を取得しモックします。


上で書いたgatewayに、指定されたuser_idを受け取り対応する顧客情報(Customer)を返すメソッドを追加して
mocker.patch.object()mocker.patch() のそれぞれを使ってテストを書いてみました。

class CustomerGateway:

    @classmethod
    def get(cls, user_id):
        return CustomerDriver.find_by(user_id)
<mocker.patch.object()を使った場合>
class TestCustomerGateway:

    def test_get_customer(self, mocker):
        customer = Customer(
            id='id001',
            name='Alice',
            zip_code=77777,
            address='SEATTLE USA',
            number=123456789)
        # mocker.patch.object(
        #    モックしたいクラスオブジェクト,
        #    'モックしたいメソッド',
        #    return_value=返したい値)
        m = mocker.patch.object(
            CustomerDriver,
            'find_by',
            return_value=customer)

        assert CustomerGateway.get('user_id') == Customer(
            id='id001',
            name='Alice',
            zip_code=77777,
            address='SEATTLE USA',
            number=123456789)

        m.assert_called_once_with('user_id')
<mocker.patch()を使った場合>
class TestCustomerGateway:

    def test_get_customer(self, mocker):
        customer = Customer(
            id='id001',
            name='Alice',
            zip_code=77777,
            address='SEATTLE USA',
            number=123456789)
        # mocker.patch(
        #    'モックしたいクラスオブジェクト.モックしたいメソッド',
        #    return_value=返したい値)
        m = mocker.patch(
            'app.main.driver.customer_driver.CustomerDriver.find_by',
            return_value=customer)

        assert CustomerGateway.get('user_id') == Customer(
            id='id001',
            name='Alice',
            zip_code=77777,
            address='SEATTLE USA',
            number=123456789)

        m.assert_called_once_with('user_id')

終わりに

初めてPythonのテストを書いた私がハマったことをつらつらと書いてみましたが、
この記事が私と同じように初めてPythonのテストを書く方のお役に立てれば幸いです。

参考資料