Amazon Web Services ブログ

AWS CodeStar プロジェクトにおける単体テストの実行

このブログ記事では、AWS CodeStar プロジェクトの一部として単体テストを実行する方法についてご紹介します。AWS CodeStar は AWS 上でアプリケーションを素早く開発、ビルド、そしてデプロイするのに役立ちます。AWS CodeStar を使えば、継続的デリバリ(CD)のツールチェーンを構築しソフトウェア開発を一箇所に集約して管理することができます。

単体テストはアプリケーションのコードの個々の単位をテストし、問題を素早く特定し取り去るのに役立ちます。単体テストは、自動化された CI/CD プロセスの1部分を構成することで、バッドコードが本番環境にデプロイされてしまうことを防ぐのにも役立てられます。

多くの AWS CodeStar プロジェクト テンプレートには、単体テストのフレームワークがあらかじめ設定されています。そのため、自信を持ってコードのデプロイを開始することができるでしょう。この AWS CodeStar プロジェクトの単体テストは、ビルドステージにおいて実行されるように設定されているので、もし単体テストがパスしなかったらそのコードがデプロイされることはありません。単体テストを内包している AWS CodeStar プロジェクト テンプレートの一覧については、AWS CodeStar ユーザガイドの AWS CodeStar プロジェクト テンプレートを参照してください。

シナリオ

私はスーパーヒーロー映画の大ファンです。なので、お気に入りの作品を一覧にして友人に投票してもらうウェブサービスを作ろうと思い立ちました。私が使ったのは、AWS Lambda で実行される Python のウェブフレームワークで、ソースコード リポジトリに AWS CodeCommit を使っているテンプレートです。CodeCommit はフルマネージドのソースコントロールサービスです。 Git リポジトリをホストし Git ベースの全てのツールと連携可能です。

ウェブサービスのエンドポイントを作成する方法は次のとおりです。

AWS CodeStar コンソールにサインインし、Start a project を選択します。すると、プロジェクト テンプレートの一覧が表示されます。

create project

コード編集の方法として AWS Cloud9 を選択します。AWS Cloud9 は、コードの記述、実行、デバッグが全て実施可能なクラウドベースの統合開発環境 (IDE) です。

choose cloud9

その他に必要な作業は次のとおりです。

  • 必要に応じて投票データを保存および取得するためのデータベース テーブルの作成。
  • 投票データの保存と取得のために作成された Lambda 関数のロジックの更新。
  • (当たり前ですが)上記ロジックが期待通りに動作するかを検証する単体テストの更新。

データベースには Amazon DynamoDB を選択しました。Amazon DynamoDB は高速で柔軟な NoSQL データベースを提供します。

AWS Cloud9 で設定する

AWS CodeStar のコンソールから AWS Cloud9 のコンソールに移動してください。これにより、プロジェクトのコードが表示されます。トップレベルのフォルダでターミナルを開き、環境変数と必要なライブラリをセットします。

PYTHONPATH 環境変数を設定するために、ターミナル上で次のコマンドを実行してください。

export PYTHONPATH=/home/ec2-user/environment/vote-your-movie

プロジェクト内で単体テストを実行できるようにするために次のコマンドを使います。

python -m unittest discover vote-your-movie/tests

コーディングを開始する

ローカル環境をセットアップしコードのコピーを手に入れたので、テンプレートファイルで DynamoDB テーブルを定義することによって DynamoDB テーブルをプロジェクトに追加してください。Serverless Application Model (SAM) のテンプレートファイルである template.yml を開いてください。Serverless Application Model (SAM) テンプレートは AWS CloudFormation を拡張して Amazon API Gateway の API、AWS Lambda の関数、そして Amazon DynamoDB のテーブルといったサーバレスアプリケーションで必要となるリソースを定義するためのシンプルな方法を提供します。

AWSTemplateFormatVersion: 2010-09-09
Transform:
- AWS::Serverless-2016-10-31
- AWS::CodeStar

