サービステストをLambda+unittest用ライブラリで実装してみる 

[AWS LambdaとServerless Advent Calendar 2020](https://qiita.com/advent-calendar/2020/lambda) 3日目の記事です。

あるサービスのテスト(外部サービスとの結合テスト)を、Lambda(python)+unittestで実装してみます。

# やること

「特定の条件を満たすメールを受信した場合はBacklogに課題を起票する」というサービスをテストするエンジンをLambda+unittestで構築します。
unittestライブラリをunittestよりも上のレイヤーの試験に使います。案外使い心地がいいです。

![アーキテクチャ (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43631/eb7a25a6-a128-5b83-0caa-46c6bba75132.png)


## テスト対象サービス

以下の条件を満たすメールを受信した場合に、Backlogに課題を起票します

 - メールタイトルに特定の文字列を含む
 - 送信元が許可された特定のメールアドレス

## テストエンジン
 
 - Lambda(python)にunittestライブラリでテストシナリオを記述し実行
 - 各テストケースは、概ね以下のような構成
    - テスト対象のシステムに対し試験条件に沿ったメールを送信
    - 課題が期待通りに起票されている/されていないことを確認
    - テストケース終了時(tearDown)にテストで起票された課題を後始末として閉じる
 - すべてのテストが完了したら結果をSNSで通知

# 実装

テストエンジンのLambdaのコードをgistにあげました
https://gist.github.com/chunkof/845395aee9c09a17ee812ae0b80bd8c2
※マネコンで直接実装してポチポチして試したので、デプロイ設定や環境変数情報は含んでいません。tests_acceptance.pyはtests/acceptance.pyにパスを読み替えてくださいmm

以下に代表的なところをピックアップします。

## lambdaハンドラ

```python:lambda_function.py
...
from unittest import TestLoader
from unittest import TextTestRunner
...
def lambda_handler(event, context):
    # run test
    loader = TestLoader()
    test = loader.discover("./tests", pattern="*.py")
    runner = TextTestRunner()
    r = runner.run(test)
    
    # report
    summary = f"Finished test ran:{r.testsRun} failed:{len(r.failures)}"
    body = f"request_id:{context.aws_request_id}\nfailures = {r.failures}"
    print(f"{summary}\n{body}")
    sns_topic.publish(Subject=summary,Message=body)
    
    return "ok"
```
 - ./tests/*.pyからテストケースをあつめて、実行してます
 - 結果をSNSトピックにpublishしてます

## テストケース実装

```python:tests/acceptance.py
...
def tearDown(self):
    """
    テストケースtearDown
    テスト起票対象を全てcompleteさせておく(後片付け
    """
    complete_all_test_issues()

def test_target_keyword(self):
    """
    キーワードそのままsubjectで送信 -> 起票されること
    """
    # input
    subject = KEYWORD
    send_email(src=SENDER_EMAIL, subject=subject)
    
    # wait
    time.sleep(WAIT_SECONDS)
    
    # assert
    issues = get_test_issues()
    self.assertEqual(1, len(issues))
    self.assertEqual(subject, issues[0]['summary'])
...
```
 - tearDown(テストケース終了毎によばれるやつ)で後片付けしてます。
    - complete_all_test_issuesの中身はBacklogのAPI叩いて対象課題をcompleteしてます。

 - test_target_keywordは条件に一致するメールの場合、Backlogに起票されることを確認しています
    - ①メール送信(SES使用) → ②ちょっと待つ → ③期待通り課題が起票されているか確認(Backlog API使用) という流れ

こんな感じで４件のテストケースを書いています。

# 実行

今回4件のテストケースを実装したのでLambdaを実行して結果をみます。
結果送信のSNSにはメールを紐付けています。

## 全ケース成功の場合

### Lambdaログ
```
START RequestId: 2158563e-cdc2-40ff-a3f0-695e14ddaa1b Version: $LATEST
....
----------------------------------------------------------------------
Ran 4 tests in 15.145s

OK
Finished test ran:4 failed:0
request_id:2158563e-cdc2-40ff-a3f0-695e14ddaa1b
failures = []
END RequestId: 2158563e-cdc2-40ff-a3f0-695e14ddaa1b
REPORT RequestId: 2158563e-cdc2-40ff-a3f0-695e14ddaa1b	Duration: 15440.17 ms	Billed Duration: 15441 ms	Memory Size: 128 MB	Max Memory Used: 83 MB	Init Duration: 563.65 ms
```

### SNS通知

![Finished_test_ran_4_failed_0_-_shuichi_aws-plus_com_-_アイレット株式会社_メール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43631/eee10265-6af8-1635-0d23-a9f2357bd83c.png)


### Backlog課題
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43631/168784da-dbed-06d0-c480-1bfafbb44d28.png)


テストで起票したものはケース終了後にtearDownで完了になっている。

## 1ケース失敗させた場合
テスト対象サービスのコードから「送信元の確認」を外して実行し、テストを1件失敗させます。

### Lambdaログ
```
START RequestId: 0add2441-1d2c-49f8-91e0-ae8ab365c001 Version: $LATEST
F...
======================================================================
FAIL: test_not_target_invalid_sender (acceptance.AcceptanceTest)
キーワードそのままsubject 対象外のアドレスから送信 -> 起票されないこと
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/var/task/tests/acceptance.py", line 94, in test_not_target_invalid_sender
    self.assertEqual(0, len(issues))
AssertionError: 0 != 1

----------------------------------------------------------------------
Ran 4 tests in 16.998s

FAILED (failures=1)
Finished test ran:4 failed:1
request_id:0add2441-1d2c-49f8-91e0-ae8ab365c001
failures = [(<acceptance.AcceptanceTest testMethod=test_not_target_invalid_sender>, 'Traceback (most recent call last):\n  File "/var/task/tests/acceptance.py", line 94, in test_not_target_invalid_sender\n    self.assertEqual(0, len(issues))\nAssertionError: 0 != 1\n')]
END RequestId: 0add2441-1d2c-49f8-91e0-ae8ab365c001
REPORT RequestId: 0add2441-1d2c-49f8-91e0-ae8ab365c001	Duration: 17291.73 ms	Billed Duration: 17292 ms	Memory Size: 128 MB	Max Memory Used: 83 MB	Init Duration: 586.21 ms	
```


### SNS通知
![Finished_test_ran_4_failed_1_-_shuichi_aws-plus_com_-_アイレット株式会社_メール.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43631/03faef9c-4eba-de50-df20-e0a46ca7ae2c.png)



### Backlog課題

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43631/00e869f0-0766-03bf-6ed8-549d35675d64.png)

対象外の送信元から送信されたケース１件が余計に起票されています。

# 使いどころ

この構成は初めて試してみましたが、Lambdaはいろいろなところに非常に組込みやすいので、シンプルで短く済むテストならありかなと思いました。
CodePipelineでデプロイ後のテストステージに組み込むとか良さそうです。

自分が普段開発し実稼働しているシステムでは「Fargate+pytest」の構成でサービステストを実装して回しているものが多いです。Lambdaベースも今後やってみようと思いますが、以下のような要因でテストの実行時間やコード量は増えていくので注意がいります。

- そもそもテスト対象がバッチ処理やデータコピーなどを伴うサービスが対象だと実行に時間がかかる
- 外部サービスとの連携で安定性を高めるため処理待ちやポーリングを入れて実行時間が延びる
- 複雑なシステムだとテストのための設定データの作成/クリーンナップの処理がそれなりのボリュームになってくる

unittestフレームワークには並列実行をサポートしているものも多いので「干渉し合わない並列実行可能なテスト」が書ける場面であれば実行時間は短縮できそうです。

## なんでこんなことしてるか
unittest用のフレームワークをそれより上のレイヤーのテストにも使い始めたのは、使い慣れていたので試してみたらカスタマイズしやすく融通がきき使いやすかったという感じです。他にもオススメなどあればコメントいただけると幸いです。