pytest で LINE Bot のテストコードを書いてみる

記事タイトルとURLをコピーする

はじめに

こんにちは。アプリケーションサービス部 河野です。

line-bot-sdk-python のおかげで簡単に LINE Bot を実装できますが、 テストコードもちゃんと書いておこうということで、pytest で試してみました。

※ 本記事で紹介する書き方については、あくまで一例としてご参考いただければ幸いです。

想定読者

  • Python の基本的な書き方を理解している
  • line-bot-sdk-python を使用して LINE Bot を実装したことがある

サンプル Bot

今回は、文字列を数える Bot を題材に、テストコードを書いてみます。

f:id:swx-go-kawano:20220102152827p:plain

テスト観点

今回は、「適切なタイミング」で「正しいメッセージ」が送信されることを単体テストで確認するとします。

「適切なタイミング」のタイミングとは、messaging-api の Webhook イベントオブジェクトを指します。 Webhookハンドラー の Webhook のイベントに対して各処理が適切なタイミングで呼び出されているかを確認します。 詳細については後述しますが、イベント毎に処理をラップしたメソッドを定義し、適切なイベントで想定したメソッドが呼び出されていることを確認します。

「正しいメッセージ」とは、TextSendMessage,や、FlexSendMessage を使用してメッセージを送信しますが、それらの引数が正しいかどうか確認します。(TextSendMessage は text=, FLexSendMessage は contents= 等です)

なお、上述のサンプル Bot は異常系の実装を簡略化しているため、異常系に関するテストは対象外とします。

テスト対象コード

フォルダ構成及び、主要となる部分の実装についてピックアップして説明します。

フォルダ構成

app
├── __init__.py
├── libs
│   ├── __init__.py
│   ├── default_event.py
│   └── message_event.py
└── src
    ├── __init__.py
    └── message_handler.py

メッセージハンドラー

src/message_handler.py に実装しています。
LINE の Webhook イベント のタイプに応じて、各処理を呼び出します。

# メッセージイベント
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    logger.info('handler: message')
 
    # 文字列をカウントして返信する処理を呼び出す
    message_event.reply_message_count(event.message.text, event.reply_token) 
 
# デフォルトイベント
@handler.default()
def default(event):
    logger.info('handler: default')
 
    # デフォルトメッセージを返信する処理を呼び出す
    default_event.reply_default_message(event.reply_token) 
 
def lambda_handler(event, context):
    logger.info('event: %s', event)
    try:
        signature = event["headers"]["x-line-signature"]
        event_body = event['body']
        handler.handle(event_body, signature)
    except InvalidSignatureError as e:
        logger.error(e)
        return error_response
    except Exception as e:
        logger.error(e)
        return error_response
    return ok_response

文字数カウントメッセージ送信

