Slide 1

Slide 1 text

あなたのアプリケーションをレガシーコード にしないための実践Pytest入門 技術開発本部 先端技術研究部 藤根 成暢

Slide 2

Slide 2 text

目次 レガシーコードの問題点、深刻さ、対策(5min) pytest によるテストパターンの解説(15min) テスト駆動開発( TDD )とリファクタリングのデモ(5min) 持ち帰ってもらいたいこと レガシーコードに対する危機感 pytest を活用したテストケース作成やリファクタリングのノウハウ お話ししないこと pytest の機能に対する体系的・網羅的な解説、テスト理論など 1

Slide 3

Slide 3 text

自己紹介 藤根(fujine) みずほリサーチ&テクノロジーズ株式会社 先端技術研究部 データ分析・AI、クラウド(AWS、Google Cloud)関連業務に従事 Python歴6年 対外発信実績 PyConJP2021: scikit-learnの新機能を紹介します PyConJP2022: Pandas卒業?大規模データを様々なパッケージで高速処理してみる Qiita: fujine@mhrt-adv 最近の出来事 新居を購入(売買契約した2か月後に南海トラフ地震が警報される...) 保育園のクラスで手足口病がたびたび流行(1歳の我が子も流行のたびに感染) 2

Slide 4

Slide 4 text

参考資料 レガシーコード改善ガイド レガシーコードからの脱却 テスト駆動開発 テスト駆動Python 3

Slide 5

Slide 5 text

レガシーコードについて 4

Slide 6

Slide 6 text

レガシーコードとは何か? ※ここでは、「テスト≒実行が速く、問題箇所を特定しやすいテスト」のニュアンス 一番シンプルに説明するなら、レガシーコードとは理由は何であれ、修正、拡張、作 業が非常に難しいコードのことだ。 (「レガシーコードからの脱却」より抜粋) “ “ 私にとってレガシーコードとは、単にテストのないコードです。(中略)テストのない コードは悪いコードである。どれだけうまく書かれているかは関係ない。(中略)テス トがあれば、検証しながらコードの動きを素早く変更することができる。テストがなけ れば、コードが良くなっているのか悪くなっているのかが本当には分からない。 (「レガシーコード改善ガイド」より抜粋) “ “ 5

Slide 7

Slide 7 text

レガシーコードの何が問題なのか? 機能の追加・変更やバグ修正が難しくなる 既存機能の振る舞いが変化していないことの検証(リグレッションテスト)に時間がかかる 保守工数が膨らみ続け、リリースサイクルが長期化するか、最悪システムを塩漬け レガシーシステムは国家規模の損失 経済産業省は「2025年以降の技術的負債による経済損失は毎年12兆円以上」と試算 一度レガシーコードとなったシステムを改善することは困難 コードを変更するためには、テストを整備する必要がある。多くの場合、テストを整備する ためにはコードを変更する必要がある。 (「レガシーコード改善ガイド」より抜粋) “ “ 6

Slide 8

Slide 8 text

どうすれば良いのか? 変更可能なコードを最初から書く コードを変更可能に保つために、テストコードを書く 単体テストを重点的に行う なぜ単体テストなのか? 実行時間が短いため、何度でも実行できる すぐにフィードバックが得られるため、安全にリファクタリングできる テストとテスト対象が近いため、エラー箇所を特定しやすい (大規模なシステムテストも重要だが、エラー原因の特定や実行時 間が長時間化しがちで、頻繁なテスト実行は困難。) 7

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

pytest による7つのテストパターン解説 9

Slide 11

Slide 11 text

環境準備 > 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

Slide 12

Slide 12 text

初期設定 プロジェクトレイアウト 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

Slide 13

Slide 13 text

①シンプルな関数 """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

Slide 14

Slide 14 text

テストコード """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

Slide 15

Slide 15 text

テスト実行 >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

Slide 16

Slide 16 text

②多数の分岐条件を持つケース """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

Slide 17

Slide 17 text

テストコード """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

Slide 18

Slide 18 text

テスト実行 >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

Slide 19

Slide 19 text

カバレッジを取得 >pytest tests\exemption --cov --cov-branch --cov-report=html htmlcov\index.html にカバレッジレポートが出力される 18

Slide 20

Slide 20 text

③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

Slide 21

Slide 21 text

テストコード """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

Slide 22

Slide 22 text

テストコード(続き) """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

Slide 23

Slide 23 text

テスト実行 >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

Slide 24

Slide 24 text

④システム日時に依存するケース """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

Slide 25

Slide 25 text

テストコード """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

Slide 26

Slide 26 text

⑤ファイル入出力のケース """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

Slide 27

Slide 27 text

テストコード """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

Slide 28

Slide 28 text

⑥外部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

Slide 29

Slide 29 text

テストコード """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

Slide 30

Slide 30 text

テストコード(続き) """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

Slide 31

Slide 31 text

テストコード(続き) """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

Slide 32

Slide 32 text

テスト実行 >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

Slide 33

Slide 33 text

(補足)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

Slide 34

Slide 34 text

⑦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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

テストコード """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

Slide 38

Slide 38 text

テストコード(続き) """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

Slide 39

Slide 39 text

テストコード(続き) """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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

テスト駆動開発とリファクタリングのデモ 40

Slide 42

Slide 42 text

テスト駆動開発( TDD )とは ゴール 動作するきれいなコード テストファーストな開発スタイル 1. 自動化されたテストが失敗したときのみ、新しいコードを書く 2. 重複を排除する プログラミングにおける作業順序サイクル 1. レッド: 動作しないテストを1つ書く 2. グリーン: そのテストを迅速に動作させる(コードは汚くても良い) 3. リファクタリング: テストを通すために発生した重複を全て削除する (「テスト駆動開発」より抜粋) 41

Slide 43

Slide 43 text

サンプルプログラムの要件 映画館の入場料金を計算する 基本料金 20歳以上 : 2,000円 20歳未満 : 1,500円 割引料金 毎年12月1日(映画の日): 1,000円 毎月1日: 1,300円 毎週水曜日: 1,600円 計算方法 割引料金が適用可能な場合は、基本料金と比較して安い方の料金を適用する。 割引条件が複数重なった場合は、最も安い割引料金を適用する。 プログラムへの引数 age : 入場者の年齢( int ) playdate : 上映日( datetime.date ) 42

Slide 44

Slide 44 text

最初にテストから書く """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

Slide 45

Slide 45 text

最小限のプログラムを書く """src/ticket.py""" from datetime import date def get_ticket_price(*, age: int, playdate: date) -> int: """年齢(age)と上映日(playdate)から、映画館の入場料を算出する。""" return 0 44

Slide 46

Slide 46 text

テストが失敗(レッド)することを確認 >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

Slide 47

Slide 47 text

「動作するプログラム」を書く """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

Slide 48

Slide 48 text

テストを全てグリーンにする >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

Slide 49

Slide 49 text

プログラムをリファクタリング """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

Slide 50

Slide 50 text

テストは全てグリーン >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

Slide 51

Slide 51 text

マジックナンバーを排除してクラス化 """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

Slide 52

Slide 52 text

テストコードも一部変更(テストパラメータはそのまま) """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

Slide 53

Slide 53 text

リファクタリング完了! >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

Slide 54

Slide 54 text

まとめ レガシーコード(=テストが無いコード)は機能変更・追加が難しく、技術的負債となりやすい pytest では、様々なパターンに応じた単体テストケースを作成・実行可能 テスト駆動開発を採用してテストとリファクタリングを同時に行うことで、テストが容易なコードが 量産され、結果的に開発スピード向上に貢献 53