Clovaスキル開発での単体テストのすすめ（第2回）

この投稿は[スマートスピーカー Advent Calendar 2018](https://qiita.com/advent-calendar/2018/smart-speaker)の12日目の投稿です。前日の投稿は @youtoy さんでした。

## はじめに

本投稿は「Clova スキル開発での単体テストのすすめ」の第2回です。
この一連の投稿では、以前投稿した「[30分くらいでClova Extension Kit SDK for Python を使ったClova スキルを作る！（後編：実装編）](https://qiita.com/sumihiro3/items/9e24170cad4ad384f453)」のソースコードを題材にします。

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

[第1回](https://qiita.com/sumihiro3/items/da490aab10499b3c169f)の投稿では、仕様追加された機能について、Session Attributes など外部依存する部分や、ランダム性のあるロジックに「接合部」を増やしてテストしやすくする例を書きました。
今回は既存ロジックについても「接合部」を増やしてテストする一方で、外部依存のある接合部自体のテストについても触れていきたいと思います。


## 目次

- [外部依存するロジックのテストは接合部を増やしてテストする](https://qiita.com/sumihiro3/items/da490aab10499b3c169f)
- **外部依存のある接合部自体をテストする（本投稿）**
- ビジネスロジックを分離する
- プラットフォーム依存部分を分離する

# 既存ロジックにも接合部を増やす（前回のおさらい）

前回は仕様追加した機能実装時に接合部を設けながらテストをしてきましたが、今回は既存ロジックに接合部を追加していきます。

## スロット値が含まれるかの判定

インテント判別時に、スロットに期待する値が含まれているかを確認しています。

- FindGourmetByPrefectureIntent では、対象の都道府県名
- FindGourmetByNameIntent では、対象のご当地グルメ名

このスロット値の検証では、[cek.core.Request](https://clova-cek-sdk-python.readthedocs.io/en/stable/core.html#request) に依存するので、ここに接合部を設けます。

変更前のコードは以下のとおりです。

```python:main.py（変更前の抜粋）
@clova.handle.intent('FindGourmetByPrefectureIntent')
def find_gourmet_by_prefecture_intent_handler(clova_request):
    '''
    都道府県に応じたご当地グルメ情報メッセージを返すインテント
    '''
    prefecture = clova_request.slot_value('prefecture')
    logger.info('Prefecture: %s', prefecture)
    response = None
    if prefecture is not None:
        try:
            # 都道府県名を判別できた場合
            response = _make_gourmet_info_message_by_prefecture(prefecture)
        except Exception as e:
            # 処理中に例外が発生した場合は、最初からやり直してもらう
            logger.error('Exception at make_gourmet_info_message_for: %s', e)
            text = '処理中にエラーが発生しました。もう一度はじめからお願いします。'
            response = response_builder.simple_speech_text(text)
    else:
        # 都道府県名を判別できなかった場合
        text = 'もう一度、ご当地グルメを調べたい都道府県名を教えてください。'
        response = response_builder.simple_speech_text(text)
        response_builder.add_reprompt(
            response,
            'ご当地グルメを調べたい都道府県名を教えてください。')
    # retrun
    return response
```

スロット値取得用の関数を追加して変更したコードです。

```python:main.py(変更後の抜粋）
@clova.handle.intent('FindGourmetByPrefectureIntent')
def find_gourmet_by_prefecture_intent_handler(clova_request):
    '''
    都道府県に応じたご当地グルメ情報メッセージを返すインテント
    '''
    prefecture = _get_slot_value(clova_request, 'prefecture')
    response = None
    if prefecture is not None:
        try:
            # 都道府県名を判別できた場合
            response = _make_gourmet_info_message_by_prefecture(prefecture)
        except Exception as e:
            # 処理中に例外が発生した場合は、最初からやり直してもらう
            logger.error('Exception at make_gourmet_info_message_for: %s', e)
            text = '処理中にエラーが発生しました。もう一度はじめからお願いします。'
            response = _build_clova_response(
                [text],
                end_session=True
            )
    else:
        # 都道府県名を判別できなかった場合
        text = 'もう一度、ご当地グルメを調べたい都道府県名を教えてください。'
        reprompt = 'ご当地グルメを調べたい都道府県名を教えてください。'
        response = _build_clova_response(
            [text],
            reprompt=reprompt
        )
    # retrun
    return response

def _get_slot_value(clova_request, slot_name):
    '''
    指定スロットの値を取得する。スロット値が設定されていない場合はNoneを返す。
    '''
    result = clova_request.slot_value(slot_name)
    logger.info('slot[%s] value is %s', slot_name, result)
    return result
```

テストは前回と同じようにMagickMock を使いながら、正常にスロット値が取れた場合、取れなかった場合、例外発生のパターンくらいはやっておきたいところです。

```python:test_main.py（抜粋）

    def test_find_gourmet_by_prefecture_intent_handler_with_no_prefecture(self):
        '''
        都道府県名が判別できなかった場合
        '''
        # setup
        # 都道府県名が判別できない
        main._get_slot_value = MagicMock(return_value=None)
        main._make_gourmet_info_message_by_prefecture = MagicMock()
        response = {}
        main._build_clova_response = MagicMock(return_value=response)
        request = MagicMock()

        # execute
        result = main.find_gourmet_by_prefecture_intent_handler(request)

        # assert
        # スロット値取得メソッドへの呼び出し検証
        main._get_slot_value.assert_called_once()
        main._get_slot_value.assert_called_with(request, 'prefecture')
        # Response 生成メソッドへの呼び出し検証
        # 都道府県名が判別された場合のメソッドは呼び出されない
        main._make_gourmet_info_message_by_prefecture.assert_not_called()
        # Response 生成メソッドへの呼び出し検証
        text = 'もう一度、ご当地グルメを調べたい都道府県名を教えてください。'
        reprompt = 'ご当地グルメを調べたい都道府県名を教えてください。'
        main._build_clova_response.assert_called_once()
        main._build_clova_response.assert_called_with(
            [text],
            reprompt=reprompt
        )
        self.assertEqual(result, response)

    def test_find_gourmet_by_prefecture_intent_handler_with_exception(self):
        '''
        DB検索時に例外が発生すること
        '''
        # setup
        # 都道府県名が判別できた
        ex_prefecture = '大阪府'
        main._get_slot_value = MagicMock(return_value=ex_prefecture)
        ex = Exception('bar')
        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.find_gourmet_by_prefecture_intent_handler(request)

        # assert
        # スロット値取得メソッドへの呼び出し検証
        main._get_slot_value.assert_called_once()
        main._get_slot_value.assert_called_with(request, 'prefecture')
        # Response 生成メソッドへの呼び出し検証
        main._make_gourmet_info_message_by_prefecture.assert_called_once()
        main._make_gourmet_info_message_by_prefecture.assert_called_with(ex_prefecture)
        # 例外発生時のResponse 生成メソッドへの呼び出し検証
        main._build_clova_response.assert_called_once()
        main._build_clova_response.assert_called_with(
            ['処理中にエラーが発生しました。もう一度はじめからお願いします。'],
            end_session=True
        )
        self.assertEqual(result, response)
```


# 外部依存のある接合部自体をテストする

## 概要

前置きが長くなりましたが、今回の本題です。

ビジネスロジック部分からは接合部となるメソッドを作ってCEK（Clova Extension Kit） への依存を大きく減らすことが出来ましたが、依存があるところもテストする必要があります。

当然ながら外部依存がある部分は、依存するものに寄り添ってテストするしかありません。
今回はCEK のうち、とりわけRequest とResponse についてテストする方法を見ていきます。

そこで[cek.core.Request クラスのソースコード](https://github.com/line/clova-cek-sdk-python/blob/master/cek/core.py) を見ると、コンストラクタにリクエスト形式のdict を渡すようになっています。

```python:core.py（抜粋）
class Request(object):

    def __init__(self, request_dict):
        self._request = request_dict['request']
        self._session = request_dict['session']
        self._context = request_dict['context']
        self.version = request_dict['version']
```

つまり、テストしたいリクエストのJSON があれば良いのです。
リクエストのJSON は[Clova Developer Center](https://clova-developers.line.biz/) の対話モデル画面で取得することが出来ます。

## Clova Developer Center でリクエストJSON を取得する

[Clova Developer Center](https://clova-developers.line.biz/) にアクセスし、対象スキルの対話モデル画面を開きます。

<img width="1404" alt="01_対話モデル画面へのリンク.png" src="https://qiita-image-store.s3.amazonaws.com/0/154381/cef5d4fb-a116-73cb-ac7b-e06d7d12e534.png">

対話モデル画面左側メニューから「テスト」を選択して、テストしたいインテント用の発話を入力し、テストボタンを押下すると画面下部にリクエストのJSON が表示されています。
単体テストにはこのJSON をコピーしてJSON ファイルにして、テストクラスからロードして使います。

<img width="713" alt="02_対話モデル画面でのリクエストJSON生成.png" src="https://qiita-image-store.s3.amazonaws.com/0/154381/dba1e3ae-f024-1b73-3d37-e8485229c3c9.png">

リクエストのJSON は「tests/testdata/find_gourmet_by_prefecture_intent.json」として保存したとします。


## スロット値取得用関数（_get_slot_value）のテスト

あとは先に書いたとおり、テストクラスで保存したリクエストJSON ファイルをロードしてcek.core.Request クラスのコンストラクタに渡せばOKです。
テストコードはこのようになります。

```python:test_main.py（抜粋）
    def test__get_slot_value(self):
        '''
        slot値（都道府県名）を取得する
        '''
        # setup
        with open('tests/testdata/find_gourmet_by_prefecture_intent.json', 'r') as f:
            request_dict = json.load(f)
        request = cek.Request(request_dict)

        # execute
        result = main._get_slot_value(request, 'prefecture')

        # assert
        self.assertEqual(result, '大阪府')
```

また、スロット値が設定されていないリクエスト用JSON ファイルも用意しておきます。
先のリクエストJSON のslots.prefecture.value を'null'に変更して別ファイルで保存しておきます。

```json:find_gourmet_by_prefecture_intent_null_slot.json（抜粋）
    "request": {
        "type": "IntentRequest",
        "intent": {
            "name": "FindGourmetByPrefectureIntent",
            "slots": {
                "prefecture": {
                    "name": "prefecture",
                    "value": null
                }
            }
        }
    }
```

これでスロット値が設定されていない場合のテストも出来ますね！

```python:test_main.py（抜粋）
   def test__get_slot_value_with_null_slot(self):
        '''
        slot値（都道府県名）が設定されていない場合のテスト
        '''
        # setup
        with open('tests/testdata/find_gourmet_by_prefecture_intent_null_slot.json', 'r') as f:
            request_dict = json.load(f)
        request = cek.Request(request_dict)

        # execute
        result = main._get_slot_value(request, 'prefecture')

        # assert
        self.assertEqual(result, None)
```


## Clova Developer Center でレスポンスJSON を取得する

次はレスポンスについても同様にテストしていきます。

[cek.core.Response クラスのソースコード](https://github.com/line/clova-cek-sdk-python/blob/master/cek/core.py) を見ると、Response クラスはdict のサブクラスであることが分かります。

```python:core.py（抜粋）
class Response(dict):

    @property
    def session_attributes(self):
        return self.setdefault('sessionAttributes', {})
```

つまり、リクエストのときと同様にテストしたいレスポンスのJSON があれば良いのです。

では、リクエストのときと同様に[Clova Developer Center](https://clova-developers.line.biz/) にアクセスし、対象スキルの対話モデル画面を開いてJSONを取得しましょう。

<img width="1404" alt="01_対話モデル画面へのリンク.png" src="https://qiita-image-store.s3.amazonaws.com/0/154381/cef5d4fb-a116-73cb-ac7b-e06d7d12e534.png">

またもリクエストのときと同様に、対話モデル画面左側メニューから「テスト」を選択して、テストしたいインテント用の発話を入力し、テストボタンを押下します。
画面最下部（リクエストJSON の下）にレスポンスJSONが表示されているので、このJSON をコピーしてJSON ファイルにし、テストクラスからロードして使います。

<img width="863" alt="03_対話モデル画面でのレスポンスJSON生成.png" src="https://qiita-image-store.s3.amazonaws.com/0/154381/fd5adfba-2d96-317f-dd82-7944c77f745c.png">

## レスポンス生成用関数（_build_clova_response）のテスト

これでレスポンスもリクエストと同様にテストできます。
レスポンス生成用関数で生成したオブジェクトが、対話モデル画面から取得したJSON ファイル内容になるように検証します。

```python:test_main.py（抜粋）
    def test__build_clova_response_for_guide_intent(self):
        '''
        Response 生成のテスト（GuideIntent）
        '''
        # setup
        with open('tests/testdata/guide_intent_response.json', 'r') as f:
            expect_response = json.load(f)
        
        # execute
        full_text = '''このスキルでは各都道府県にあるご当地グルメと、その詳細を調べることが出来ます。
都道府県にあるご当地グルメを知りたいときは、都道府県名を教えてください。
ご当地グルメの詳細を知りたいときは、そのご当地グルメの名前を教えてください。'''
        attributes = {'HasExplainedService': True}
        result = main._build_clova_response(
            [full_text],
            session_attributes=attributes
        )

        # assert
        self.assertEqual(attributes, result.session_attributes)
        self.assertEqual(False, result['response']['shouldEndSession'])
        self.assertEqual(full_text, result['response']['outputSpeech']['values'][0]['value'])

    def test__build_clova_response_for_find_gourmet_by_name_intent(self):
        '''
        Response 生成のテスト（FindGourmetByNameIntent）
        '''
        # setup
        with open('tests/testdata/find_gourmet_by_name_intent_response.json', 'r') as f:
            expect_response = json.load(f)
        
        # execute
        full_text = 'くしかつ わ、大阪府 のご当地グルメです。小ぶりに切った肉や魚介類、野菜を個別に串に刺して衣をまぶして揚げた料理です。ソースの二度漬けは禁止です。'
        attributes = None
        result = main._build_clova_response(
            [full_text],
            end_session=True
        )

        # assert
        self.assertEqual(attributes, result.session_attributes)
        self.assertEqual(True, result['response']['shouldEndSession'])
        self.assertEqual(full_text, result['response']['outputSpeech']['values'][0]['value'])
```

## まとめ

このようにビジネスロジックと外部依存する接合部を切り離しておくと、各関数の役割が明確になってきますし、それぞれのテストも容易になってきます。

しかも、Clova Developer Center ではリクエスト／レスポンスのJSON を簡単に生成できるので、これを活用しない手はありませんね。

さて、ここまで関数を色々と分割してきた関係で、逆にコード全体の見渡しが難しくなってきました。今後の投稿では、よりビジネスロジックとCEK 部分を切り離しながら、クラス設計のリファクタリングを進めていきたいと思います。