【Python】ダックタイピングで保守性を高める

## はじめに

Pythonをはじめとする動的型付け言語では、**ダックタイピング**という動的型付けの性質を応用させた考え方があります。
ダックタイピングを活用することで、柔軟かつ再利用性の高いコードを書くことができます。
本記事では、ダックタイピングを駆使して効果的なテストコードを作成する方法を紹介します！

## ダックタイピングとは？

ダックタイピング（Duck Typing）とは、

> もしそれがアヒルのように歩き、アヒルのように鳴くなら、それはアヒルである  

[ダック・タイピング - Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%80%E3%83%83%E3%82%AF%E3%83%BB%E3%82%BF%E3%82%A4%E3%83%94%E3%83%B3%E3%82%B0)

という考え方に基づき、オブジェクトの型ではなく、そのオブジェクトが持つメソッドや属性によって振る舞いを判断するパラダイムです。

以下はダックタイピングを理解するためのpythonでの実装サンプルです。

```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`メソッドを持っていることを前提としています。

```python
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`メソッドを実装しているかどうかを確認します。

```python
# 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)
```

### テストコードの解説

1. **データソースクラスの定義**

   - `FileDataSource` と `APIDataSource` は共に`read`メソッドを実装しており、`process_data`関数の要件を満たしています
   - `InvalidDataSource` は`read`メソッドを持たず、エラーが発生することを確認するために使用します

2. **pytestフィクスチャの使用**

   - 各データソースオブジェクトをフィクスチャとして定義し、テスト関数に注入します。
   - これは、OOPにおけるDIと同じ動きをしていることに注目してください！これこそがダックタイピングがもたらすテスト容易性になります。

3. **個別テスト関数**

   - `test_process_data_with_file_data_source` と `test_process_data_with_api_data_source` は、正しいデータソースを渡した場合の正常な動作をテストします
   - `test_process_data_with_invalid_data_source` は、無効なデータソースを渡した場合に適切なエラーが発生することを確認します

4. **パラメータ化されたテスト**

   - `test_process_data_parametrized` は、異なる有効なデータソースを一つのテスト関数でテストします
   - `test_process_data_invalid_parametrized` は、異なる無効なデータソースを一つのテスト関数でテストし、エラーが発生することを確認します

### テスト結果

```bash
============================= 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`が追加され、静的型付けの恩恵を受けながらダックタイピングを活用することが可能になりました。

https://docs.python.org/ja/3/library/typing.html#typing.Protocol

`Protocol`を使用すると、特定のメソッドや属性を持つことを型として明示的に定義でき、コードの可読性や保守性が向上します。

### `typing.Protocol`を使ってプロトコルを定義する

プロトコルは以下のように定義します。
OOPにおけるインターフェースと同様に、関数内部の具体的な処理内容は定義できません。

```python
from typing import Protocol

class DataSourceProtocol(Protocol):
    def read(self) -> str:
        """データソースからデータを読み込む"""
```

プロトコルを使って先ほどの`proccess_data`のサンプルを修正すると以下のようになります。

```diff
+ 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`メソッドに引き渡すことも可能です。

テストコードにも一部修正が必要ですね。

```diff
+ 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`を使用して柔軟かつ再利用性の高いテストコードを作成する方法を紹介しました！
何かのお役に立てれば幸いです！