Upgrade to Pro — share decks privately, control downloads, hide ads and more …

あなたのアプリケーションをレガシーコードにしないための実践Pytest入門/pyconjp20...

 あなたのアプリケーションをレガシーコードにしないための実践Pytest入門/pyconjp2024_pytest

PyConJP2024の登壇資料です。
「あなたのアプリケーションをレガシーコードにしないための実践Pytest入門」

More Decks by みずほリサーチ&テクノロジーズ株式会社 先端技術研究部

Other Decks in Technology

Transcript

  1. 目次 レガシーコードの問題点、深刻さ、対策(5min) pytest によるテストパターンの解説(15min) テスト駆動開発( TDD )とリファクタリングのデモ(5min) 持ち帰ってもらいたいこと レガシーコードに対する危機感 pytest

    を活用したテストケース作成やリファクタリングのノウハウ お話ししないこと pytest の機能に対する体系的・網羅的な解説、テスト理論など 1
  2. 自己紹介 藤根(fujine) みずほリサーチ&テクノロジーズ株式会社 先端技術研究部 データ分析・AI、クラウド(AWS、Google Cloud)関連業務に従事 Python歴6年 対外発信実績 PyConJP2021: scikit-learnの新機能を紹介します PyConJP2022:

    Pandas卒業?大規模データを様々なパッケージで高速処理してみる Qiita: fujine@mhrt-adv 最近の出来事 新居を購入(売買契約した2か月後に南海トラフ地震が警報される...) 保育園のクラスで手足口病がたびたび流行(1歳の我が子も流行のたびに感染) 2
  3. py test テストフレームワーク pytest を使ってみよう! pytest の特徴 テストコードが書きやすく、読みやすい 標準の assert

    文を使用する テストモジュールを自動的に探索 fixture 、 monkeypatch 、 tmp_path といったテスト用機能を標準装備 多数の外部プラグインによる拡張性 The pytest framework makes it easy to write small tests, yet scales to support complex functional testing for applications and libraries. (pytest documentationより抜粋) “ “ 8
  4. 環境準備 > python -V Python 3.12.6 >mysqld --version C:\mysql-8.3.0-winx64\bin\mysqld.exe Ver

    8.3.0 for Win64 on x86_64 (MySQL Community Server - GPL) requirements.txt requests==2.32.3 SQLAlchemy==2.0.34 PyMySQL[rsa]==1.1.1 # testing freezegun==1.5.1 pytest==8.3.3 pytest-cov==5.0.0 pytest-mysql==3.0.0 # lint mypy==1.11.2 pylint==3.2.7 10
  5. 初期設定 プロジェクトレイアウト pyproject.toml requirements.txt src - mod1.py - mod2.py ...

    tests mod1 - test_mod1.py mod2 - conftest.py - test_mod2.py ... pyproject.toml [tool.pytest.ini_options] pythonpath = "src" # テスト対象モジュールのパス testpaths = ["tests",] # テストモジュールのパス 11
  6. ①シンプルな関数 """src/simple.py""" def calc_bmi(*, height: float, weight: float) -> float:

    """身長[m]と体重[kg]からBMIを計算する""" if (height <= 0) or (weight <= 0): raise ValueError("Height and weight must be greater than 0.") return weight / (height ** 2) 実行結果が引数のみに依存する関数 正常ケースとエラーケース( ValueError )の両方をテストしたい 12
  7. テストコード """tests/simple/test_simple.py""" import pytest from simple import calc_bmi class TestCalcBMI:

    """simple.calc_bmi関数のテストスイート""" def test_calc_bmi(self, height=2.0, weight=80): """身長と体重から算出したBMI値を検証""" assert calc_bmi(height=height, weight=weight) == 20.0 def test_invalid_height_and_weight(self, height=0, weight=0): """身長と体重が範囲外時にValueErrorが送出されることを検証""" # 例外のタイプのみを検証 with pytest.raises(ValueError): calc_bmi(height=height, weight=weight) # 例外メッセージも含めて検証 with pytest.raises(ValueError) as e: calc_bmi(height=height, weight=weight) assert str(e.value) == "Height and weight must be greater than 0." 13
  8. テスト実行 >pytest tests\simple ================================= test session starts ================================= platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0 collected 2 items tests\simple\test_simple.py .. [100%] ================================== 2 passed in 0.15s ================================== pytest コマンドでテストを実行 pytest : tests 内の全テストケースを実行 pytest tests/simple : tests/simple 内の全テストケースを実行 pytest tests/simple/test_simple.py::TestCalcBMI : TestCalcBMI テストスイートのみを実行 テストモジュール名に続く .. は、2件のテストケースの正常終了を意味する 14
  9. ②多数の分岐条件を持つケース """src/exemption.py""" def calc_exemption_amount(*, income: int) -> int: """所得額(income)から給与所得控除額を算出する""" if

    income < 0: raise ValueError("Income must be positive.") if income <= 1_625_000: return 550_000 if 1_625_000 < income <= 1_800_000: return int(income * 0.4) - 100_000 if 1_800_000 < income <= 3_600_000: return int(income * 0.3) + 800_000 if 3_600_000 < income <= 6_600_000: return int(income * 0.2) + 440_000 if 6_600_000 < income <= 8_500_000: return int(income * 0.1) + 1_100_000 return 1_950_000 if や match の分岐条件を網羅しつつ、境界値もテストしたい 15
  10. テストコード """tests/exemption/test_exemption.py""" import pytest from exemption import calc_exemption_amount class TestCalcExemptionAmount:

    """exemption.calc_exemption_amount関数のテストスイート""" @pytest.mark.parametrize( # 同一のテスト関数を複数のテストパラメータでテスト ("income", "expected"), # テストパラメータ名(テスト関数の引数名と一致) [ (1_625_000, 550_000), # 162.5万円以下 (1_625_003, 550_001), # 162.5万円超かつ180万円以下 (1_800_000, 620_000), # 同上 (1_800_001, 1_340_000), # 180万円超かつ360万円以下 (3_600_000, 1_880_000), # 同上 (3_600_001, 1_160_000), # 360万円超かつ660万円以下 (6_600_000, 1_760_000), # 同上 (6_600_010, 1_760_001), # 660万円超かつ850万円以下 (8_500_000, 1_950_000), # 同上 (8_500_001, 1_950_000), # 850万円超 ] ) def test_income_and_exemption(self, income, expected): """所得額に応じた給与所得控除額の算出結果を検証""" assert calc_exemption_amount(income=income) == expected 16
  11. テスト実行 >pytest tests\exemption ================================== test session starts =================================== platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0 collected 11 items tests\test_exemption.py ........... [100%] =================================== 11 passed in 0.17s =================================== テストパラメータ数と同数の . が出力される @pytest.mark.parametrize はテストが途中で失敗しても最後のパラメータまで実行してくれる 17
  12. ③OS環境変数に依存しているケース """src/env.py""" import os # 環境変数API_URLに、本番環境用APIのURLを設定 os.environ["API_URL"] = "https://production.example.com" def

    get_api_url() -> str | None: """環境変数API_URLの設定値(未設定ならNone)を返す""" return os.getenv("API_URL") 外部システムの接続情報などを環境変数から取得する os.environ の取得値をテスト時のみ上書きしたい 19
  13. テストコード """tests/env/conftest.py""" import pytest @pytest.fixture def mock_env_api_url(monkeypatch): """環境変数API_URLをモック化""" monkeypatch.setenv("API_URL", "http://localhost:8080")

    @pytest.fixture で、テストケース実行に必要な前処理・後処理用の関数を定義 スコープは、 function (デフォルト)、 class 、 module 、 package 、 session から選択可能 組み込みフィクスチャ monkeypatch の setenv にて、テスト時のみ有効な環境変数を設定 その他、属性や辞書要素の設定・削除、 sys.path へのテスト用パス追加なども可能 フィクスチャは conftest.py に集約する テストケースとそれ以外のコードを分けて管理すると、テストモジュール全体の見通しが良くなる 20
  14. テストコード(続き) """tests/env/test_env.py""" from env import get_api_url class TestGetAPIURL: """env.get_api_url関数のテストスイート""" def

    test_get_api_url(self, mock_env_api_url): """モック化した環境変数API_URLの取得結果を検証""" assert get_api_url() == "http://localhost:8080" conftest.py は自動的にインポートされる フィクスチャを有効化するには、テスト関数の引数にフィクスチャ関数名を入れるだけ 21
  15. テスト実行 >pytest --setup-show tests\env ============================================= test session starts ============================================== platform

    win32 -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0 collected 1 item tests\env\test_env.py SETUP F monkeypatch SETUP F mock_env_api_url (fixtures used: monkeypatch) tests/env/test_env.py::TestGetAPIURL::test_get_api_url (fixtures used: mock_env_api_url, monkeypatch). TEARDOWN F mock_env_api_url TEARDOWN F monkeypatch ============================================== 1 passed in 0.35s =============================================== --setup-show オプションで、実行されたフィクスチャの名前とタイミングが出力される 22
  16. ④システム日時に依存するケース """src/hours.py""" from datetime import datetime, time def is_in_business() ->

    bool: """システム時刻が営業時間中(土日を除く9〜17時)であればTrue、それ以外はFalseを返す""" now = datetime.now() # 5は土曜日、6は日曜日 if now.weekday() in (5, 6): return False if time(9, 0, 0) <= now.time() <= time(17, 0, 0): return True return False 現在時刻に依存した関数 任意の時刻でテストできるようにしたい 23
  17. テストコード """tests/hours/test_hours.py""" from freezegun import freeze_time import pytest from hours

    import is_in_business class TestIsInBusiness: """hours.is_in_business関数のテストスイート""" @pytest.mark.parametrize( ("now", "expected"), [ ("2024-09-27 08:59:59", False), # 金曜日9時前 ("2024-09-27 09:00:00", True), # 金曜日9時 ("2024-09-27 17:00:00", True), # 金曜日17時 ("2024-09-27 17:00:01", False), # 金曜日17時過ぎ ("2024-09-28 12:00:00", False), # 土曜日 ("2024-09-29 12:00:00", False), # 日曜日 ] ) def test_is_in_business(self, now, expected): """時刻と曜日の組合せによる営業時間内の判定結果を検証""" # freezegun.freeze_time()で、withブロック内のシステム時刻をテスト時刻に設定 with freeze_time(now): assert is_in_business() == expected 24
  18. ⑤ファイル入出力のケース """src/fileio.py""" from pathlib import Path import re def cat_to_dog(*,

    input_path: Path, output_path: Path) -> None: """input_pathのテキストを読み込み、文字列に含まれる"猫"を"犬"に置換して、output_pathに書き込む""" input_text = input_path.read_text() output_text = re.sub("猫", "犬", input_text) output_path.write_text(output_text) テスト開始時にテストデータファイルを作成し、テスト終了後に削除したい 25
  19. テストコード """tests/fileio/test_fileio.py""" from fileio import cat_to_dog class TestCatToDog: """fileio.cat_to_dog関数のテストスイート""" def

    test_normal(self, tmp_path): """出力ファイルにて`猫`が`犬`に置換されることを検証""" input_path = tmp_path / "input.txt" output_path = tmp_path / "output.txt" input_path.write_text("吾輩は猫である。名前はまだない。") cat_to_dog(input_path=input_path, output_path=output_path) assert output_path.read_text() == "吾輩は犬である。名前はまだない。" 組み込みフィクスチャ tmp_path でテスト用一時ディレクトリを作成( function スコープ) 複数のテストケースで共有したい場合は temp_path_factory ( session スコープ)を使用 26
  20. ⑥外部APIに依存したケース """src/api.py""" import requests ENDPOINT = "http://zipcloud.ibsnet.co.jp/api/search" # 郵便番号から住所情報を検索するAPI def

    get_address(*, zipcode: str) -> str | None: """郵便番号から住所情報を検索する""" response = requests.get(ENDPOINT, params={"zipcode": zipcode}, timeout=5) response.raise_for_status() data = response.json() # エラーメッセージがある場合はValueErrorで送出 if (message := data["message"]) is not None: raise ValueError(message) # 郵便番号が該当なしの場合はNoneを返す if (results := data["results"]) is None: return None return f"{results[0]["address1"]} {results[0]["address2"]} {results[0]["address3"]}" 27
  21. テストコード """tests/api/mockresponse.py""" from dataclasses import dataclass ResultsType = list[dict[str, str]]

    | None @dataclass class MockResponse: """HTTPレスポンスボディのモック""" message: str | None = None results: ResultsType = None def raise_for_status(self) -> None: return None def json(self) -> dict[str, str | ResultsType]: return {"message": self.message, "results": self.results} 28
  22. テストコード(続き) """tests/api/conftest.py""" import re import pytest import requests from mockresponse

    import MockResponse @pytest.fixture def mock_response(monkeypatch) -> None: """レスポンスデータをモック化""" def mock_get(*args, **kwargs) -> MockResponse: """requests.getのzipcode引数に応じて、MockResponseを応答""" zipcode = kwargs["params"]["zipcode"] if zipcode == "0000000": return MockResponse() elif re.match("^[0-9]{7}$", zipcode): return MockResponse(results=[{"address1": "都道府県", "address2": "市区町村", "address3": "番地"}]) else: return MockResponse(message="郵便番号の桁数や値が不正です。") monkeypatch.setattr(requests, "get", mock_get) 29
  23. テストコード(続き) """tests/api/test_api.py""" import pytest from api import get_address class TestGetAddress:

    """api.get_addressモジュールのテストクラス""" @pytest.mark.parametrize( ("zipcode", "expected"), [ ("0000000", None), ("1111111", "都道府県 市区町村 番地") ] ) def test_get_address(self, mock_response, zipcode, expected): """郵便番号が0000000(テスト用)ならNone、それ以外は固定住所を返すことを検証""" assert get_address(zipcode=zipcode) == expected @pytest.mark.parametrize("zipcode", ["1", "12345678", "dummy"]) def test_invalid_zipcode(self, mock_response, zipcode): """郵便番号が不正な場合、ValueErrorが送出されることを検証""" with pytest.raises(ValueError) as e: get_address(zipcode=zipcode) assert str(e.value) == "郵便番号の桁数や値が不正です。" 30
  24. テスト実行 >pytest tests\api ======================================= test session starts ======================================== platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0 collected 5 items tests\api\test_api.py ..... [100%] ======================================== 5 passed in 0.16s ========================================= 31
  25. (補足)APIレスポンスデータが大量or複雑な場合 """tests/api/conftest.pyより抜粋""" def mock_get(*args, **kwargs) -> MockResponse: """requests.getの引数に応じてモックレスポンスを変更""" zipcode =

    kwargs["params"]["zipcode"] if zipcode == "0000000": - return MockResponse() + return_file = "tests/data/no-contents.json" elif re.match("^[0-9]{7}$", zipcode): - return MockResponse(results=[{"address1": "都道府県", "address2": "市区町村", "address3": "番地"}]) + return_file = "tests/data/contents.json" else: - return MockResponse(message="郵便番号の桁数や値が不正です。") + return_file = "tests/data/validation-error.json" + with open(return_file, encoding="utf-8") as f: + return MockResponse(**json.load(f)) 事前にテスト用レスポンスデータをファイル保存しておくと、テストコードがスッキリする 32
  26. ⑦DB接続するケース 本番DBに影響を与えず、開発用/テスト用DBで高速にテストしたい 各テストケースを独立で実行したい(挿入テスト⇒参照テスト、のような順序制約を排除) """src/model.py""" from datetime import date from sqlalchemy

    import String from sqlalchemy.orm import declarative_base, Mapped, mapped_column Base = declarative_base() class User(Base): """Userテーブルスキーマ""" __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(12), nullable=False) birthday: Mapped[date] = mapped_column(nullable=False) def __eq__(self, other) -> bool: return (self.id == other.id) and (self.name == other.name) and (self.birthday == other.birthday) 33
  27. DB接続するケース(続き) """src/db.py""" import os from sqlalchemy import create_engine from sqlalchemy.engine

    import URL from sqlalchemy.orm import scoped_session, sessionmaker from model import Base # 説明の便宜上、DB接続情報を環境変数から取得 # (セキュアな実装ではないため、実際のプロジェクトでは各種シークレット管理サービスを使用願います) DATABASE_CONFIG = {"drivername": "mysql+pymysql", "username": os.environ["MYSQL_USER"], "password": os.environ["MYSQL_PASSWORD"], "host": os.environ["MYSQL_HOST"], "port": os.environ["MYSQL_PORT"], "database": os.environ["MYSQL_DATABASE"], "query": {"charset": "utf8"}} # スキーマ定義を元にテーブルを作成 engine = create_engine(URL.create(**DATABASE_CONFIG), echo=False) Base.metadata.create_all(engine) # DBセッションを確立 Session = scoped_session(sessionmaker(engine)) 34
  28. DB接続するケース(続き) """src/crud.py""" from sqlalchemy import insert, select from sqlalchemy.orm.scoping import

    scoped_session from model import User def get_user(db_session: scoped_session, user_id: int) -> User | None: """user_idをキーに、Userレコードをuserテーブルから検索する""" stmt = select(User).where(User.id == user_id) return db_session.scalar(stmt) def add_user(db_session: scoped_session, user: User) -> int: """userテーブルに単一のUserレコードを挿入し、PK(id)を返す""" with db_session() as session: session.add(user) session.commit() return user.id 35
  29. テストコード """tests/db/conftest.py""" import os from operator import itemgetter import pytest

    from pytest_mysql import factories from sqlalchemy import create_engine from sqlalchemy.engine import URL from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.pool import NullPool from model import Base # 環境変数からDB情報を取得 host, port, user, passwd = itemgetter("MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD")(os.environ) # MySQLテスト用データベースの接続用フィクスチャ。mysql_fixtureは関数スコープのため、テストケース実行の度にデータベースが作成・削除される mysql_noproc = factories.mysql_noproc(host=host, port=port, user=user) mysql_fixture = factories.mysql("mysql_noproc", passwd=passwd, dbname="test") @pytest.fixture def test_session(mysql_fixture): """テスト用のデータベースを作成し、DBセッションを確立""" url = URL.create(drivername="mysql+pymysql", username=user, password=passwd, host=host, port=port, database="test", query={"charset": "utf8"}) engine = create_engine(url, echo=False, poolclass=NullPool) Base.metadata.create_all(engine) session = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine)) try: yield session except Exception: session.rollback() else: session.commit() finally: session.close() Base.metadata.drop_all(engine) 36
  30. テストコード(続き) """tests/database/test_get_user.py""" from datetime import date from crud import get_user

    from model import User class TestGetUser: """crud.get_user関数のテストスイート""" def test_get_user(self, test_session): """idに一致するUserレコードが取得されることを検証""" user = User(name="sato", birthday=date(1999, 12, 31)) with test_session() as session: session.add(user) session.commit() user_id = user.id user_ = get_user(db_session=test_session, user_id=user_id) assert user == user_ def test_no_user(self, test_session): """idに一致するUserレコードが存在しない場合にNoneが返ることを検証""" assert get_user(db_session=test_session, user_id=1) is None 37
  31. テストコード(続き) """tests/database/test_add_user.py""" from datetime import date import pymysql import sqlalchemy

    from sqlalchemy import select from crud import add_user from model import User class TestAddUser: """crud.add_user関数のテストスイート""" def test_add_user(self, test_session): """新規Userレコードが正常に挿入されることを検証""" user = User(name="sato", birthday=date(1999, 12, 31)) user_id = add_user(db_session=test_session, user=user) # 挿入レコードのidで検索し、挿入レコードと検索結果が一致することを検証 assert user == test_session.scalar(select(User).where(User.id == user_id)) def test_duplicate_pk(self, test_session): """PKが同じUserレコードを複数挿入時にIntegrityErrorが発生することを検証""" try: for _ in range(2): add_user(db_session=test_session, user=User(id=1, name="sato", birthday=date(1999, 12, 31))) except sqlalchemy.exc.IntegrityError as e: # MySQLのオリジナルエラー(e.orig)を取得し、例外クラスとエラーコード(1062:主キー重複)が一致することを検証 assert isinstance(e.orig, pymysql.err.IntegrityError) assert e.orig.args[0] == 1062 38
  32. pytest を更に活用するためのプラグイン集 1300件以上のプラグインが公式で紹介 プラグイン名 機能 pytest-cov C1/C2カバレッジを取得、HTMLレポート出力 pytest-xdist テストケースを並列実行 pytest-asyncio

    非同期関数をテスト pytest-timeout テストケースにタイムアウト時限を設定 pytest-randomly テストケースをランダムに実行(順序依存したテストケース発見に利用) pytest-mock unittest.mock のラッパー pytest-clarity 期待値とテスト結果の差異を分かりやすく表示 pytest-suger テストの進捗を、ドットの代わりにプログレスバーで表示 pytest-playwright Playwright によるE2Eテスト実行 39
  33. テスト駆動開発( TDD )とは ゴール 動作するきれいなコード テストファーストな開発スタイル 1. 自動化されたテストが失敗したときのみ、新しいコードを書く 2. 重複を排除する

    プログラミングにおける作業順序サイクル 1. レッド: 動作しないテストを1つ書く 2. グリーン: そのテストを迅速に動作させる(コードは汚くても良い) 3. リファクタリング: テストを通すために発生した重複を全て削除する (「テスト駆動開発」より抜粋) 41
  34. サンプルプログラムの要件 映画館の入場料金を計算する 基本料金 20歳以上 : 2,000円 20歳未満 : 1,500円 割引料金

    毎年12月1日(映画の日): 1,000円 毎月1日: 1,300円 毎週水曜日: 1,600円 計算方法 割引料金が適用可能な場合は、基本料金と比較して安い方の料金を適用する。 割引条件が複数重なった場合は、最も安い割引料金を適用する。 プログラムへの引数 age : 入場者の年齢( int ) playdate : 上映日( datetime.date ) 42
  35. 最初にテストから書く """tests/test_ticket.py""" from datetime import date import pytest from ticket

    import get_ticket_price class TestTicket: """ticket.get_ticket_priceモジュールのテストスイート""" @pytest.mark.parametrize( ("age", "playdate", "expected"), [ (19, date(2024, 9, 27), 1500), # 基本料金の境界値 (20, date(2024, 9, 27), 2000), # 基本料金の境界値 (19, date(2024, 10, 1), 1300), # 1日 (20, date(2024, 10, 1), 1300), # 1日 (19, date(2024, 10, 2), 1500), # 水曜日 (20, date(2024, 10, 2), 1600), # 水曜日 (19, date(2024, 12, 1), 1000), # 12月1日 (20, date(2024, 12, 1), 1000), # 12月1日 (19, date(2025, 1, 1), 1300), # 1日かつ水曜日 (20, date(2025, 1, 1), 1300), # 1日かつ水曜日 (19, date(2027, 12, 1), 1000), # 12月1日かつ水曜日 (20, date(2027, 12, 1), 1000), # 12月1日かつ水曜日 ] ) def test_ticket_price(self, age, playdate, expected): """年齢と日付の組合せによる入場料の算出結果を検証""" assert get_ticket_price(age=age, playdate=playdate) == expected 43
  36. 最小限のプログラムを書く """src/ticket.py""" from datetime import date def get_ticket_price(*, age: int,

    playdate: date) -> int: """年齢(age)と上映日(playdate)から、映画館の入場料を算出する。""" return 0 44
  37. テストが失敗(レッド)することを確認 >pytest --tb=no tests\ticket\test_ticket.py ========================================== test session starts =========================================== platform

    win32 -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0, mysql-3.0.0 collected 12 items tests\ticket\test_ticket.py FFFFFFFFFFFF [100%] ======================================== short test summary info ========================================= FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate0-1500] - assert 0 == 1500 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate1-2000] - assert 0 == 2000 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate2-1300] - assert 0 == 1300 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate3-1300] - assert 0 == 1300 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate4-1500] - assert 0 == 1500 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate5-1600] - assert 0 == 1600 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate6-1000] - assert 0 == 1000 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate7-1000] - assert 0 == 1000 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate8-1300] - assert 0 == 1300 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate9-1300] - assert 0 == 1300 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[19-playdate10-1000] - assert 0 == 1000 FAILED tests/ticket/test_ticket.py::TestTicket::test_ticket_price[20-playdate11-1000] - assert 0 == 1000 =========================================== 12 failed in 0.36s =========================================== 45
  38. 「動作するプログラム」を書く """src/ticket.py""" from datetime import date def get_ticket_price(*, age: int,

    playdate: date) -> int: """年齢(age)と上映日(playdate)から、映画館の入場料を算出する。""" if playdate.day == 1: if playdate.month == 12: return 1000 else: return 1300 elif playdate.weekday() == 2: if age >= 20: return 1600 else: return 1500 elif age >= 20: return 2000 else: return 1500 46
  39. テストを全てグリーンにする >pytest tests\ticket\test_ticket.py ======================================== test session starts =================================== platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0, mysql-3.0.0 collected 12 items tests\ticket\test_ticket.py ............ [100%] ========================================= 12 passed in 0.39s =================================== 「動作する」ことが確認できたので、安心してリファクタリングに着手できる 47
  40. プログラムをリファクタリング """src/ticket2.py""" from datetime import date def _get_base_price(*, age: int)

    -> int: """年齢から基本料金を算出する""" return 2000 if age >= 20 else 1500 def _get_discounted_price(*, playdate: date) -> int | None: """上映日の月日と曜日から割引料金を算出する""" match (playdate.month, playdate.day, playdate.weekday()): case (12, 1, _): return 1000 case (_, 1, _): return 1300 case (_, _, 2): return 1600 case _: return None def get_ticket_price(*, age: int, playdate: date) -> int: """年齢と上映日から入場料を算出する。""" base_price = _get_base_price(age=age) discounted_price = _get_discounted_price(playdate=playdate) or base_price return min(base_price, discounted_price) 48
  41. テストは全てグリーン >pytest tests\ticket\test_ticket2.py ====================================== test session starts ====================================== platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0, mysql-3.0.0 collected 12 items tests\ticket\test_ticket2.py ............ [100%] ====================================== 12 passed in 0.48s ======================================= 「(意図せずに)何か壊したものは無い」と自信が持てる 更にリファクタリングを進められる 49
  42. マジックナンバーを排除してクラス化 """src/ticket3.py""" from dataclasses import dataclass from datetime import date

    from typing import ClassVar @dataclass(frozen=True) class TicketPrice: """映画館の入場料算出クラス""" age: int # 年齢 playdate: date # 上映日 BASE_ADULT: ClassVar[int] = 2000 # 基本料金(大人) BASE_YOUNGER: ClassVar[int] = 1500 # 基本料金(子供) DISCOUNT_MOVIE_DAY: ClassVar[int] = 1000 # 割引料金(12月1日) DISCOUNT_FIRST_DAY: ClassVar[int] = 1300 # 割引料金(毎月1日) DISCOUNT_WEDNESDAY: ClassVar[int] = 1600 # 割引料金(毎週水曜日) def _get_base_price(self) -> int: """年齢から基本料金を算出する""" return self.BASE_ADULT if self.age >= 20 else self.BASE_YOUNGER def _get_discounted_price(self) -> int | None: """上映日の月日と曜日から割引料金を算出する""" match (self.playdate.month, self.playdate.day, self.playdate.weekday()): case (12, 1, _): return self.DISCOUNT_MOVIE_DAY case (_, 1, _): return self.DISCOUNT_FIRST_DAY case (_, _, 2): return self.DISCOUNT_WEDNESDAY case _: return None def get_price(self) -> int: """年齢と上映日から入場料を算出する""" base_price = self._get_base_price() discounted_price = self._get_discounted_price() or base_price return min(base_price, discounted_price) 50
  43. テストコードも一部変更(テストパラメータはそのまま) """tests/test_ticket3.py""" from datetime import date import pytest from ticket3

    import TicketPrice class TestTicketPrice: """ticket.get_ticket_priceモジュールのテストスイート""" @pytest.mark.parametrize( ("age", "playdate", "expected"), [ (19, date(2024, 9, 27), 1500), # 基本料金の境界値 (20, date(2024, 9, 27), 2000), # 基本料金の境界値 (19, date(2024, 10, 1), 1300), # 1日 (20, date(2024, 10, 1), 1300), # 1日 (19, date(2024, 10, 2), 1500), # 水曜日 (20, date(2024, 10, 2), 1600), # 水曜日 (19, date(2024, 12, 1), 1000), # 12月1日 (20, date(2024, 12, 1), 1000), # 12月1日 (19, date(2025, 1, 1), 1300), # 1日かつ水曜日 (20, date(2025, 1, 1), 1300), # 1日かつ水曜日 (19, date(2027, 12, 1), 1000), # 12月1日かつ水曜日 (20, date(2027, 12, 1), 1000), # 12月1日かつ水曜日 ] ) def test_ticket_price(self, age, playdate, expected): """年齢と日付の組合せによる入場料の算出結果を検証""" assert TicketPrice(age=age, playdate=playdate).get_price() == expected 51
  44. リファクタリング完了! >pytest tests\ticket\test_ticket3.py ====================================== test session starts ====================================== platform win32

    -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0 configfile: pyproject.toml plugins: cov-5.0.0, mysql-3.0.0 collected 12 items tests\ticket\test_ticket3.py ............ [100%] ====================================== 12 passed in 0.49s ======================================= 「動作するプログラム」を維持しながら、安全かつ短時間で「綺麗なプログラム」にリファクタリング できた 52