kakakakakku blog

Weekly Tech Blog: Keep on Learning!

自信を持って pytest を活用するためのノウハウが凝縮された「テスト駆動 Python 第2版」を読んだ

「テスト駆動 Python 第2版」を読んだ📕

仕事で pytest を使ってて,もっと自信を持って書けるようになりたいな〜と思っていたら本書を見つけてさっそく読んでみた.pytest の機能・記法・設定・Tips などの理解が深まって本当に読んで良かった❗️フィクスチャ・パラメータ化・モック・プラグイン活用など,今まで何となく書いてたところを自信を持って書けるようになって,仕事で pytest を書くのが楽しくなった🦄

もちろん pytest の公式ドキュメントを読むべきだし,本書の内容の多くは公式ドキュメントにも載っているとは思うけど,本書の翻訳はとても読みやすく,pytest の全体像をサッと把握できて,また Cards というサンプルアプリケーションを題材に実際に pytest を試しながら読み進められるから本書を読む価値はあると思う.個人的には本当に読んで良かったな〜と感じてる👍

docs.pytest.org

目次

目次をザッと見て,気になる Chapter があったら読んでみると良いかと👌

  • Part.1: pytest の主力機能
    • Chapter.1: はじめての pytest
    • Chapter.2: テスト関数を書く
    • Chapter.3: pytest のフィクスチャ
    • Chapter.4: 組み込みフィクスチャ
    • Chapter.5: パラメータ化
    • Chapter.6: マーカー
  • Part.2: プロジェクトに取り組む
    • Chapter.7: 戦略
    • Chapter.8: 設定ファイル
    • Chapter.9: カバレッジ
    • Chapter.10: モック
    • Chapter.11: tox と継続的インテグレーション
    • Chapter.12: スクリプトとアプリケーションのテスト
    • Chapter.13: テストの失敗をデバッグする
  • Part.3: ブースターロケット
    • Chapter.14: サードパーティプラグイン
    • Chapter.15: プラグインの作成
    • Chapter.16: 高度なパラメータ化

本書で使う Cards アプリケーションのコードは原著サイトから ZIP でダウンロードできる.

pragprog.com

Chapter.4

pytest で一時的なディレクトリを作れる組み込みフィクスチャ tmp_path(function スコープ)と tmp_path_factory(session スコープ)はさっそく使えそうだった.

docs.pytest.org

他にも Chapter.4 で紹介されてた組み込みフィクスチャは便利で capsys は別途試して簡単にまとめた.

kakakakakku.hatenablog.com

Chapter.8

pytest.initestpaths を設定するのは明確にはなるけど冗長だよなぁ〜と思っていたらtestpaths を指定しておくと pytest 開始時の時間を少し節約できる」と書いてあって発見だった👀ある程度プロジェクトの規模が大きくないと差は出なさそうではあるけど知れて良かった情報の一つだった👍

[pytest]
testpaths = tests

docs.pytest.org

Chapter.12

スクリプトとテストコードを src ディレクトリと tests ディレクトリに分割したら import できずにハマるというのはよくあると思う💨僕自身も最初ハマって先輩に相談して教えてもらった経緯もあったりする.本書の第2版では Chapter.12 に Python の検索パスの解説が追加されていて良かった❗️本書を読んで pytest の import にハマる人が減ると良いなぁ〜 \( 'ω')/

.
├── pytest.ini
├── src
│   └── hello.py
└── tests
    └── test_hello.py

本書では pytest.inipythonpath を設定する例が紹介されていた.

[pytest]
pythonpath = src

もちろん pytest.ini ではなく pyproject.toml に設定することもできる👌

[tool.pytest.ini_options]
pythonpath = "src"

Chapter.14

pytest プラグインは今までほとんど活用できてなかった💨 pytest サイトの Plugin List を見ながら気になったプラグインをさっそく導入してみて便利❗️

github.com

github.com

github.com

github.com

Chapter.16

