雑念ストレージ

プログラミング関連のメモとか

【Python】mock_openで複数のファイルの読み書きをMock化する

前にmock_openを使って1つのファイルの読み書きをMock化する方法を書いた。
複数のファイルの読み書きをMock化する場合は少しコツがあったので、メモしておく。

複数ファイルの読み込みをMock化する場合

import unittest
from unittest.mock import MagicMock, patch, mock_open


def read_2files(file_path1: str, file_path2: str) -> tuple[str, str]:
    # ファイルを2つ読み込んで返すだけ
    with open(file_path1, 'r', encoding='UTF-8') as f:
        data1 = f.read()

    with open(file_path2, 'r', encoding='UTF-8') as f:
        data2 = f.read()

    return data1, data2


class TestRead2Files(unittest.TestCase):
    def test_read_2files(self):
        # ファイルのモックを準備する
        file_dict = {
            'file1.txt': mock_open(read_data='AAA')(),
            'file2.txt': mock_open(read_data='BBB')(),
        }

        mock = MagicMock()

        # args[0]にファイルパスが入っているので、それに合わせてファイルのモックを切り替える。
        # openを呼び出す際に名前付き引数を使っているなら、lambdaの引数はargsだけではなくkwargsも用意する必要があるので注意。
        # 今回の場合、encodingを名前付き引数で指定している。
        mock.side_effect = lambda *args, **kwargs: file_dict[args[0]]

        with patch('builtins.open', mock):
            # 処理を呼び出す
            result = read_2files('file1.txt', 'file2.txt')
            # 結果を確認
            self.assertEqual(result, ('AAA', 'BBB'))

以下の部分で、ファイルパスごとにread_dataを設定したファイルのモックを用意している。

file_dict = {
    'file1.txt': mock_open(read_data='AAA')(),
    'file2.txt': mock_open(read_data='BBB')(),
}

mock_open(read_data='AAA')()のところは、カッコが続いて面食らうけど、以下のように段階を踏んで考えるとわかりやすい。

  • mock_open(read_data='AAA')
    • open関数のモック
  • mock_open(read_data='AAA')()
    • open関数を呼び出した結果
    • つまり、with open(file_path1, 'r', encoding='UTF-8') as f: でいう f のこと

複数ファイルの書き込みをMock化する場合

import unittest
from unittest.mock import MagicMock, patch, mock_open


def write_2files(file_path1: str, file_path2: str):
    # ファイル2つに書き込むだけ
    with open(file_path1, 'w', encoding='UTF-8') as f:
        f.write("AAA")

    with open(file_path2, 'w', encoding='UTF-8') as f:
        f.write("BBB")


class TestWrite2Files(unittest.TestCase):

    def test_write_multi(self):
        file_mock1 = mock_open()()
        file_mock2 = mock_open()()

        file_dict = {
            'file1.txt': file_mock1,
            'file2.txt': file_mock2,
        }

        mock = MagicMock()
        mock.side_effect = lambda *args, **kwargs: file_dict[args[0]]

        with patch('builtins.open', mock):
            write_2files('file1.txt', 'file2.txt')

            file_mock1.write.assert_called_once_with("AAA")
            file_mock2.write.assert_called_once_with("BBB")

読み込みとあまり変わらない。
書き込み(write)が呼ばれたことをassertしたいので、ファイルのモックは変数に持たせている。(file_mock1file_mock2)

複数ファイルの読み書き両方をMock化する場合

import unittest
from unittest.mock import MagicMock, patch, mock_open


def read_write_multi(input_file_path1: str, input_file_path2: str,
                     output_file_path1: str, output_file_path2: str):
    # ファイル2つを読み込んで、それぞれの内容に追記して別ファイル2つに書き出す
    with open(input_file_path1, 'r', encoding='UTF-8') as f:
        data1 = f.read()

    with open(input_file_path2, 'r', encoding='UTF-8') as f:
        data2 = f.read()

    with open(output_file_path1, 'w', encoding='UTF-8') as f:
        f.write(data1 + " APPEND 1")

    with open(output_file_path2, 'w', encoding='UTF-8') as f:
        f.write(data2 + " APPEND 2")