libs/message_event.py に実装しています。
トークに送信されたメッセージの文字数をカウントし、LINEに返信します。
当該処理は、文字列をカウントするため、メッセージイベントから呼び出されます。(上述のメッセージハンドラーの def handle_message(event)

def reply_message_count(receive_message: str, reply_token: str):
    send_message = f"{receive_message}の文字数は「{len(receive_message)}」です"
 
    line_bot_api.reply_message(
        reply_token,
        TextSendMessage(text=send_message))

デフォルトメッセージ送信

libs/default_event.py に実装しています。
固定文言を LINE に返信します。
当該処理は、default イベント(ハンドラー内でどのイベントにも該当しなかったイベント)から呼び出されます。(上述のメッセージハンドラーの def default(event)

def reply_default_message(reply_token: str):
    send_message = "テキストメッセージではないため、計算できません。"
 
    line_bot_api.reply_message(
        reply_token,
        TextSendMessage(text=send_message))

テストコード

それでは、以下で実際にテストコードを書いてみます。

フォルダ構成

tests
├── __init__.py
└── app
    ├── __init__.py
    ├── libs
    │   ├── __init__.py
    │   ├── test_default_event.py
    │   └── test_message_event.py
    └── src
        └── test_message_handler.py

メッセージハンドラーのテスト

「適切なタイミング」で、各処理が呼び出されているかを確認します。
もう少し具体的に考えるために、テストケースを以下表で示します。

テストケース 期待結果
正常系:メッセージイベント(トークにテキストを送信した場合) 文字数カウントメッセージ送信(reply_message_count()) が一回呼び出されていること
正常系:デフォルトイベント(トークにスタンプを送信した場合) デフォルトメッセージ送信(reply_default_message()) が一回呼び出されていること
正常系:デフォルトイベント(トークに画像を送信した場合) デフォルトメッセージ送信(reply_default_message())が一回呼び出されていること
正常系:デフォルトイベント(トークに位置情報を送信した場合) デフォルトメッセージ送信(reply_default_message())が一回呼び出されていること

上記テストケースを test_message_handler.pyで実装したテストコードが以下になります。

import pytest
from pytest_mock import MockerFixture
 
from app.src import message_handler
 
 
def test_正常系_メッセージイベント(mocker: MockerFixture):
    """正常系:メッセージイベント"""
    event = {
                "headers": {
                    "x-line-signature": "dummy"
                },
                "body": '{"events":[{"type":"message","message":{"type":"text","text":"こんにちは"},"replyToken":"dummy"}]}'
            }
    context = None
 
    event_message_text = "こんにちは"
    reply_token = "dummy"
 
    # 署名検証を無効にする
    _ = mocker.patch("linebot.SignatureValidator.validate", return_value=True)
 
    mock_reply_message_count = mocker.patch("app.src.message_handler.message_event.reply_message_count", return_value=None)
 
    message_handler.lambda_handler(event, context)
 
    mock_reply_message_count.assert_called_once_with(event_message_text, reply_token)
 
 
@pytest.mark.parametrize(
    "event",
    [
        (
            {
                "headers": {
                    "x-line-signature": "dummy"
                },
                "body": '{"events":[{"type":"message","message":{"type":"sticker"},"replyToken":"dummy"}]}'
            }
        ),
        (
            {
                "headers": {
                    "x-line-signature": "dummy"
                },
                "body": '{"events":[{"type":"message","message":{"type":"image"},"replyToken":"dummy"}]}'
            }
        ),
        (
            {
                "headers": {
                    "x-line-signature": "dummy"
                },
                "body": '{"events":[{"type":"message","message":{"type":"location"},"replyToken":"dummy"}]}'
            }
        ),
    ],
    ids=["スタンプ送信", "画像送信", "位置情報"]
)
def test_正常系_デフォルトイベント(mocker: MockerFixture, event):
    """正常系:デフォルトイベント"""
    context = None
 
    reply_token = "dummy"
 
    # 署名検証を無効にする
    _ = mocker.patch("linebot.SignatureValidator.validate", return_value=True)
 
    mock_reply_default_message = mocker.patch("app.src.message_handler.default_event.reply_default_message", return_value=None)
 
    message_handler.lambda_handler(event, context)
 
    mock_reply_default_message.assert_called_once_with(reply_token)

テストコードの流れは以下の通りです。

  1. テストデータを定義する(サンプル Bot が API Gateway × Lambda 構成のため、event と context 定義)
    1. メッセージイベントは、{"type":"text"}
    2. デフォルトイベントは、{"type":"sticker"}, {"type":"image"}, {"type":"location"}
  2. Webhook ハンドラー初期化時の、署名検証処理をモック(mocker.patch())し、無効にする(返り値を True 固定値にする)
  3. 確認したい処理をモックする(mocker.patch()
    1. メッセージイベントは、reply_message_count をモックする
    2. デフォルトイベントは、reply_default_message をモックする
  4. モックした処理が各イベント一回呼び出されていることを確認する(assert_called_once_with()

文字数カウント送信のテスト

先ほどと同様に以下にテストケースを表に示します。

テストケース 期待結果
正常系:「こんにちは」の場合 こんにちはの文字数は「5」です というメッセージが送信される(※)
正常系:hello の場合 helloの文字数は「5」です というメッセージが送信される(※)

※ LINE メッセージを送信する処理(reply_message)をモックし、assert_called_once_with() で引数(送信内容)を確認します。

上記テストケースを test_message_event.pyで実装したテストコードが以下になります。

import pytest
from app.libs.message_event import reply_message_count
from pytest_mock import MockerFixture
from linebot.models import TextSendMessage
 
 
@pytest.mark.parametrize(
    "receive_message, message_count",
    [
        ("こんにちは", "5"),
        ("hello", "5")
    ]
)
def test_正常系_文字数送信(mocker: MockerFixture, receive_message: str, message_count: str):
    """正常系:文字数送信"""
    reply_token = "dummy"
    send_message = f"{receive_message}の文字数は「{message_count}」です"
    mock_reply_message = mocker.patch("app.libs.message_event.line_bot_api.reply_message", return_value=None)
 
    reply_message_count(receive_message, reply_token)
 
    mock_reply_message.assert_called_once_with(reply_token, TextSendMessage(text=send_message))

テストコードの流れは以下の通りです。

  1. テストデータを定義する(pytest.mark.parametrize
  2. テストデータから、期待結果の送信文字列を定義する
  3. reply_messageをモックする
  4. assert_called_once_with()で、reply_message の引数を検証する

デフォルトメッセージ送信

先ほどと同様に以下にテストケースを表に示します。

テストケース 期待結果
正常系:デフォルトメッセージ送信 テキストメッセージではないため、計算できません。というメッセージが送信される(※)

※ LINE メッセージを送信する処理(reply_message)をモックし、assert_called_once_with() で引数(送信内容)を確認することで、送信内容を確認します。

from app.libs.default_event import reply_default_message
from pytest_mock import MockerFixture
from linebot.models import TextSendMessage
 
 
def test_正常系_デフォルトメッセージ送信(mocker: MockerFixture):
    """正常系:デフォルトメッセージ送信"""
    reply_token = "dummy"
    send_message = "テキストメッセージではないため、計算できません。"
    mock_reply_message = mocker.patch("app.libs.default_event.line_bot_api.reply_message", return_value=None)
 
    reply_default_message(reply_token)
 
    mock_reply_message.assert_called_once_with(reply_token, TextSendMessage(text=send_message))
 

文字数カウント送信のテストと同様のため、説明は割愛します。

テストコード実行

以下コマンドを実行し、実際にテストコードを動かします。

f:id:swx-go-kawano:20220102154945p:plain

間違ったテストデータに変更し、テストが失敗することも確認します。 test_message_event.pyの テストデータ("hello", "5") から ("hello", "3") に変更し、実行します。

f:id:swx-go-kawano:20220102154958p:plain

失敗してますね。良さそうです。

さいごに

いかがでしたでしょうか?
今回伝えたかったポイントは、メッセージハンドラーと返信処理を分けることによってテストコードが書きやすくなるという点です。

文字列をカウントする程度の LINE Bot であれば、ハンドラー内で返信処理を実装した場合でも、「テストコードの書きやすさ」という観点では、さほど変わりません。しかし、送信するメッセージを作成するにあたって、バックエンドと通信したり、外部APIを呼び出す処理が発生するケースが大半です。 その場合、返信処理が複雑になればなるほど、ハンドラー内で実装してしまうとテストコードが書きづらくなると思います。(実際に私がそうでした)

そのため、上述した「適切なタイミング」と「正しいメッセージ」というテスト観点を意識すると、多少はテストコードが書きやすくなったよという共有でした。
どなたかの参考になれば幸いです。

今後もいろいろ試していきたいと思います。

swx-go-kawano (執筆記事の一覧)