堅牢なBorgパターンのクラスをテストできるか

Borgパターンとは

Singletonはあるクラスのインスタンスを一つだけにするという実装です。しかし、実際にはインスタンスを一つにしたいのではなく、状態が同じインスタンスが手に入れば良い場合がほとんどではないでしょうか。Borgパターンはまさに、あるクラスのすべてのインスタンスで同じ状態を持つ実装です。

Borgパターンの例

これはアプリケーション内の設定を持つConfigクラスをBorgパターンで実装した例です。※良い例が浮かばなかったのでこれを掲載しましたが、この内容をPythonで行うのならモジュールを使うのが最も簡単でしょう。

Pythonの言語仕様で、クラス変数(ここでは__shared_state)は全クラスで共通になっています。この__share_stateをオブジェクトの状態を保持する__dict__に代入することで、このクラスのすべてのインスタンスで状態が共有されることになります。

class Config(object):
    __shared_state = {}

    def __init__(self, config=None):
        self.__dict__ = self.__shared_state
        if 'conf' not in self.__dict__:
            self.conf = config

一番初めにConfigクラスをインスタンス化する際に

config = Config({
    'key': 'value'
})

このように辞書を渡すことで、以後どこからでも最初に渡した辞書の内容にアクセスできます。

UnitTestをしたい

ここにBorgパターンを利用したクラスが二つあります。一つは先ほどと全く同じConfigクラス。もう一つはそのConfigクラスを内部で利用するTestTargetクラスです。さらにこのTestTargetクラスは__call__メソッドを持っています。 このTestTargetクラスが得たConfigのdebugの値がTrueの場合に、SomeClassのfuncが呼ばれ、Falseの場合にfuncが呼ばれないことをテストしたいと思います。

# borg.py

class Config(object):
    __shared_state = {}

    def __init__(self, config=None):
        self.__dict__ = self.__shared_state
        if 'conf' not in self.__dict__:
            self.conf = config

    def __getattr__(self, name):
        return self.conf[name]


class TestTarget(object):
    __shared_state = {}

    def __init__(self):
        self.__dict__ = self.__shared_state
        if '_instance' not in self.__dict__:
            self._instance = SomeClass()
            if Config().debug:
                self._instance.func()

    def __call__(self):
        return self._instance

    @classmethod
    def get(cls):
        return cls()()


class SomeClass(object):
    def func(self):
        pass

このTestTargetクラスが得たConfigのdebugの値がTrueの場合に、SomeClassのfuncが呼ばれ、Falseの場合にfuncが呼ばれないことをテストしたいと思います。

Config({
    'debug': True
})

# funcが呼ばれる
TestTarget.get()
Config({
    'debug': False
})

# funcが呼ばれない
TestTarget.get()

テストを書く

# borg_test.py

import unittest
from unittest import mock
import borg


class TestTargetTest(unittest.TestCase):
    @mock.patch('borg.SomeClass')
    @mock.patch('borg.Config')
    def test_call_with_debug(self, MockConfig, MockSomeClass):
        MockConfig().debug = True

        borg.TestTarget.get()

        self.assertTrue(MockSomeClass.called)
        self.assertTrue(MockSomeClass().func.called)

    @mock.patch('borg.SomeClass')
    @mock.patch('borg.Config')
    def test_call_with_non_debug(self, MockConfig, MockSomeClass):
        MockConfig().debug = False

        borg.TestTarget.get()

        self.assertTrue(MockSomeClass.called)
        self.assertFalse(MockSomeClass().func.called)

if __name__ == '__main__':
    unittest.main()

二つのテストケースの違いは、debugがTrueであるかFalseであるかと、SomeClassのMockが呼ばれたかどうかです。しかしこの二つのテストのうち、後に実行されたものは失敗します。

% python3 borg_test.py
.F
======================================================================
FAIL: test_call_with_non_debug (__main__.TestTargetTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/usr/lib/python3.4/unittest/mock.py", line 1136, in patched
    return func(*args, **keywargs)
  File "borg_test.py", line 22, in test_call_with_non_debug
    self.assertTrue(MockSomeClass.called)
AssertionError: False is not true

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (failures=1)

なぜなら、テスト対象のクラスのBorgパターンが状態を共有しているため、

if '_instance' not in self.__dict__:

がFalseになり、SomeClassが呼び出されないためです。※テストはメソッド名をソートした順に実行されるため、メソッド名に依存します。今回は上にあるtest_call_with_debug関数が先に実行されます。

このクラスをテストするには__dict__から_instanceを取り除く必要があります。しかし、テストの中で

borg.TestTarget().__shared_state = {}

としても、__shared_stateという変数は存在せず、エラーになります。ではどうしたらいいのかと言うと、Pythonには__(_が二つ)で始まる変数は_{クラス名}{変数名}となる仕様があるため、これを利用できます。

print(dir(borg.TestTarget()))

でも確認することができます。

['_TestTarget__shared_state', '__call__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_instance', 'get']

というわけで、_TestTarget__shared_stateをtearDownで空の辞書で上書きすることで解決できました。

import unittest
from unittest import mock
import borg

class TestTargetTest(unittest.TestCase):
    def tearDown(self):
        borg.TestTarget._TestTarget__shared_state = {}

    @mock.patch('borg.SomeClass')
    @mock.patch('borg.Config')
    def test_call_with_debug(self, MockConfig, MockSomeClass):
        MockConfig().debug = True

        borg.TestTarget.get()

        self.assertTrue(MockSomeClass.called)
        self.assertTrue(MockSomeClass().func.called)

    @mock.patch('borg.SomeClass')
    @mock.patch('borg.Config')
    def test_call_with_non_debug(self, MockConfig, MockSomeClass):
        MockConfig().debug = False

        borg.TestTarget.get()

        self.assertTrue(MockSomeClass.called)
        self.assertFalse(MockSomeClass().func.called)

if __name__ == '__main__':
    unittest.main()

私はこれにBorgパターンを相手にしたときに遭遇しましたが、その他のクラス変数を初期化したい場合にも使えるテクニックのはずです。

コメント

2015 - 2017 (c) 成瀬基樹