subTestを使って複数のパターンをまとめてテストしたい

この記事は さくらインターネット [Advent Calendar 2024](https://qiita.com/advent-calendar/2024/sakura) 25日目の記事です。

## 記事を書いたきっかけ

業務でDjangoを使ったアプリケーション開発に関わっています。その中でパターンが大量にあるテストをすることがあったので、備忘録的な意味も込めて軽く残したいと思います。

## subTestとは?

unittestモジュールに含まれている機能。
複数のテストケースを一つのテストメソッド内でまとめて実行できる。
そのため、類似したテストをまとめて行いたい場合などで利用できます。

## なぜsubTestを使ったのか

複雑な条件によってレスポンスの内容が変化するようなエンドポイントをテストする必要がありました。

全ての条件を網羅的にテストする必要がありましたが、テストメソッドをそれぞれ別に分けた場合、
- テストメソッドが増えすぎて複雑になってしまい、可読性が著しく落ちる
- テストに使用するデータ自体も複雑になってしまう

という問題が起きるため、unittestの機能で実現できる一番シンプルなやり方としてsubTestを使用しました。

## 使い方

任意のキーワード引数を渡すことで各subTestの識別に使うことができます。

```py
with self.subTest([name1=value1, name2=value2...]):
    # テストを書く
```

## 例

ドキュメントなど何かしらのリソースを管理するアプリケーションを作る場合、適切な権限を持っている場合は成功のレスポンスを返して、権限がない場合は弾かれることをテストで確認する必要があります。

例えば、以下の表のようにテスト用のユーザがあってそれぞれに権限が振られていたとします。
ユーザ1は`ドキュメントID: aaaa`を閲覧のみ可能。
ユーザ2は`ドキュメントID: aaaa`を編集可能 etc...

| ユーザーID | ドキュメントID | 権限   | 閲覧可能 | 編集可能 |
|------------|----------------|--------|----------|----------|
| 1          | aaaa           | viewer | ○        | ×        |
| 2          | aaaa           | editor | ○        | ○        |
| 1          | bbbb           | editor | ○        | ○        |
| 2          | bbbb           |  None  | ×        | ×        |

例えば、以下のようなテストでは
- GET /documents時に権限通りの適切なレスポンスが返ってくる
- PUT /documents時に権限通りの適切なレスポンスが返ってくる
ことを確認しています。

このとき、fixture等で各権限が割り振られたユーザのテストデータを全て用意しようとすると、テストする権限が増えるほどテストデータも増えて管理が複雑になっていきます。

そこで、subTestを使って各テストケースごとに都度データを用意する(今回は権限を割り当てる処理を組み込む)ことで、不要なテストデータの増加を防げます。これにより、テストメソッドを1つにまとめられ、どの条件がテストされていないかも把握しやすくなるのではと考えました。

```python
import unittest
import requests

class DocumentAPITestCase(unittest.TestCase):
    BASE_URL = 'https://example.com/api/documents'

    def assign_role(self, user_id, document_id, role):
        """
        DBを更新する等、ユーザーに権限(role)を割り当てる処理
        """
        # 実際の処理は今回の本題ではないため省略

    def test_get_documents(self):
        """
        GET /documents時に適切なレスポンスが返ってくることを確認
        """
        test_cases = [
            {'user_id': 1, 'document_id': 'aaaa', 'role': 'viewer', 'status_code': 200},
            {'user_id': 2, 'document_id': 'aaaa', 'role': 'editor', 'status_code': 200},
            {'user_id': 1, 'document_id': 'bbbb', 'role': 'editor', 'status_code': 200},
            {'user_id': 2, 'document_id': 'bbbb', 'role': None, 'status_code': 403},
        ]

        for case in test_cases:
            with self.subTest(user_id=case['user_id'], document_id=case['document_id']):
                # ドキュメントに対してユーザーに権限(role)を割り当てる処理を呼び出す
                self.assign_role(user_id, document_id, role)
                token = "xxxxxxxxxxxx" # ユーザのトークンを取ってくる処理が必要だが、今回は重要ではないため割愛
                headers = {'Authorization': f'Bearer {token}'}

                response = requests.get(f'{self.BASE_URL}/{case["document_id"]}', headers=headers)
                self.assertEqual(response.status_code, case['status_code'])

    def test_update_documents(self):
        """
        PUT /documents時に適切なレスポンスが返ってくることを確認
        """
        test_cases = [
            {'user_id': 1, 'document_id': 'aaaa', 'role': 'viewer', 'status_code': 403},
            {'user_id': 2, 'document_id': 'aaaa', 'role': 'editor', 'tatus': 200},
            {'user_id': 1, 'document_id': 'bbbb', 'role': 'editor', 'status_code': 200},
            {'user_id': 2, 'document_id': 'bbbb', 'role': None, 'status_code': 403},
        ]

        for case in test_cases:
            with self.subTest(user_id=case['user_id'], document_id=case['document_id']):
                # ドキュメントに対してユーザーに権限(role)を割り当てる処理を呼び出す
                self.assign_role(user_id, document_id, role)
                token = "xxxxxxxxxxxx" # ユーザのトークンを取ってくる処理が必要だが、今回は重要ではないため割愛
                headers = {'Authorization': f'Bearer {token}'}
                data = {
                    "title": "タイトル",
                    "contents": "本文"
                }

                response = requests.put(f'{self.BASE_URL}/{case["document_id"]}', headers=headers, data=data)
                self.assertEqual(response.status_code, case['status_code'])

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

## ハマったポイント

subTestで複数のテストケースを一つのテストメソッド内でまとめて実行する場合、[setUp()](https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp)などで行った初期化処理が各テストケースごとに行われません。

そのため、Djangoのfixtureなどでテストデータを用意していて更新や削除等のテストを行った場合、各ケースごとにテストデータが初期化されないため、subTest内で明示的に初期化処理を行う必要があります。

https://docs.python.org/3/library/unittest.html#unittest.TestCase.setUp:~:text=Method%20called%20to%20prepare%20the%20test%20fixture.%20This%20is%20called%20immediately%20before%20calling%20the%20test%20method%3B

## まとめ

PythonのsubTestを使ったテストについて書きました。今回は例として比較的シンプルなものを選びましたが、条件が複雑になってくると1つのテストメソッドで大量のパターンを試すケースは多いのかなと個人的に思います。
最後に、普段からPythonを使うことが多いので、備忘録的な内容は今後も残していけたらいいなと思っています。

## 参考

https://docs.python.org/ja/3/library/unittest.html#distinguishing-test-iterations-using-subtests

https://qiita.com/Asayu123/items/61ef72bb829dd8baba9f