class TestReadWriteMulti(unittest.TestCase):

    def test_read_write_multi(self):
        output_file_mock1 = mock_open()()
        output_file_mock2 = mock_open()()

        file_dict = {
            'input1.txt': mock_open(read_data='AAA')(),
            'input2.txt': mock_open(read_data='BBB')(),
            'output1.txt': output_file_mock1,
            'output2.txt': output_file_mock2,
        }

        mock = MagicMock()
        mock.side_effect = lambda *args, **kwargs: file_dict[args[0]]

        with patch('builtins.open', mock):
            read_write_multi('input1.txt', 'input2.txt', 'output1.txt', 'output2.txt')

            output_file_mock1.write.assert_called_once_with("AAA APPEND 1")
            output_file_mock2.write.assert_called_once_with("BBB APPEND 2")

読み込みの例と書き込みの例を組み合わせただけ。

【Python】MagicMockの使い方メモ2 - ユニットテストでの使い方とpatch

ユニットテストでは、開発環境から呼び出せない処理はモックに差し替えてテストしたい。
たとえば、外部のAPIの呼び出しやファイルの読み書きなどをモックにすることが多い。

モックにするやり方が色々あるので、簡単にまとめる。

インスタンスのメソッドをMagicMockで置き換える

以下のhoge_funcメソッドをモックにしてみる。

class Hoge:
    def hoge_func(self):
        return 'called func'

hoge_funcメソッドにMagicMockを設定してみる。
メソッドがモックに置き換わり、戻り値が'called mock'になっている。

def test(self):
    hoge = Hoge()
    hoge.func = MagicMock(return_value='called mock')
    self.assertEqual(hoge.func(), 'called mock')

クラスメソッドをMagicMockで置き換える

クラスメソッドもモック化できる。
たとえば、以下のようにクラスメソッドを持つクラスがあったとする。

class Hoge2:
    @classmethod
    def class_func(cls):
        # 1から10のランダムな数値を返す
        return random.randint(1, 10)

クラスメソッドをMagicMockで置き換えれば、動作を変えることができる。

def test(self):
    print(Hoge2.class_func())
    # 3

    Hoge2.class_func = MagicMock(return_value=99)

    print(Hoge2.class_func())
    # 99

対象メソッドのパスを指定してモック化する(patch)

ユニットテストで、モック化したいインスタンスのメソッドにアクセスできることは少ないと思う。
大抵は、テストしたいモジュールから呼び出される別のモジュールをモック化したいはず。

たとえば、以下のようなコードを考えてみる。

class Hoge:
    def hoge_func(self):
        # 1から10のランダムな数値を返す
        return random.randint(1, 10)

class Fuga:
    def __init__(self):
        self.hoge = Hoge()

    def fuga_func(self):
        val = self.hoge.hoge_func()
        # 2倍にして返す
        return val * 2

Fugaクラスはインスタンス変数にHogeのオブジェクトを持っている。
Fugaクラスのfuga_funcのテストをしたい場合、Hogeクラスのhoge_funcの返す値がランダムなのでテストし辛い。

このような場合、patchhoge_funcをモック化すると楽にテストできる。

def test(self):
    with patch('module.Hoge.hoge_func') as hoge_func_mock:
        # hoge_funcの戻り値を10に固定する
        hoge_func_mock.return_value = 10

        fuga = Fuga()
        self.assertEqual(fuga.fuga_func(), 20)

patchは以下のようにデコレータとして書くこともできる。

@patch('module.Hoge.hoge_func', return_value=5)
def test(self, hoge_func_mock):
    fuga = Fuga()
    self.assertEqual(fuga.fuga_func(), 10)

デコレータで指定したモックはユニットテストの引数として記載する必要がある。(hoge_func_mockの部分)
複数のデコレータを設定した場合は、 デコレータでで書いた逆順 に引数を書く必要があるので注意する。

