wkwk_soprano’s blog

wkwkのメモです

pytestでmonkeypatchを使う

(旧ブログからの移行記事です)
pytestでテストしたいクラスの一部メソッドをモック化したいときにmonkeypatchをつかう。この記事はそのメモ。

-- 例えば以下のようなクラスのテストをすることを想定する。

class Hoge:
    x = 10
    y = 20

    def sum_hoge(self):
        return self.x + self.y

    def sum_power(self):
        sum_x_y = self.sum_hoge()
        return pow(sum_x_y,2)

sum_hogeをテストするときに、sum_hoge内で参照しているクラス変数xを変えても確からしい結果(ここでは「変更後のx+yの値」になること)になるか確かめたい
このときmonkeypatchを使うと以下のようにテストできる

import Hoge # ここはディレクトリ構成等に合わせて変える

def test_sum_hoge_success(monkeypatch):
    hoge = Hoge()
    monkeypatch.setattr(hoge, "x", 20)
    expected_result = 40
    actual_result = hoge.sum_hoge()
    assert expected_result == actual_result

monkeypatchは組み込み関数なので新たにimportする必要はない。

次にsum_powerをテストする
このメソッドの中ではsum_hogeを使っているので、sum_hogeメソッドをモックしてsum_powerをテストする

import Hoge # ここはディレクトリ構成等に合わせて変える

def test_sum_power_success(monkeypatch):
    def mock_sum_hoge():
        return 50

    hoge = Hoge()
    monkeypatch.setattr(hoge, "sum_hoge", mock_sum_hoge)
    expected_result = 2500
    actual_result = hoge.sum_power()
    assert expected_result == actual_result

mock_sum_hogeHogeクラスのsum_hogeメソッドをモックしている関数   
mockerを使う方法もあるようだが、違いはよくわかっていない
ひとまずはmonkeypatchでしのげるためこれでいく

【メモ】Azure FunctionsをVisual Studioから初めてデプロイする - その1

以下のドキュメントに従って作業を進める https://docs.microsoft.com/en-us/azure/azure-functions/functions-develop-vs?tabs=in-process

最初は単にログを表示するだけのFunctionにして、Blobとかにアクセスさせるのはあとでやることにする

Publishする

Publish to AzureのところでResource GroupやPlan Typeを選ぶところがある
Resource GroupやStorage Accountは事前に作っておけばよいだけ
Plan TypeはConsumptionのままが良さそう
 ←他のを選ぶとApp Service建てられてしまい、起動回数ベースの課金というメリットを失ってしまう模様
 ←今回は1日1回実行すればよいようなFunctionを考えているため、App Service建てられると困る
 

App Insightsの設定をする

Functionが出力するログを確認するにはApp Insightsの設定もする必要があるのだが、最初にデプロイした時点では未設定となっている(=自動で設定してくれるわけではない)
以下手順のNew function app in the portalの部分に従い設定するとMonitorでログが確認できるようになる
https://docs.microsoft.com/en-us/azure/azure-functions/configure-monitoring?tabs=v2#enable-application-insights-integration

(以下が実際のログ/最初はTimer Triggerでログ出力するだけのFunctionをデプロイしている)

そもそもこういったログのプライシングはどうなっているのか(Azure Functions自体が起動回数ベースの課金で安く済ませられてもログでお金がかかりすぎてはよくない)
https://azure.microsoft.com/en-us/pricing/details/monitor/

そもそもApp Insightsについてきちんと知らない(のでいつかドキュメント読む)

次にやりたいこと

Blob StorageやSQL Databaseにアクセスする

たぶんつづく

【メモ】 単体テストとinternalメソッド

publicにはしたくないけれどもテストをしたいメソッドの処遇をどうするべきか 例えばユーザの住所を登録するクラスをpublicとprivateのメソッドからなる以下のような処理で書いたとする

public class UserAddress
{
    public void Register(string address)
    {
        var detailedAddress = AddressDetails(address);
        (DBに登録する処理)
    }

    private AddressDetails DecomposeAddress(string address)
    {
        var addressDetails = new AddressDetails
        {
            (住所をAddressDetailsに入るよう分割する処理)
        };
        return addressDetails;
    }
}

public class AddressDetails
{
    public string PostalCode { get; set; }
    public string Prefecture { get; set; }
    public string City { get; set; }
}

ここでDecomposeAddressへ入力する住所が不正なときに想定した挙動になっているかテストしたいのだが、privateのままだと単体テストができない(Reflectionとやらを使ったらできるとのことだが、今回の話からそれるので今は触れない) しかしながらこのクラス自体は「(ユーザから)住所の文字列を受け取って(DBへ)登録する」という責務で作られているため、DecomposeAddressメソッドを外部に向けて公開する必要がない

C#の場合、このprivateメソッドをinternalにすることでテスト用のプロジェクトからアクセスできるようにするという方法を取れるらしい 具体的にはinternalなクラスをテストする場合、 [assembly: InternalsVisibleTo("テストのアセンブリ")] というアトリビュートをクラスのあるファイル内に記述するとそのように設定できるとのこと(こちらの記載より)

このような方法がとれるのであれば、テストしたいような詳細は最初からinternalで書いてしまえばよい気がしている 実際こちらのStack Overflowの回答をみているとinternalクラスはテストされるべきで、またこちらのdiscussionからするとテストされるべき詳細はinternalで書くべきという主張を確認できる ※「テストされるべき詳細はinternalで書くべき」は本文からすると言いすぎかもしれない