@pytest.mark.parametrize でパラメータ化するのは普段から使っているけど,idids にテスト識別子を設定するという Tips は今まで活用できてなかった.確かにパラメータ化したテストで失敗すると判別しにくく感じるときもあった.さっそく仕事でも使うようにした👌

docs.pytest.org

その他

他に読書メモに残したことを箇条書きにしておく❗️

  • pytest --setup-show でフィクスチャの実行順をログに出力できる
  • pytest --tb=no でテスト失敗時のトレースバックを非表示にできる
  • pytest --showlocals でテスト失敗時にローカル変数を表示できる
  • conftest.py でフィクスチャを共有できる
  • @pytest.fixture の名前を変更できる
  • requests をモックできる Responses が便利そう

誤植

出版社サイトに掲載されていない誤植を見つけたのでメモしておく📝

  • P.120: 対処できるしょうか。対処できるでしょうか。
  • P.122: 簡単かかもしれないが簡単かもしれないが

www.shoeisha.co.jp

X ポスト🔗

pytest の capsys で stdout(標準出力)と stderr(標準エラー)をテストする

pytest の capsys を使うと Python スクリプトで出力する stdout(標準出力)と stderr(標準エラー)をテストできる❗️関数の実行結果ではなく,その途中に出力するログに着目したい場面もあって便利〜 \( 'ω')/

docs.pytest.org

👾 src/app.py

hello() 関数は HelloWorld! を stdout と stderr に出力して,version() 関数は Python バージョンを stdout に出力する.サンプルコードなので特に意味はないけど今回はこの関数をテスト対象にする💡

import platform
import sys


def hello():
    print('Hello')
    print('World!', file=sys.stderr)


def version():
    print(platform.python_version())

👾 tests/test_app.py

テストコードでは capsys.readouterr() を使って stdout と stderr を取得して簡単に assert できる👌

from app import hello, version


def test_main(capsys):
    hello()
    captured = capsys.readouterr()
    assert captured.out == 'Hello\n'
    assert captured.err == 'World!\n'


def test_version(capsys):
    version()
    captured = capsys.readouterr()
    assert captured.out == '3.12.2\n'

テスト実行

テストできた👌

$ pytest .
===================================================== test session starts =====================================================
(中略)
configfile: pytest.ini
collected 2 items

tests/test_app.py ..                                                                                                    [100%]

====================================================== 2 passed in 0.01s ======================================================

参考資料

capsys「テスト駆動 Python 第2版」の CHAPTER 4 にも載ってた📕

Terraform で無料利用枠の VPC IP Address Manager (IPAM) を設定する

2023年11月から VPC IP Address Manager (IPAM)「無料枠利用枠」が追加されて Public IP Insights などの機能が無料で使えるようになった💡そして,2024年2月から課金対象になった IPv4 の最適化のために Public IP Insights を使いたいという場面もあると思う.

aws.amazon.com

aws.amazon.com

Terraform で試す

実は Terraform AWS Provider では今まで aws_vpc_ipamtier はサポートされていなかった.今日(2024年3月29日)にリリースされた v5.43.0 でついにサポートされた❗️待ってました〜 \( 'ω')/

github.com

👾 ipam.tf

設定自体は簡単で aws_vpc_ipamtier = "free" を追加すれば OK👌デフォルトは advanced なので注意しておくと良さそう.あと今回は operating_regions にバージニア北部リージョンを設定した💡もちろん複数リージョンを設定することもできる.

resource "aws_vpc_ipam" "main" {
  tier = "free"
  operating_regions {
    region_name = "us-east-1"
  }
}

関連記事

AWS CDK で VPC IP Address Manager (IPAM) を設定する記事は過去に書いているので参考まで〜📝

kakakakakku.hatenablog.com

AWS CDK で外部パッケージを含む Python の AWS Lambda 関数をデプロイする

AWS CDK で外部パッケージを含む Python の AWS Lambda 関数をデプロイする場合,requirements.txt から依存関係を解決して,デプロイするアセットとして ZIP にまとめる(バンドルする)必要がある💡

今回は aws-cdk-lib.aws_lambda module@aws-cdk/aws-lambda-python-alpha module を使う方法を試す❗️

