はじめに
Pythonをはじめとする動的型付け言語では、ダックタイピングという動的型付けの性質を応用させた考え方があります。
ダックタイピングを活用することで、柔軟かつ再利用性の高いコードを書くことができます。
本記事では、ダックタイピングを駆使して効果的なテストコードを作成する方法を紹介します!
ダックタイピングとは?
ダックタイピング(Duck Typing)とは、
もしそれがアヒルのように歩き、アヒルのように鳴くなら、それはアヒルである
という考え方に基づき、オブジェクトの型ではなく、そのオブジェクトが持つメソッドや属性によって振る舞いを判断するパラダイムです。
以下はダックタイピングを理解するためのpythonでの実装サンプルです。
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Robot:
def speak(self):
return "Beep boop!"
# ダックタイピングに基づいて speak メソッドを呼び出す
def make_sound(animal):
# `animal`が`speak`メソッドを持っているか確認せずに呼び出す
print(animal.speak())
dog = Dog()
cat = Cat()
robot = Robot()
make_sound(dog) # Woof!
make_sound(cat) # Meow!
make_sound(robot) # Beep boop!
このようにダックタイピングでは、オブジェクトがどの型であるかではなく、オブジェクトが何ができるかに着眼した実装になります。
ダックタイピングの利点
- 柔軟性: 異なるクラスやオブジェクトが同じインターフェースを持っていれば、同じ関数やメソッドで操作可能
- 拡張性: 新しいクラスを追加する際、既存のコードを変更する必要が少ない
- テスト容易性: モックやスタブを簡単に作成でき、テストの際に柔軟にオブジェクトを差し替え可能
今回は、3つ目の利点に着目して、実際にダックタイピングを活用したテストコードの実装例を紹介します!
ダックタイピングを駆使したテストコードの実践例
process_data
というデータ処理関数を例に、ダックタイピングを活用したテストコードをpytest
で書いていきます。
process_data
関数の定義
まずSUTとして以下の関数を定義します
データソースからデータを読み込み、処理する関数process_data
この関数は、渡されたオブジェクトがread
メソッドを持っていることを前提としています。
def process_data(data_source):
"""
data_sourceからデータを読み込み、処理する関数。
data_sourceはreadメソッドを持つオブジェクトでなければならない。
"""
try:
data = data_source.read()
# データの処理例: データを大文字に変換
processed = data.upper()
return processed
except AttributeError:
raise TypeError("data_sourceはreadメソッドを持っている必要があります。")
テストコードの実装例
次に、このprocess_data
関数をテストするためのpytest
テストコードを作成します。
異なるクラスのオブジェクトを用意し、read
メソッドを実装しているかどうかを確認します。
# test_data_processor.py
import pytest
from data_processor import process_data
class FileDataSource:
"""ファイルからデータを読み込むデータソース"""
def read(self):
return "file data"
class APIDataSource:
"""APIからデータを取得するデータソース"""
def read(self):
return "api data"
class InvalidDataSource:
"""readメソッドを持たない無効なデータソース"""
def fetch(self):
return "invalid data"
@pytest.fixture
def file_data_source():
return FileDataSource()
@pytest.fixture
def api_data_source():
return APIDataSource()
@pytest.fixture
def invalid_data_source():
return InvalidDataSource()
# 正常系
@pytest.mark.parametrize("data_source,expected", [
(FileDataSource(), "FILE DATA"),
(APIDataSource(), "API DATA"),
])
def test_process_data_parametrized(data_source, expected):
result = process_data(data_source)
assert result == expected
# 異常系
@pytest.mark.parametrize("data_source", [
InvalidDataSource(),
123,
None,
])
def test_process_data_invalid_parametrized(data_source):
with pytest.raises(TypeError):
process_data(data_source)
テストコードの解説
-
データソースクラスの定義
-
FileDataSource
とAPIDataSource
は共にread
メソッドを実装しており、process_data
関数の要件を満たしています -
InvalidDataSource
はread
メソッドを持たず、エラーが発生することを確認するために使用します
-
-
pytestフィクスチャの使用
- 各データソースオブジェクトをフィクスチャとして定義し、テスト関数に注入します。
- これは、OOPにおけるDIと同じ動きをしていることに注目してください!これこそがダックタイピングがもたらすテスト容易性になります。
-
個別テスト関数
-
test_process_data_with_file_data_source
とtest_process_data_with_api_data_source
は、正しいデータソースを渡した場合の正常な動作をテストします -
test_process_data_with_invalid_data_source
は、無効なデータソースを渡した場合に適切なエラーが発生することを確認します
-
-
パラメータ化されたテスト
-
test_process_data_parametrized
は、異なる有効なデータソースを一つのテスト関数でテストします -
test_process_data_invalid_parametrized
は、異なる無効なデータソースを一つのテスト関数でテストし、エラーが発生することを確認します
-
テスト結果
============================= test session starts =============================
collected 5 items
test_data_processor.py ..... [100%]
============================== 5 passed in 0.03s ==============================
このようにダックタイピングを使うと、OOPにおけるDIをお手軽に実現できます!
SUTが外部のオブジェクトに依存している場合や、テスト容易性のためにあえて依存性を持たせる場合などに、あえて型は定義せずにダックタイピングを活用するというのも1つのプラクティスになるのではないでしょうか。
いやでも、typingしないのは流石に気持ちわるくね?
という方、分かります。そんな方のためのソリューションも用意されています。
typing.Protocol
を使ってプロトコル(擬似的なインターフェース)を実装する
Python 3.8以降では、typing
モジュールにProtocol
が追加され、静的型付けの恩恵を受けながらダックタイピングを活用することが可能になりました。
Protocol
を使用すると、特定のメソッドや属性を持つことを型として明示的に定義でき、コードの可読性や保守性が向上します。
typing.Protocol
を使ってプロトコルを定義する
プロトコルは以下のように定義します。
OOPにおけるインターフェースと同様に、関数内部の具体的な処理内容は定義できません。
from typing import Protocol
class DataSourceProtocol(Protocol):
def read(self) -> str:
"""データソースからデータを読み込む"""
プロトコルを使って先ほどのproccess_data
のサンプルを修正すると以下のようになります。
+ def process_data(data_source: DataSourceProtocol):
"""
data_sourceからデータを読み込み、処理する関数。
data_sourceはreadメソッドを持つオブジェクトでなければならない。
"""
try:
data = data_source.read()
# データの処理例: データを大文字に変換
processed = data.upper()
return processed
except AttributeError:
raise TypeError("data_sourceはreadメソッドを持っている必要があります。")
ちなみに、プロトコルを継承したクラスでプロトコルに定義されたプロパティ以外を定義することは可能です。
また、そのようなクラスから生成されたオブジェクトを上記のproccess_data
メソッドに引き渡すことも可能です。
テストコードにも一部修正が必要ですね。
+ class FileDataSource(DataSourceProtocol):
"""ファイルからデータを読み込むデータソース"""
def read(self):
return "file data"
+ class APIDataSource(DataSourceProtocol):
"""APIからデータを取得するデータソース"""
def read(self):
return "api data"
+ class InvalidDataSource(DataSourceProtocol):
"""readメソッドを持たない無効なデータソース"""
def fetch(self):
return "invalid data"
まとめ
本記事では、Pythonのダックタイピングを活用し、pytest
を使用して柔軟かつ再利用性の高いテストコードを作成する方法を紹介しました!
何かのお役に立てれば幸いです!