Clovaスキル開発での単体テストのすすめ

この投稿はLINEBot&Clova Advent Calendar 2018の5日目の投稿です。前日の投稿は @akira108 さんの「友だち数50万人を誇るbotリマインくん詳解」でした。

多くの人に使われる大人気Bot の裏側を見ることができます!

本投稿ではClova スキル開発時の単体テストについて書いていきます。

言語はPython を使っていますが、考え方は他の言語でも同じだと思っていますので参考にしていただければ幸いです。


はじめに

皆さんはClova スキル開発時に単体テストをしてますか?

ハッカソンなど時間がない中で開発することも多いですし、Clova Platform からのWebhook で実行されることから単体テストし辛い面もあるので、単体テストをすっ飛ばしてシュミレーターや実機でのテストに頼ってしまうこともあるのではないでしょうか。

そうなれば再テストをしようとすると時間もかかりますし、仕様変更や機能追加の時に大変になります。ぜひ単体テストを充実させて面倒なテストは機械に任せ、企画やシナリオに時間を時間をかけるようにしていきましょう。

この一連の投稿ではClova スキルで単体テストを充実させていくための例を上げていきます。


目次


  • 外部依存するロジックのテストは接合部を増やしてテストする(本投稿)

  • ビジネスロジックを分離する

  • プラットフォーム依存部分を分離する


題材

この一連の投稿では、以前投稿した「30分くらいでClova Extension Kit SDK for Python を使ったClova スキルを作る!(後編:実装編)」のソースコードを題材にします。

先の投稿ではテストを一切書いていない、いわゆる「レガシーコード」でした。

また、この一連の投稿では、読者の皆さんは、この「レガシーコード」を引き継ぎサービスとして機能追加や仕様変更を行うことになりました。うまくテストを加えながら対応して拡張しやすいコードにしていきましょう。


開発環境

この一連の投稿で利用する開発環境は以下のとおりです。


  • Python 3.6.5

  • AWS Lambda

  • Dynamo DB

  • Flask

  • zappa (サーバーレスフレームワーク)


外部依存するロジックのテストは接合部を増やしてテストする


概要

Clova スキル開発では、Clova platform に依存することから、Request や、Session Attributes など外部依存が多くなります。

品質の高いサービス提供や、Bot とのロジック共有、Amazon Alexa など他社スキルにも同じサービスを提供にするにはリファクタリングに備えて単体テストを充実させる必要があります。

また、共有を進めていくと更に外部依存が増えるので早いうちからテストできる体勢にしておきたいですね。


まずは機能追加から

スキル開発を引き継いで間もなく、機能追加することになりました。

追加するのはサービス開始には欠かせない「ガイドインテント」と、ランダムで都道府県にあるご当地グルメを教えるインテントです。


追加機能1:ガイドインテント

スキルの機能説明を発話するインテントです。

ただし、スキル利用中に何度も同じことを発話しないよう、最初に説明するときは詳しい情報を発話しますが、2回目以降は簡易な説明をするようにします。

なお、機能説明したかどうかは永続化せず、スキルを一度終了して再度ガイドインテントを実行した際には、最初は詳しい情報を発話し、2回目以降は簡易な説明とします。


実装方法を考える

それでは実装方法を検討しましょう。

機能説明したかどうかは永続化しないのでデータベースに記録はせず、Clova リクエストおよびレスポンス・メッセージにあるsession.sessionAttributes を利用します。

session.sessionAttributes はdictionary でマルチターン会話に必要な中間情報を格納できます。

今回は'HasExplainedService'という要素の有無で機能説明済みかどうかを判断します。

また、どのようにテストしていくかも考えてみましょう。

session.sessionAttributes は「cek.core.Request 」クラスの属性ですので、CEK(Clova Extension Kit)に依存します。

テストする方法としてはいくつか考えられますが、今回は「接合部」を作ってテストしやすくしてみます。


接合部とは

接合部とは、『レガシーコード改善ガイド』で解説されている「もとのコードを編集しなくてもコードの振る舞いを変えることができる場所」のことです。

つまり、テストの際に本番コードを書き換えなくても、テストコードを実行するように切り替えることが可能となります。

今回のように外部依存となる箇所や、ランダム性のある箇所のテストなどに用いられます。


実装

実装していきます。

まずは、CEK 依存となるSession Attributes を基に機能説明済みかどうかを判断する箇所と、Clova に返すResponse を生成する箇所をテストしやすいように関数を作ります。


main.py(抜粋)

def _has_explained_service(clova_request):

'''
機能説明済みかどうかを判別する
'''