Parameters:
  ProjectId:
    Type: String
    Description: CodeStar projectId used to associate new resources to team members

Resources:
  # 投票を保存するための DB テーブル
  MovieVoteTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        # Candidate の名前を、テーブルの partition key として使用
        Name: Candidate
        Type: String
  # 投票を保存と取得するための Lambda 関数を新規作成
  MovieVoteLambda:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      Runtime: python3.6
      Environment:
        # Lambda 関数で使用する環境変数を設定
        Variables:
          TABLE_NAME: !Ref "MovieVoteTable"
          TABLE_REGION: !Ref "AWS::Region"
      Role:
        Fn::ImportValue:
          !Join ['-', [!Ref 'ProjectId', !Ref 'AWS::Region', 'LambdaTrustRole']]
      Events:
        GetEvent:
          Type: Api
          Properties:
            Path: /
            Method: get
        PostEvent:
          Type: Api
          Properties:
            Path: /
            Method: post

AWS サービスと接続するために Python の boto3 ライブラリを使います。それから、単体テストにおける AWS サービスの呼び出しをモックするために Python のモックライブラリを使います。次のコマンドを使ってこれらのライブラリをインストールしてください。

pip install --upgrade boto3 mock -t .

install dependencies

これらのライブラリを buildspec.yml に追加してください。buildspec.yml は、CodeBuild を実行する際に必要な YAML ファイルです。

version: 0.2

phases:
  install:
    commands:

      # AWS CLI を最新バージョンに更新
      - pip install --upgrade awscli boto3 mock

  pre_build:
    commands:

      # 'tests'ディレクトリ内の単体テストを検索し実行します。より詳しい情報はこちらを参照: <https://docs.python.org/3/library/unittest.html#test-discovery>
      - python -m unittest discover tests

  build:
    commands:

      # AWS CloudFormation を使って アプリケーションをパッケージングするために AWS SAM を使用
      - aws cloudformation package --template template.yml --s3-bucket $S3_BUCKET --output-template template-export.yml

artifacts:
  type: zip
  files:
    - template-export.yml

index.py を開いてください。Lambda 関数のシンプルな投票ロジックをこのファイルに書きます。

import json
import datetime
import boto3
import os

table_name = os.environ['TABLE_NAME']
table_region = os.environ['TABLE_REGION']

VOTES_TABLE = boto3.resource('dynamodb', region_name=table_region).Table(table_name)
CANDIDATES = {"A": "Black Panther", "B": "Captain America: Civil War", "C": "Guardians of the Galaxy", "D": "Thor: Ragnarok"}

def handler(event, context):
    if event['httpMethod'] == 'GET':
        resp = VOTES_TABLE.scan()
        return {'statusCode': 200,
                'body': json.dumps({item['Candidate']: int(item['Votes']) for item in resp['Items']}),
                'headers': {'Content-Type': 'application/json'}}

    elif event['httpMethod'] == 'POST':
        try:
            body = json.loads(event['body'])
        except:
            return {'statusCode': 400,
                    'body': 'Invalid input! Expecting a JSON.',
                    'headers': {'Content-Type': 'application/json'}}
        if 'candidate' not in body:
            return {'statusCode': 400,
                    'body': 'Missing "candidate" in request.',
                    'headers': {'Content-Type': 'application/json'}}
        if body['candidate'] not in CANDIDATES.keys():
            return {'statusCode': 400,
                    'body': 'You must vote for one of the following candidates - {}.'.format(get_allowed_candidates()),
                    'headers': {'Content-Type': 'application/json'}}

        resp = VOTES_TABLE.update_item(
            Key={'Candidate': CANDIDATES.get(body['candidate'])},
            UpdateExpression='ADD Votes :incr',
            ExpressionAttributeValues={':incr': 1},
            ReturnValues='ALL_NEW'
        )
        return {'statusCode': 200,
                'body': "{} now has {} votes".format(CANDIDATES.get(body['candidate']), resp['Attributes']['Votes']),
                'headers': {'Content-Type': 'application/json'}}

