🧪

テストしやすくリファクタリングしたら、いろいろうれしかった

2023/07/19に公開

はじめに

開発をしていて、「あれ、テストしにくいな・・・」と思うことがあり、テストしやすくなるようにリファクタリングしてみると、いろいろメリットを感じることができたので共有します!

リファクタリング前の状態

Lambda本体

lambda_function.py
def lambda_handler(event, context):
    try:
        # eventオブジェクトからクエリパラメータを取得
        query_params = event.get("queryStringParameters")
        user_id = int(query_params.get("id")) if query_params.get("id") else None
        first_name = ・・・
        last_name = ・・・
        prefecture = ・・・
        address = ・・・
        phone_number = ・・・

        # レコード取得
        records = []
        for user in execute_sql(
            sql="実行したいSQL文",
            params={  # SQL文内のプレースホルダに渡すパラメータ
                "user_id": id,
                "first_name": first_name,
                "last_name": last_name,
                "prefecture": prefecture,
                "address": address,
                "phone_number": phone_number,
            },
        ):
            records.append(user)

        response = {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(records),
        }
        〜〜〜(省略)〜〜〜

上記は、AWS Lambdaで実装された、ユーザー検索画面等でユーザー一覧を取得するAPIの一部です。
execute_sql関数 はSQL文とパラメータを引数として受け取り、そのクエリを実行し、(何か取得するSQLであれば)結果をジェネレータで返す関数です。
execute_sql関数 に渡す 実行したいSQL文 は以下です。

Lambdaで実行したいSQL文

get_user.sql
SELECT
    id,
    first_name,
    last_name,
    prefecture,
    address,
    phone_number
FROM
    users
WHERE
    ((%(id)s IS NULL) OR (id = %(id)s))
    AND
    ((%(first_name)s IS NULL) OR (first_name LIKE CONCAT('%%', %(first_name)s, '%%')))
    AND
    ((%(last_name)s IS NULL) OR (last_name LIKE CONCAT('%%', %(last_name)s, '%%')))
    AND
    ((%(prefecture)s IS NULL) OR (prefecture = %(prefecture)s))
    AND
    ((%(address)s IS NULL) OR (address LIKE CONCAT('%%', %(address)s, '%%')))
    AND
    ((%(phone_number)s IS NULL) OR (phone_number = %(phone_number)s))
ORDER BY
    id DESC

SQLクエリ内の %(id)s のような表記は、Pythonのプレースホルダと呼ばれるもので、SQLクエリの一部を後から動的に置換するために使います。

テストコード

test_get_users.py
def test_get_users():
    from lambda_function import lambda_handler

    # 200
    event = {
        "queryStringParameters": {
            "id": "1",
            "firstName": "太郎",
            "lastName": "山田",
            "prefecture": "東京都",
            "address": "渋谷区1-1-1",
            "phone_number": "09012345678",
        }
    }

    expected = 200
    result = lambda_handler(event, None)
    assert expected == result["statusCode"]  # assertステートメントで結果を検証

このテストではLambdaが返すステータスコードを検証していますが、SQL内部の条件分岐に対するテストケースが網羅されていません。
実際は、eventオブジェクトの中身のテストケースを増やせば網羅できるのですが、「ちょっとテストしにくいな・・・」と感じました。

この時に感じた「テストの困難さ」は「LambdaがSQL実行部分を含んでいて、責務が分けられていない」つまり、単一責任の原則に反しているからだと後でわかりました。

単一責任の原則とは

モジュール、クラスまたは関数は、単一の機能について責任を持ち、 その機能をカプセル化するべきである。

Lambda全体のテストに加えて、SQLを実行している部分もテストしたいと考え、リファクタリングを行いました。

リファクタリングの適用

Lambda本体

lambda_function.py
# 新たに作成
def get_users(
        self,
        *,
        user_id: int = None,
        first_name: str = None,
        last_name: str = None,
        prefecture: str = None,
        address: str = None,
        phone_number: str = None,
    ) -> Generator[mysql.connector.cursor.RowType, None, None]:
        for user in execute_sql(
            sql="実行したいSQL文",
            params={
                "id": user_id,
                "first_name": first_name,
                "last_name": last_name,
                "prefecture": prefecture,
                "address": address,
                "phone_number": phone_number,
            },
        ):
            yield user

def lambda_handler(event, context):
    try:
        # eventオブジェクトからクエリパラメータを取得
        〜〜〜(省略)〜〜〜

        # get_usersでレコード取得
        records = []
        for user in get_users(
            user_id=user_id,
            first_name=first_name,
            last_name=last_name,
            prefecture=prefecture,
            address=address,
            phone_number=phone_number,
        ):
            records.append(user)

        response = {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps(records),
        }
        〜〜〜(省略)〜〜〜

SQL実行部分を get_users関数 に独立させることができました。

追加テストコード

get_users関数 に対して以下のテストが追加でき、SQLの内部の条件分岐を網羅することができました。

test_get_users.py
def test_get_users():
    from lambda_function import get_users

    # スタッフID
    assert 1 == len(list(get_users(user_id=1)))

    # 名完全一致
    assert 1 == len(list(get_users(first_name="花子")))

    # 名部分一致
    assert 3 == len(list(get_users(first_name="子")))

    # 姓完全一致
    assert 2 == len(list(get_users(last_name="山田")))

    # 姓部分一致
    assert 4 == len(list(get_users(last_name="山")))

    # 姓名完全一致
    assert 1 == len(list(get_users(first_name="太郎", last_name="山田")))

    # 都道府県
    assert 3 == len(list(get_users(prefecture="北海道")))

    # 住所完全一致
    assert 1 == len(list(get_users(address="札幌市16-16-16")))

    # 住所部分一致
    assert 3 == len(list(get_users(address="札幌市")))

    # 電話番号
    assert 1 == len(list(get_users(phone_number="09012345678")))

まとめ

今回のリファクタリングによりうれしかったこと

  1. テスト網羅性の向上: get_users関数 はSQLの実行とその結果を返す役割を持ちます。これにより、Lambda全体のテストだけでなく、SQLの実行部分について個別にテストを行うことが容易になりました。テストの網羅性が向上し、各パラメータが正しくSQLに適用されるか、期待通りの結果を返すかを確認できるようになりました。

  2. 可読性の向上: get_users関数 を見るだけで、何をするための関数であるかが理解しやすくなりました。SQLの実行に必要なパラメータが明示的に関数の引数として示されており、それらがどのように使用されるかが一目瞭然です。(単一責任の原則の適用)

  3. 再利用性の向上: get_users関数 を切り出すことで、同じユーザ取得ロジックを他の箇所でも再利用することが可能になりました。同じコードを複数箇所に書く必要がなくなり、保守性が向上しました。

よい経験ができました🙌🏻

GitHubで編集を提案
エックスポイントワン技術ブログ

Discussion