前提

今回はサンプルとして requests に依存したコードを以下のディレクトリ構成で置いてある前提とする \( 'ω')/

functions/requests
├── app.py
└── requirements.txt

aws_lambda module を使う

まず,AWS CDK で AWS Lambda 関数をデプロイするときによく使う aws_lambda module では Code.fromAsset のオプションとして BundlingOptions を設定できる.

docs.aws.amazon.com

実装としてはザッとこんな感じになる 👾

Python のコンテナ内で pip install コマンドを実行して /asset-output ディレクトリに依存関係をインストールしたら,後はそのまま ZIP にまとめて(バンドルして)デプロイされる👌

new aws_lambda.Function(this, 'PythonFunction', {
  functionName: 'sandbox-python-function',
  runtime: aws_lambda.Runtime.PYTHON_3_12,
  handler: 'app.lambda_handler',
  code: aws_lambda.Code.fromAsset(path.join(__dirname, '../functions/requests'), {
    bundling: {
      image: aws_lambda.Runtime.PYTHON_3_12.bundlingImage,
      command: [
        'bash', '-c',
        'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output'
      ]
    }
  })
})

aws-lambda-python-alpha module を使う

まだ alpha ではあるけど aws-lambda-python-alpha module を使うと entry に指定したディレクトリにある requirements.txtPipfile から自動的に依存関係を解決してくれる👌

実装としてはザッとこんな感じになる 👾

new aws_lambda_python_alpha.PythonFunction(this, 'PythonFunctionAlpha', {
  functionName: 'sandbox-python-function-alpha',
  runtime: aws_lambda.Runtime.PYTHON_3_12,
  index: 'app.py',
  handler: 'lambda_handler',
  entry: path.join(__dirname, '../functions/requests'),
})

aws_lambda.Function のプロパティもサポートされてるし,Lambda Layer も簡単にデプロイできるし便利〜 \( 'ω')/

docs.aws.amazon.com

デプロイ確認

期待通りに requirements.txt に定義した requests をデプロイできている❗️

JSON Schema で簡単にバリデーションを実装できる Powertools for AWS Lambda (Python) の Validation

Powertools for AWS Lambda (Python)「Validation」を使うと AWS Lambda 関数に渡されたイベント情報のバリデーションを JSON Schema に沿って実現できる.例えば,必須パラメータ・文字数制限・ENUM・正規表現などをチェックできる👌

Powertools for AWS Lambda (Python) 自体は Tracer / Logger / Event Source Data Classes などをよく使うけど,Validation は今まで活用できてなく,試してみたらとても便利だったので,今回試した結果を簡単にまとめておく \( 'ω')/

docs.powertools.aws.dev

検証環境

今回は AWS SAM を使って Amazon API Gateway (REST API) と AWS Lambda 関数を構築する.あくまでサンプルとして Amazon API Gateway の / に POST リクエストを送るとバリデーションロジックを含んだ AWS Lambda 関数が実行されるようにした.また Powertools は Lambda Layer でセットアップする.

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

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: powertools-validation
      CodeUri: src/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      Layers:
        - arn:aws:lambda:ap-northeast-1:017000801446:layer:AWSLambdaPowertoolsPythonV2:67
      Events:
        Api:
          Type: Api
          Properties:
            Path: /
            Method: POST

最終的なディレクトリ構成は以下のようになる💡

├── events
│   └── event.json
├── samconfig.toml
├── src
│   └── app.py
└── template.yaml

👾 app.py

今回はバリデーションを紹介するサンプルとして,特にロジックはなく Amazon API Gateway への POST リクエストに対して 200 OK もしくは 400 BAD_REQUEST を返す実装にした.また AWS Lambda 関数のベストプラクティスを参考に Handler とロジックを分割して main() にまとめてある(実際にはもっと細かく分割しても良さそう).