def get_allowed_candidates():
    l = []
    for key in CANDIDATES:
        l.append("'{}' for '{}'".format(key, CANDIDATES.get(key)))
    return ", ".join(l)

上記のコードは、HTTPS リクエストの呼び出しをイベントとして受け取っています。HTTP GET リクエストの場合、投票結果をテーブルから取得します。HTTP POST リクエストの場合は、選択した候補で投票をセットします。また、悪意のあるリクエストを除外するために POST リクエストの中の入力値をバリデーションしています。

このサンプルコードでは投票候補リストを CANDIDATES 変数に保持していますが、そうではなく JSON ファイルに格納し Python の json ライブラリを使うことも可能です。

単体テストを更新してみましょう。tests フォルダ配下で test_handler.py を開いてロジックを検証するように変更してください。

import os
# DynamoDBのモックで使われる、モックの環境変数
os.environ['TABLE_NAME'] = "MockHelloWorldTable"
os.environ['TABLE_REGION'] = "us-east-1"

# 私達独自のロジックが入ったライブラリ
import index

# Boto3'のコアライブラリ
import botocore
# JSON を扱うために
import json
# 単体テストライブラリ
import unittest
## 設定に基づいて StringIO を取得
try:
    from StringIO import StringIO
except ImportError:
    from io import StringIO
## Python のモックライブラリ
from mock import patch, call
from decimal import Decimal