result = False
if 'HasExplainedService' in clova_request.session_attributes:
result = True
return result

def _build_clova_response(messages, session_attributes=None, reprompt=None, end_session=False):
'''
発話用のレスポンスオブジェクトを生成する
'''

response = clova.response(
messages,
reprompt=reprompt,
end_session=end_session
)
response.session_attributes = session_attributes
return response


その上で、インテント処理部分を実装していきます。


main.py(抜粋)

@clova.handle.intent("Clova.GuideIntent")

def guide_intent_handler(clova_request):
'''
適切なインテントが判別できなかった場合にたどり着くインテント
初回は詳細な機能説明をするが、2回目以降は簡易な説明とする。
ただし機能説明済みかどうかは永続化しない。
'''

# 詳細な機能説明文
full_text = '''このスキルでは各都道府県にあるご当地グルメと、その詳細を調べることが出来ます。
都道府県にあるご当地グルメを知りたいときは、都道府県名を教えてください。
ご当地グルメの詳細を知りたいときは、そのご当地グルメの名前を教えてください。'''

# 簡易な機能説明文
simple_text = '都道府県名か、詳しく知りたいご当地グルメの名前を教えてください。'
message_text = full_text
# GuideIntent での機能説明が初回かどうか
if _has_explained_service(clova_request) is True:
message_text = simple_text
# build response
response = _build_clova_response(
[message_text],
session_attributes={'HasExplainedService': True}
)
return response

テストコードも実装します。

接合部の関数(_has_explained_service、_build_clova_response)は、モックオブジェクトのMagickMock を使って振る舞い(機能説明済みかどうか)を変えていきます。

MagickMock はPython 標準のテストライブラリに含まれるモックオブジェクトで、クラスや関数、メソッドなどを置き換えてシュミレートしたり、実行後に呼び出され方を検証することができます。

JavaScript であればJEST のMock などが相当すると思います。


test_main.py(抜粋)

from unittest import TestCase

from unittest.mock import MagicMock

import main

class TestMain(TestCase):

def test_guide_intent_handler_with_fulltext(self):
'''
機能説明が初回の場合
'''

# setup
# 機能説明はしていない [1]
main._has_explained_service = MagicMock(return_value=False)
response = {}
main._build_clova_response = MagicMock(return_value=response)
request = MagicMock()

# execute
result = main.guide_intent_handler(request)

# assert
# 機能説明済みかの判別関数への呼び出し検証 [2]
main._has_explained_service.assert_called_once()
main._has_explained_service.assert_called_with(request)
# Response 生成関数への呼び出し検証 [3]
full_text = '''このスキルでは各都道府県にあるご当地グルメと、その詳細を調べることが出来ます。
都道府県にあるご当地グルメを知りたいときは、都道府県名を教えてください。
ご当地グルメの詳細を知りたいときは、そのご当地グルメの名前を教えてください。'''

main._build_clova_response.assert_called_once()
main._build_clova_response.assert_called_with(
[full_text],
session_attributes={'HasExplainedService': True}
)
self.assertEqual(result, response)

def test_guide_intent_handler_with_simpletext(self):
'''
機能説明済みの場合
'''

# setup
# 機能説明済み [1]
main._has_explained_service = MagicMock(return_value=True)
response = {}
main._build_clova_response = MagicMock(return_value=response)
request = MagicMock()

# execute
result = main.guide_intent_handler(request)

# assert
# 機能説明済みかの判別関数への呼び出し検証 [2]
main._has_explained_service.assert_called_once()
main._has_explained_service.assert_called_with(request)
# Response 生成関数への呼び出し検証 [3]
simple_text = '都道府県名か、詳しく知りたいご当地グルメの名前を教えてください。'
main._build_clova_response.assert_called_once()
main._build_clova_response.assert_called_with(
[simple_text],
session_attributes={'HasExplainedService': True}
)
self.assertEqual(result, response)



  • [1]: MagicMock を使って機能説明済みかどうかを判別する関数(_has_explained_service)の戻り値を置き換えます

  • [2]: _has_explained_service 関数の実行回数や、実行時の引数を検証します

  • [3]: Response 生成関数(_build_clova_response)の実行回数や、実行時の引数を検証します

これでロジック部分の実装が抽出され、実装やテストがシンプルになりました。


追加機能2:ランダムで都道府県にあるご当地グルメを教えるインテント

ランダムに都道府県を選び、その都道府県にあるご当地グルメ情報を答えます。


実装方法を考える

47都道府県からランダムに一つ選ぶため、先ほどと同じように「接合部」を作りながら、ロジックとテストを実装していきます。


実装