そして,今回は超簡易的な「TODO アプリ」を例として,title / category / description / link というパラメータを受け取る API のバリデーションを実装した.パラメータごとのバリデーション要件は以下のコードの SCHEMA を見てもらえればと❗️さらに Powertools for AWS Lambda (Python) の Validation ではバリデーションロジックを @validator デコレータを使った実装と validate() 関数を使った実装から選べる.どちらも試してみて,個人的には以下の2つの理由から validate() 関数を使うのが良いと思った💡

  • validate() 関数は Lambda コンテキストに依存してなくてローカル開発がしやすかった
  • 例外発生時のハンドリングなどを柔軟に実装しやすかった

また validate() 関数を呼び出すときに envelope を指定できて,イベントオブジェクトの中からバリデーションする箇所を限定できる.今回は Amazon API Gateway (REST API) から渡されるイベントをバリデーションするため,envelopes.API_GATEWAY_REST を指定した.すると自動的に body がバリデーション対象になる👌現状は8種類の envelope が提供されている \( 'ω')/

  • API_GATEWAY_HTTP
  • API_GATEWAY_REST
  • CLOUDWATCH_EVENTS_SCHEDULED
  • CLOUDWATCH_LOGS
  • EVENTBRIDGE
  • KINESIS_DATA_STREAM
  • SNS
  • SQS
import json
from aws_lambda_powertools.utilities.validation import SchemaValidationError, envelopes, validate
from http import HTTPStatus
from jmespath.exceptions import JMESPathTypeError

SCHEMA = {
    '$schema': 'http://json-schema.org/draft-07/schema',
    'type': 'object',
    'required': ['title', 'category'],
    'properties': {
        'title': {
            'type': 'string',
            'pattern': '^[a-zA-Z0-9_]*$'
        },
        'category': {
            'type': 'string',
            'enum': ['Python', 'Go', 'Java']
        },
        'description': {
            'type': 'string',
            'minLength': 10,
            'maxLength': 100
        },
        'link': {
            'type': 'string',
            'format': 'uri'
        }
    },
}


def main(event):
    try:
        validate(event=event, schema=SCHEMA, envelope=envelopes.API_GATEWAY_REST)
    except JMESPathTypeError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': str(e)})
        }
    except json.JSONDecodeError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': e.msg})
        }
    except SchemaValidationError as e:
        return {
            'statusCode': HTTPStatus.BAD_REQUEST,
            'body': json.dumps({'message': e.validation_message})
        }

    return {
        'statusCode': HTTPStatus.OK,
        'body': json.dumps({'message': 'ok'})
    }


def lambda_handler(event, context):
    return main(event)


if __name__ == '__main__':
    with open('../events/event.json', 'r') as f:
        event = json.load(f)

    main(event)

動作確認

data must contain ['category', 'title'] properties

event.json

{}

必須の titlecategory プロパティがなくバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data must contain ['category', 'title'] properties"}

data.title must match pattern ^[a-zA-Z0-9_]*$

event.json

{
    "title": "Powertoolsを試す",
    "category": "Python"
}

title に日本語を含んでいるためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.title must match pattern ^[a-zA-Z0-9_]*$"}

data.category must be one of ['Python', 'Go', 'Java']

event.json

{
    "title": "Powertools",
    "category": "AWS"
}

category の ENUM 以外の値を指定しているためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.category must be one of ['Python', 'Go', 'Java']"}

data.description must be longer than or equal to 10 characters

event.json

{
    "title": "Powertools",
    "category": "Python",
    "description": ""
}

description が10文字以上になっていないためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.description must be longer than or equal to 10 characters"}

event.json

{
    "title": "Powertools",
    "category": "Python",
    "description": "Try Powertools for AWS Lambda.",
    "link": "docs.powertools.aws.dev"
}

link が URL 形式になっていないためバリデーションエラー🙅‍♂️

$ curl -s -X POST --data @event.json ${ENDPOINT}
{"message": "data.link must be uri"}

まとめ

Powertools for AWS Lambda (Python)「Validation」を使うと AWS Lambda 関数に渡されたイベント情報のバリデーションを柔軟に実装できてとても便利だった❗️

今後は積極的に使っていくぞー \( 'ω')/

関連リンク🔗

github.com