InternalsVisibleToのアトリビュートを使えばテスト以外のアセンブリからも参照することは可能だが、それをしたいならそもそもpublicにすればよいだけなので、あくまで開発上は使いたいけど公開したくないようなクラス・メソッドに対して使うのがいいのかなと感じた

pytestで環境変数の一時的な設定をしたい

TL;DR

環境変数に関係するメソッドのテストで、一時的に環境変数を書き換えたい→mock.patchを使うことで実現できる

やりたいこと

異常系のテストとして、環境変数IDが設定されていないときはKeyErrorが発生することを確認するテストを設けたい

設定

ファイルの構成は以下の通り

my_dir/
  ├ src/ 
  │   └ my_code.py 
  └ test/
      └ test_my_code.py

環境変数を取得するメソッドが以下のように存在していたとする(my_code.py)

from dataclasses import dataclass
import os

@dataclass
class getEnvNames:
    def __init__(self):
        self.id = os.environ["ID"]
        self.name = os.environ["NAME"]

テストの方針

環境変数を一時的に書き換える場合はunittest.mockmock.patchを使う
使い方は以下の通り

from unittest.mock import patch

with patch.dict("os.environ", {"ID": "test"}):
    test_func()

まずは正常系のテスト

単純に置き換えるだけであれば以下のような感じでOK

from unittest.mock import patch
from my_code import getEnvNames

def test_getEnvNames_success():
    with patch.dict("os.environ", {"ID": "1", "NAME": "test"}):
        target_class = getEnvNames()

    assert target_class.id = "1"
    assert target_class.name = "test"

次に異常系のテスト

今回の主題である環境変数が設定されていなときのバージョン いまのところ調べた感じだとwith句の中で環境変数の削除をするしかなさそう ここで設定された環境変数は一時的に削除されるようで、with句を出たらもとに戻っている様子

import pytest
from unittest.mock import patch
from my_code import getEnvNames

def test_getEnvNames_failure():
    os.environ["ID"] = "2"
    os.environ["NAME"] = "sample"

    with patch.dict("os.environ", {"ID": "1", "NAME": "test"}):
        os.environ.pop("ID", default=None)
        with pytest.raises(KeyError):
            getEnvNames()

    assert os.environ["ID"] == "2"
    assert os.environ["NAME"] == "sample"

elasticsearch.pyのexpand_wildcards指定について

背景

ES投入時にインデックスを削除する処理をしているが、そこで使っているメソッドにexpand_wildcardsという引数が用意されていた これがデフォルトでopenになっているらしく、このままではcloseしたインデックスは削除できないのでは?という疑問が生じた

例えば以下のような処理を想定している(以下のコードは動作確認していない)

# 準備
from elasticsearch import Elasticsearch
es = Elasticsearch()

# 以下の部分が
es_client.indices.delete(index="target-index")
# 本当はあるべきではないか?
es_client.indices.delete(index="target-index", expand_wildcards="all")

ドキュメントにあたる

es-insertのインデックス削除に使っているのは上述の通りElasticsearchクラスだが、このクラスのdeleteメソッドではexpand_wildcardsという引数は存在してなさそう https://elasticsearch-py.readthedocs.io/en/7.x/api.html#elasticsearch.Elasticsearch.delete

一方IndicesClientdeleteにはexpand_wildcardsという引数が存在している https://elasticsearch-py.readthedocs.io/en/7.x/api.html#elasticsearch.Elasticsearch.delete

この違いは一体何なのだ

そもそもElasticsearchとIndicesClientどちらのクラスを使うべきか?

Elasticsearchクラスに関する記述を読んでみると、以下のようなことが書いてある(リンク

The instance has attributes cat, cluster, indices, ingest, nodes, snapshot and tasks that provide access to instances of CatClient, ClusterClient, IndicesClient, IngestClient, NodesClient, SnapshotClient and TasksClient respectively. This is the preferred (and only supported) way to get access to those classes and their methods.

つまるところ、ElasticsearchクラスのindicesというattributeはIndicesClientにアクセスするためのものらしく、そちらを使ってほしいとのこと ならなぜdeleteメソッドに使える引数が異なるのか引っかかるところだが、一旦その気持ちは抑えて、Elasticsearchクラスをそのまま使い次に進もうと思う

expand_wildcardsとはなにか

とはいえ結局IndicesClientは使っているので、問題のこのクラス(メソッド)について見てみる IndicesClientクラスのdeleteメソッドに用意されているexpand_wildcardsの説明をみると以下のようなことが記載されている

Whether wildcard expressions should get expanded to open or closed indices (default: open) Valid choices: open, closed, hidden, none, all Default: open

Whether wildcard expressions should get expanded to open or closed indicesとあることから、あくまでこの引数は削除対象のインデックス名を指定する際にワイルドカードを用いた場合につかうものであるようだ

当初の疑問に対して答えると - index引数にhoge-indexのようなインデックス名を指定している場合、そのインデックスがopenでもcloseでも削除できる - index引数にhoge-*のようなワイルドカードを用いた指定をしている場合、そのパターンにマッチするインデックスであってもcloseされているものは削除の対象にならない

ということになる

確証のない話

ElasticsearchにはResolve APIなるものがあるらしく、そちらが元なのか?とも感じている https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-resolve-index-api.html