@patch('botocore.client.BaseClient._make_api_call')
class TestCandidateVotes(unittest.TestCase):

    ## HTTP GET リクエストのフローをテスト
    ## (モックの)テーブルから取得した投票結果付きの成功レスポンスが返ってくることが期待されます
    def test_get_votes(self, boto_mock):
        # テスト対象のメソッドに入力するイベント
        expected_event = {'httpMethod': 'GET'}
        # DynamoDB テーブル内のモックの値
        items_in_db = [{'Candidate': 'Black Panther', 'Votes': Decimal('3')},
                        {'Candidate': 'Captain America: Civil War', 'Votes': Decimal('8')},
                        {'Candidate': 'Guardians of the Galaxy', 'Votes': Decimal('8')},
                        {'Candidate': "Thor: Ragnarok", 'Votes': Decimal('1')}
                    ]
        # DynamoDB テーブルのレスポンスのモック
        expected_ddb_response = {'Items': items_in_db}
        # boto を介して DynamoDB 呼び出すことによって返ってくることが期待されるレスポンスのモック
        response_body = botocore.response.StreamingBody(StringIO(str(expected_ddb_response)),
                                                        len(str(expected_ddb_response)))
        # モック内の期待される値をセット
        boto_mock.side_effect = [expected_ddb_response]
        # 実行中に、これらのパラメータを使用して DynamoDB Scan 関数が呼び出されることが期待されます
        expected_calls = [call('Scan', {'TableName': os.environ['TABLE_NAME']})]

        # テスト対象の関数を呼び出します
        result = index.handler(expected_event, {})

        # 期待される関数呼び出しがモックに対して発生していること、そしてレスポンスが正しいことを検証するために、単体テストのアサーションを実行
        assert result.get('headers').get('Content-Type') == 'application/json'
        assert result.get('statusCode') == 200

        result_body = json.loads(result.get('body'))
        # 関数の呼び出し結果がテーブルから取り出した結果と一致するかを検証
        assert len(result_body) == len(items_in_db)
        for i in range(len(result_body)):
            assert result_body.get(items_in_db[i].get("Candidate")) == int(items_in_db[i].get("Votes"))

        assert boto_mock.call_count == 1
        boto_mock.assert_has_calls(expected_calls)

    ## 選択した候補に投票を行う HTTP POST リクエストのフローをテスト
    ## 確認メッセージ付きの成功レスポンスが返ってくることが期待されます
    def test_place_valid_candidate_vote(self, boto_mock):
        # テスト対象のメソッドに入力するイベント
        expected_event = {'httpMethod': 'POST', 'body': "{\"candidate\": \"D\"}"}
        # DynamoDB テーブルのレスポンスのモック
        expected_ddb_response = {'Attributes': {'Candidate': "Thor: Ragnarok", 'Votes': Decimal('2')}}
        # boto を介して DynamoDBを呼び出すことによって返ってくることが期待されるレスポンスのモック
        response_body = botocore.response.StreamingBody(StringIO(str(expected_ddb_response)),
                                                        len(str(expected_ddb_response)))
        # モック内の期待される値をセット
        boto_mock.side_effect = [expected_ddb_response]
        # 実行中に、これらのパラメータを使用して DynamoDB UpdateItem 関数が呼び出されることが期待されます
        expected_calls = [call('UpdateItem', {
                                                'TableName': os.environ['TABLE_NAME'], 
                                                'Key': {'Candidate': 'Thor: Ragnarok'},
                                                'UpdateExpression': 'ADD Votes :incr',
                                                'ExpressionAttributeValues': {':incr': 1},
                                                'ReturnValues': 'ALL_NEW'
                                            })]
        # テスト対象の関数を呼び出します
        result = index.handler(expected_event, {})
        # 期待される関数呼び出しがモックに対して発生していること、そしてレスポンスが正しいことを検証するために、単体テストのアサーションを実行
        assert result.get('headers').get('Content-Type') == 'application/json'
        assert result.get('statusCode') == 200

        assert result.get('body') == "{} now has {} votes".format(
            expected_ddb_response['Attributes']['Candidate'], 
            expected_ddb_response['Attributes']['Votes'])

        assert boto_mock.call_count == 1
        boto_mock.assert_has_calls(expected_calls)

    ## 存在しない候補に投票を行う HTTP POST リクエストのフローをテスト
    ## 確認メッセージ付きの成功レスポンスが返ってくることが期待されます
    def test_place_invalid_candidate_vote(self, boto_mock):
        # テスト対象のメソッドに入力するイベント
        # 投票候補として正しい ID は A, B, C, そして D です
        expected_event = {'httpMethod': 'POST', 'body': "{\"candidate\": \"E\"}"}
        # テスト対象の関数を呼び出します
        result = index.handler(expected_event, {})
        # 期待される関数呼び出しがモックに対して発生していること、そしてレスポンスが正しいことを検証するために、単体テストのアサーションを実行
        assert result.get('headers').get('Content-Type') == 'application/json'
        assert result.get('statusCode') == 400
        assert result.get('body') == 'You must vote for one of the following candidates - {}.'.format(index.get_allowed_candidates())

    ## 選択した候補に投票を行う HTTP POST リクエストのフローをテスト。ただし、選択した候補は POST のボディに不正なキーで紐付けられています。
    ## 適切なエラーメッセージをともなった失敗(400)レスポンスが返ってくることが期待されます
    def test_place_invalid_data_vote(self, boto_mock):
        # テスト対象のメソッドに入力するイベント
        # "name" は期待される入力キーではありません
        expected_event = {'httpMethod': 'POST', 'body': "{\"name\": \"D\"}"}
        # テスト対象の関数を呼び出します
        result = index.handler(expected_event, {})
        # 期待される関数呼び出しがモックに対して発生していること、そしてレスポンスが正しいことを検証するために、単体テストのアサーションを実行
        assert result.get('headers').get('Content-Type') == 'application/json'
        assert result.get('statusCode') == 400
        assert result.get('body') == 'Missing "candidate" in request.'

    ## 選択した候補に投票を行う HTTP POST リクエストのフローをテスト。ただし、リクエスト ボディは期待されている JSON 文字列形式ではありません。
    ## 適切なエラーメッセージをともなった失敗(400)レスポンスが返ってくることが期待されます
    def test_place_malformed_json_vote(self, boto_mock):
        # テスト対象のメソッドに入力するイベント
        # "body" が JSON ではなく単なる文字列です
        expected_event = {'httpMethod': 'POST', 'body': "Thor: Ragnarok"}
        # テスト対象の関数を呼び出します
        result = index.handler(expected_event, {})
        # 期待される関数呼び出しがモックに対して発生していること、そしてレスポンスが正しいことを検証するために、単体テストのアサーションを実行
        assert result.get('headers').get('Content-Type') == 'application/json'
        assert result.get('statusCode') == 400
        assert result.get('body') == 'Invalid input! Expecting a JSON.'