【Python】MagicMockの使い方メモ1 - MagicMockとは

普段なんとなく使っていたMagicMockについて調べ直したのでまとめる。

MagicMockとは

いきなりうまく説明できないけど、定義していないメソッドでも呼べてしまう、ぐにゃぐにゃの粘土みたいなイメージ、、

m = MagicMock()

func1_result = m.func1()
print(func1_result)
# <MagicMock name='mock.func1()' id='4313588304'>

func2_result = func1_result.func2()
print(func2_result)
# <MagicMock name='mock.func1().func2()' id='4313657552'>

上の例では、生成したインスタンスに対して、定義していないメソッドfunc1を呼び出している。
結果、func1は新しいMagicMockインスタンスを返している。

さらにそのインスタンスに対してメソッドfunc2を呼び出しても、同様に新しいMagicMockインスタンスを返している。

return_valueでメソッドが返す値を指定する

そのままでは単にMagicMockのインスタンスを返すだけで役に立たない。
MagicMockは、メソッドが返す値をreturn_valueに設定できる。

m = MagicMock()
m.func1.return_value = "hoge"

print(m.func1())
# hoge

side_effectでメソッドが呼ばれるたびに返す値を変える

メソッドを複数回呼び出したときに、返す値を変えたい場合はside_effectを使う。

m = MagicMock()
m.func1.side_effect = ["hoge1", "hoge2"]

print(m.func1())
# hoge1
print(m.func1())
# hoge2

メソッドを呼ばれたときに例外を投げたい場合もside_effectに設定する。

m = MagicMock()
m.func1.side_effect = Exception("テスト用の例外")

try:
    m.func1()
except Exception as e:
    print(e)
    # テスト用の例外

値を返したり、例外を投げたりを組み合わせることもできる。
以下のコードは、3回目のfunc1で例外を投げている。

m = MagicMock()
m.func1.side_effect = ["hoge1", "hoge2", Exception("テスト用の例外")]

m.func1()
m.func1()

try:
    m.func1()
except Exception as e:
    print(e)

ユニットテストでの使い方は、後で書く、、

【Python】mock_openでファイルの読み書きをMock化する

ファイルの読み書きをするプログラムをテストするとき、実際にファイルを用意するのは面倒。
ファイルの読み書きはMock化したいが、そんなときはPythonが公式で用意してくれているmock_open関数が便利。

たとえば、以下のようなプログラムをテストしたいとする。
(テキストファイルを読み込んで、1行追加した内容で別のファイルに保存しているだけ)

def func(input_file_path: str, output_file_path: str):

    with open(input_file_path, 'r', encoding='UTF-8') as f:
        data = f.read()

    with open(output_file_path, 'w', encoding='UTF-8') as f:
        f.write(data + '\nAPPEND!')

ファイルの読み書きをMock化してテストする場合、以下のようになる。

import unittest
from unittest.mock import mock_open, patch, call

import read_write  # テスト対象のスクリプト


class TestTextAppender(unittest.TestCase):

    def test_append(self):
        m = mock_open(read_data='INITIAL TEXT')
        with patch("builtins.open", m):
            # テスト対象のメソッドを実行
            read_write.func("input.txt", "output.txt")

        # ファイル読み込みのためのopenが呼ばれたことの確認
        self.assertEqual(m.call_args_list[0], call('input.txt', 'r', encoding='UTF-8'))
        # ファイル書き込みのためのopenが呼ばれたことの確認
        self.assertEqual(m.call_args_list[1], call('output.txt', 'w', encoding='UTF-8'))

        # mock化されたopenを呼び、ファイルオブジェクト(これもmock化されている)を取得する
        file = m()
        # ファイル書き込みのwriteが呼ばれたことの確認
        file.write.assert_called_once_with("INITIAL TEXT\nAPPEND!")

入力ファイルはmock_openメソッドの引数read_dataに設定すればOK。
出力ファイルはファイルオブジェクトのwriteから取得する。

