pytest+motoでS3へのバケット一覧リクエストからオブジェクト確認リクエストまで細かくテストしてみた

S3を操作するロジックに対するテストコードについて、pytest+motoで作成した際の過程を書いてみました。
2019.06.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

Python製のロジックに対するユニットテストにはpytestは有効な一打です。そして、AWSサービスへアクセスするロジックに対するテストにはmotoという選択肢があります。

一例として、S3のファイル有無確認用ロジックのテストコードについて、motoを使って試行錯誤した末のまとめを書いてみました。

テストを組み立てる順番

今回は以下の順番で進めます。

  1. テスト環境の作成
  2. テストケースの追加
  3. クラス実装
  4. テストの実行

前提環境

  • Python3.7

依存ライブラリ

  • pipenv
  • moto
  • boto3
  • pytest

テスト環境の作成

pipenv install --python 3.7
pipenv install boto3
pipenv install --dev moto pytest

ファイル・ディレクトリ構成

ユニットテスト実行を目的として、以下の構成にしました。各__init__.pyは空のファイルです。

setup.cfg
src/
  mypkg/
    __init__.py
    s3_access.py
tests/
  __init__.py
  foo/
    __init__.py
    conftest.py
    test_s3_access.py
参考
pytest入門 - 闘うITエンジニアの覚え書き

setup.cfgの作成

pytestのコマンドだけで引数レス実行できるように指定します。

testpaths
テスト対象ディレクトリ
python_files
対象ファイルパターン
python_functions
対象テスト名パターン(指定されたパターンから始まるテストのみ
[tool:pytest]
testpaths = tests/
python_files = test_*.py
python_functions = test
minversion = 3.7
addpot = --pdb

tests/foo/conftest.pyの作成

テストケースから実装クラス呼び出しを行うために、src/ディレクトリへのパスを追加します。

from pathlib import Path
import sys

sys.path.append(str(Path("./src/").resolve()))

テストケースの追加

pytestmotoを併用して実装します。

motoを利用したテストは、必要に応じてテストケース用データを作成する必要があります。S3の場合、データの作成は@mock_s3デコレータを利用した関数内で、S3へのリクエストコードを追加・実行することによって行います。このデータは一時的なものであるため、必要に応じてテスト毎に作成します。

例として、bucketの作成前後を確認した手続きは以下のようになります。2回目のlist_buckets()にて、テストケース用データが空ではなくなったことを確認しています。

# S3上にbucketが作成されていないことを確認
% aws s3 ls | grep 'test_bucket'
% vim test_bucket.py
import boto3
from moto import mock_s3


@mock_s3
def test_s3_bucket():
    client = boto3.client('s3')
    assert client.list_buckets()['Buckets'] == []
    client.create_bucket(Bucket='test_bucket')
    assert client.list_buckets()['Buckets'] == []
    
% pytest test_bucket.py
..
..
========================== FAILURES ====================
_______________________ test_s3_bucket ________________
..
..
    @mock_s3
    def test_s3_bucket():
        client = boto3.client('s3')
        assert client.list_buckets()['Buckets'] == []
        client.create_bucket(Bucket='test_bucket')
>       assert client.list_buckets()['Buckets'] == []
E       AssertionError: assert [{'CreationDa...test_bucket'}] == []
E         Left contains one more item: {'CreationDate': datetime.datetime(2006, 2, 3, 16, 45, 9, tzinfo=tzutc()), 'Name': 'test_bucket'}
E         Use -v to get the full diff

# 実際にはS3上でbucketが作成されていないことを確認
% aws s3 ls | grep 'test_bucket'

ファイル有無確認用のテストコードを作成する

気をつけるべき点として、実際にはアップロードされないとしても、upload_fileで対象にするファイルは必ずローカルに存在しなければなりません

% touch /path/to/file

tests/foo/test_s3_access.pyの作成

from moto import mock_s3
import boto3
import os
import pytest
import .mypkg.s3_access import S3Download


@mock_s3
def test_download():
    BUCKET = 'bucket'
    FILE_PATH = '/path/to/file'
    FILE_NAME = 'file'
    client = boto3.client('s3')
    client.create_bucket(Bucket=BUCKET)
    client.upload_file(FILE_PATH, BUCKET, FILE_NAME)
    s3_downloader = S3Download()
    assert s3_downloader.isexists(FILE_NAME) is True

クラスの実装

S3上のオブジェクト確認にはhead_objectを利用しました。

S3 — Boto 3 Docs 1.9.170 documentation

src/mypkg/s3_access.pyの作成

import boto3
from botocore.errorfactory import ClientError

    
class S3Download:
    def isexists(self, file_name):
        try:
            boto3.client('s3').head_object(Bucket='bucket', Key=file_name)
        except ClientError:
            return False
        return True

テストの実行

今回pytestのみで実行可能にしているため、引数は不要です。

% pytest
============ test session starts ================
..
..
collected 1 item

test_bucket.py .

まとめ

AWS上のサービスを操作するロジックのテストコードは書き辛い」と思った時こそ、motoの利用をおすすめします。