if __name__ == '__main__':
    unittest.main()

サンプルコードにはコメントを丁寧に残しているので、各単体テストが何をやっているかは分かりやすいと思います。成功条件をテストするとともに、ロジック内で正常に取り扱われる失敗のパスもテストしています。

この単体テストでは、モックライブラリの patch デコレータ (@patch) を使用しています。@patch は、呼び出したい関数をモックするのに役立ちます(今回の場合で言えば、botocore ライブラリの BaseClient クラスの _make_api_call 関数です)。
変更を commit する前に、ローカルでテストを動かしてみましょう。ターミナルで、再度テストを実行してください。もし、全ての単体テストがパスしたらこんな感じの結果を目にすると思われます。

You:~/environment $ python -m unittest discover vote-your-movie/tests
.....
----------------------------------------------------------------------
Ran 5 tests in 0.003s

OK
You:~/environment $

AWS にアップロードする

テストが通ったということは、いよいよコードを commit してリポジトリに push するときがきたということです。

変更を add する

ターミナルでプロジェクトのフォルダに移動し、push するつもりの変更内容を確認するために次のコマンドを使用してください。

git status

変更したファイルのみを add するため、次のコマンドを使用してください。
(訳注: git add の -u オプションは、新規追加されたファイルは除いて、変更のあった部分のみを add します)

git add -u

変更を commit する

(コミットメッセージを付けて)変更を commit するために、次のコマンドを使用してください。

git commit -m "Logic and tests for the voting webservice."

AWS CodeCommit に変更を push する

commit した変更を CodeCommit に push するために、次のコマンドを使用してください。

git push

AWS CodeStar のコンソールで、変更内容がパイプラインを経由してデプロイされるのを確認することができます。また、AWS CodeStar のコンソールには、このプロジェクトの「ビルド実行」に遷移するリンクがあるので、 AWS CodeBuild 上でテストが実行されるのを見ることができます。Build Runs テーブルの下の最新のリンクをクリックするとログが表示されます。

unit tests at codebuild

デプロイが完了すると、AWS Lambda 関数と DynamoDB テーブルが作成されるとともにこのプロジェクトに同期され、 AWS CodeStar で表示されるようになります。AWS CodeStar のナビーションバーにある Project というリンクをクリックすると、このプロジェクトに紐付けられた AWS リソースが表示されます。

codestar resources

新しいデータベーステーブルなので中身は空っぽです。なので何度か投票してみましょう。Postman をダウンロードすれば、アプリケーション エンドポイントの POST と GET の呼び出しをテストすることができます。テストするエンドポイントは AWS CodeStar コンソールの Application endpoints の下に表示されている URL です。

Postman を開いて結果を見てみましょう。POST リクエストで何度か投票を行って見ましょう。今回の例では、正しい投票は A, B, C, または D のうちどれかの値を持ちます。正しい POST リクエストはこんなふうになります。

A, B, C, あるいは D 以外のなんらかの値を使った場合にどのようになるかがこちらです。

POST Fail

それでは、データベースから投票結果を取得するために GET リクエストを利用したいと思います。

GET success

以上で終了です。AWS Lambda, Amazon API Gateway, そして DynamoDB を使ってシンプルな投票ウェブサービスを作成しました。そして、ロジックを検証する単体テストを導入しました。そのおかげでグッドコードを ship することができましたね!
Happy coding!

翻訳は SA 畑史彦が担当しました。原文は AWS DevOps Blog の『Performing Unit Testing in an AWS CodeStar Project』です。