mock_open についてのメモ

公式のリファレンスを見ると、mock_openは以下のように説明されている。

open() の利用を置き換えるための mock を作るヘルパー関数。 open() を直接呼んだりコンテキストマネージャーとして利用する場合に使うことができます。

コンテキストマネージャーとは、withブロックで使うことでファイルやリソースの開放を自動でやってくれるやつのこと。
JavaでいうClosableやAutoClosableみたいなもの。

ファイルの入出力は、コンテキストマネージャーとしての処理とreadやwriteの処理があって、mock化するのが大変だからmock_openが作られたのかな、、?

【PyCharm】選択中の変数のハイライト色を変える

PyCharmを使うときに設定しておきたい点。
選択中の変数をハイライトする色を変える。

設定変更前

dataを選択しているのだけど、色が地味過ぎて分かりづらい、、

設定変更後

これなら見やすい。


Editor > Color Scheme > General にある、Code > Identifier under caretIdentifire under caret (write) の色を変える。

変数の使用箇所を確認しながらコードを読みたいので、目立つ色だと嬉しい。

【Django】Factory Boyで、カテゴリを外部テーブルに持つようなレコードを簡単に作成する

以下のような、「本」と「本の種別(マスターデータ)」を表すModelがあるとする。

class BookCategory(models.Model):
    name = models.TextField()

class Book(models.Model):
    name = models.TextField()
    book_category = models.ForeignKey(BookCategory, on_delete=models.DO_NOTHING)

実データの例は以下のような感じ。

ER図にすると以下のようになる。

BookのレコードをFactory Boyで作りたいとする。
単純に作ると、以下のようになる。

class BookFactory(DjangoModelFactory):
    class Meta:
        model = models.Book

    name = "Test Name"
    book_category = BookCategory.objects.get(name='comic')

この例では、book_categoryのデフォルト値を'comic'のレコードにしている。
ただ、ユニットテストではカテゴリを色々と変えてレコードを作りたい。

# カテゴリがデフォルトの'comic'ならこれでいいけど
book1 = BookFactory.create()

# 別のカテゴリを設定したいときは、マスターデータを取得してくる必要がある。ちょっと面倒。
category = BookCategory.objects.get(name='science')
book2 = BookFactory.create(book_category=category)

こういうときは、excludeとlazy_attributeを組み合わせて、factory側でマスターデータを取得すると便利。

class BookFactory(DjangoModelFactory):
    class Meta:
        model = models.Book
        exclude = ["category_name"]

    name = "Test Name"

    # Bookのmodelに存在しない、カテゴリを取得するキーとして使うだけのフィールド
    category_name = "comic"

    book_category = factory.LazyAttribute(lambda o: BookCategory.objects.get(name=o.category_name))

category_nameというフィールドを追加し、BookCategoryを検索するキーとして使用している。
book_categoryはLazyAttributeとして遅延評価させて取得している。

category_nameはBookには存在しないフィールドなので、excludeにに指定する必要がある。
(excludeに指定することで、factory内で使うだけのフィールドだということを明示している)

book2 = BookFactory.create(category_name='history')

Bookの生成は以上のようになるため、スッキリ書けるようになった。

【Git】間違えてMergeしたプルリクをRevertしたあと、もう一度Mergeしたいとき

mainブランチにプルリクをMergeしたあと、一旦Revertで差し戻してから、しばらくして再度Mergeしたいときがある。

ただ、何も考えずに再度Mergeしようとしてもうまくいかない。

上の図であれば、再度マージしてもコミット3とコミット4はmainブランチに反映されない。
(というか、develop->mainのPRを再度作成しようとしても、差分がないと表示される)

こうなってしまうのは、Gitはコミット3とコミット4はすでにmainにマージ済みだと判断するため。

手っ取り早く対処するなら、新規に作成したブランチでRevertのRevertをする。

これならRevertしたコミット3とコミット4を復活できる。
必要なら、develop2ブランチで追加で修正もできる。