💨
abstract class for unittest
背景・目的
- 書籍「単体テストの考え方・使い方」を読んで、より良いユニットテストのために抽象化すべきパターンを学んだ。
- 実際に応用してみたので、具体の方法や感想を共有できれば。
元々知ってた抽象化
- 同様の振る舞い(機能要件)であるが詳細実装は異なるケースに対する抽象化。
- モジュール同士の密結合を避ける(依存性逆転)用途で用いられる。
- すでに実務や個人開発で積極的に利用していた。
class Animal(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
新しく知った抽象化
- 外部プロセス呼び出し部分の抽象化。
- より良いユニットテストを設計するために用いられる(もちろんユニットテスト以外にも)。
- 利用したことがなかった。
Pythonの例。BigQueryからデータを取得するクラスを作成。その際に、外部プロセス呼び出し部分を抽象化している。
これがなぜユニットテストにおいてどんなメリットを享受できるかは後述。
class AbstractDataWarehouseClient(ABC):
@abstractmethod
def execute_sql_query(self, query: str) -> List[List[float]]:
pass
class BigQueryClient(AbstractDataWarehouseClient):
def __init__(self):
self.__base_client = bigquery.Client()
def execute_sql_query(self, query):
return self.__base_client.query_and_wait(query)
class BigQueryStore:
def __init__(self, bigquery_table_id):
self.bigquery_table_id = bigquery_table_id
self.__client = BigQueryClient()
def select_all(self):
rows = []
for row in self.__client.execute_sql_query(
f"SELECT * FROM `{self.bigquery_table_id}`"
):
rows.append(dict(row.items()))
return rows
良いユニットテストの定義
本書では以下の4つの性質を満たせば良いユニットテストであると定義している。つまり外部プロセス部分の抽象化によって以下の性質を得られる(メリットを享受できている)と良さそう。
- 退行(regression)に対する保護
- 概要:バグやエラーを検知できること。開発コードだけでなく、依存する外部ライブラリもカバーできると尚良い。
- リファクタリングへの耐性
- 概要:外部から観測可能な振る舞いを検証していること(ブラックボックステスト的なアプローチ)。内部を知りすぎていると、リファクタリングした際にテストが壊れてしまう(振る舞いは変わってないのに)。
- 迅速なフィードバック
- 概要:テストが素早く実行できて結果を得られること。E2EはFBを得るのに時間がかかるテストである。一方で、リファクタリングへの耐性は高い。
- 保守のしやすさ
- 概要:テストコードの可読性が担保されていること。外部プロセス依存が少ないこと。
実装と比較
- 上のBigQueryからデータを取得するクラスのselect_allメソッドに対するユニットテストを考える。
- まず抽象化なしの場合で、どんなテストになるか確認する。
- 次に抽象化ありの場合で、どんなテストになるか確認し評価する。
実装:抽象化なし
class BigQueryStore(AbstractDataWarehouse):
def __init__(self, bigquery_table_id):
self.bigquery_table_id = bigquery_table_id
self.__client = bigquery.Client()
def select_all(self):
rows = []
for row in self.__client.query_and_wait(
f"SELECT * FROM `{self.bigquery_table_id}`"
):
rows.append(dict(row.items()))
return rows
def test_select_all():
with patch(
"bigquery.Client.query_and_wait",
MagicMock(
return_value={...} # mocked response,
),
) as mock:
store = BigQueryStore("example_table")
res = store.select_all()
assert res == {...} # expected_result
実装:抽象化あり
class AbstractDataWarehouseClient(ABC):
@abstractmethod
def execute_sql_query(self, query: str) -> List[List[float]]:
pass
class BigQueryClient(AbstractDataWarehouseClient):
def __init__(self):
self.__base_client = bigquery.Client()
def execute_sql_query(self, query):
return self.__base_client.query_and_wait(query)
class BigQueryStore:
def __init__(self, bigquery_table_id):
self.bigquery_table_id = bigquery_table_id
self.__client = BigQueryClient()
def select_all(self):
rows = []
for row in self.__client.execute_sql_query(
f"SELECT * FROM `{self.bigquery_table_id}`"
):
rows.append(dict(row.items()))
return rows
def test_select_all():
with patch(
"BigQueryClient.execute_sql_query", # 変わっているのはここだけ
MagicMock(
return_value={...} # mocked response,
),
) as mock:
store = BigQueryStore("example_table")
res = store.select_all()
assert res == {...} # expected_result
評価
実装を比較した結果、異なる点はモックする対象であることがわかる。
抽象化していない方は具体のライブラリをモックしているのに対し、抽象化した方はインターフェースで定義されたメソッドに対してモックしている。
前者は実装詳細をモックしていると言える。実装詳細を知りすぎると、リファクタリング耐性が失われ、保守しづらくなる。例えば利用するライブラリが変更された場合は全てのテストコードのモック部分を変更しなければならなくなる(google cloudはbigquery_v2みたいなことをよくやる)。
後者も実装詳細をモックしているものの、モック対象が具体のライブラリではなく抽象インターフェースである。具体のライブラリ実装はクライアントクラス内部に隠蔽されている。
したがって、ライブラリ実装部分のリファクタリングが必要になっても変更箇所は小さく止まる。
加えて、外部プロセス依存部分が明確にインターフェースとして定義されているため、結合テスト時にどれをモックしたらいいのか(モックしなくていいのか)明確になるメリットもある。
まとめ
プロセス外依存部分に対して抽象化することで得られる恩恵は下記になる。
- プロセス外依存部分がより安定したインターフェースによって隠蔽されるため、保守・設計しやすいユニットテストが書きやすくなる。
- (上記例には含めなかったが)外部ライブラリ固有のエラーをハンドリングする部分が明確になるので、退行に対する保護も高まる。
Discussion