ランダムに都道府県(正確には都道府県リストのインデックス値)を選ぶ関数を作ってそこを接合部とします。


main.py(抜粋)

def _get_random_prefecture_index():

'''
都道府県リストのインデックス値をランダムに返す
'''

index = random.randint(0, len(PREFECTURE_LIST) -1)
return index

インテント処理部分を実装していきます。


main.py(抜粋)

@clova.handle.intent('RandomPrefectureGourmetIntent')

def random_prefecture_gourmet_intent_handler(clova_request):
'''
ランダムで都道府県にあるご当地グルメを教えるインテント
'''

try:
pref_index = _get_random_prefecture_index()
prefecture = PREFECTURE_LIST[pref_index]
response = _make_gourmet_info_message_by_prefecture(prefecture)
except Exception as e:
# 処理中に例外が発生した場合は、最初からやり直してもらう
logger.error('Exception at _make_gourmet_info_message_for: %s', e)
message_text = '処理中にエラーが発生しました。もう一度はじめからお願いします。'
response = _build_clova_response(
[message_text],
end_session=True
)
return response

テストコードも実装します。

前の機能と同様に、接合部の関数(_get_random_prefecture_index、_build_clova_response)は、MagickMock を使って振る舞いを指定していきます。


test_main.py(抜粋)

    def test__get_random_prefecture_index(self):

'''
ランダム値がリストの範囲内であること
'''

# execute
result = main._get_random_prefecture_index()

# assert
self.assertTrue(result >=0 and result < 47)

def test_random_prefecture_gourmet_intent_handler(self):
'''
指定の都道府県が取得できていること
'''

# setup
# 静岡県のindexが取得できる想定
main._get_random_prefecture_index = MagicMock(return_value=21)
response = {}
main._make_gourmet_info_message_by_prefecture = MagicMock(return_value=response)
request = MagicMock()

# execute
result = main.random_prefecture_gourmet_intent_handler(request)

# assert
# 機能説明済みかの判別メソッドへの呼び出し検証
main._get_random_prefecture_index.assert_called_once()
# Response 生成メソッドへの呼び出し検証
prefecture = '静岡県'
main._make_gourmet_info_message_by_prefecture.assert_called_once()
main._make_gourmet_info_message_by_prefecture.assert_called_with(prefecture)
self.assertEqual(result, response)


また、実行中に例外を発生させ、エラーメッセージが発話されるかも検証しましょう。

例外を発生させるにはMagickMock のside_effect に例外オブジェクトを渡してやると、実行時に指定の例外が発生します。


test_main.py(抜粋)

    def test_random_prefecture_gourmet_intent_handler_with_exception(self):

'''
Response生成時に例外が発生すること
'''

# setup
# 高知県のindexが取得できる想定
main._get_random_prefecture_index = MagicMock(return_value=38)
response = {}
ex = ValueError('foo')
main._make_gourmet_info_message_by_prefecture = MagicMock(side_effect=ex)
response = {}
main._build_clova_response = MagicMock(return_value=response)
request = MagicMock()

# execute
result = main.random_prefecture_gourmet_intent_handler(request)

# assert
# 機能説明済みかの判別メソッドへの呼び出し検証
main._get_random_prefecture_index.assert_called_once()
# Response 生成メソッドへの呼び出し検証(例外発生)
prefecture = '高知県'
main._make_gourmet_info_message_by_prefecture.assert_called_once()
main._make_gourmet_info_message_by_prefecture.assert_called_with(prefecture)
# Response 生成メソッドへの呼び出し検証
main._build_clova_response.assert_called_once()
main._build_clova_response.assert_called_with(
['処理中にエラーが発生しました。もう一度はじめからお願いします。'],
end_session=True
)
self.assertEqual(result, response)


テストを実行してみると、ちゃんと例外が発生していることがログから分かりますね。


テスト実行結果

....ERROR:main:Exception at _make_gourmet_info_message_for: foo

.
----------------------------------------------------------------------
Ran 5 tests in 0.003s


まとめ

こうやってテスト方法を考えながら実装していくと、一つの関数やクラス、メソッドにロジックが集約されずにロジック部分がシンプルになって、後で仕様変更となった場合にも対応しやすくなります。

今回の実装レベルでは関数の分割が大げさな感がありますが、API やDB 処理などへの依存が多いコードや、Clova 以外のプラットフォームに対応する場合などに備え、ロジックをシンプルに保つ実装の足がかりにもなります。

なお、今回は既存部分には手を入れずに新しく作る部分のみテストしながら実装をしてきました。今後の投稿では既存部分もテストを盛り込んでリファクタリングしていきたいと思います。