【第11回】pytest入門:CoffeeLog / FishingLog がちゃんと動いているかテストする

Python入門

第10回までで、

  • CoffeeLog / FishingLog クラスを作り
  • メソッドで「お気に入り判定」「50アップ判定」「コメント生成」などをできるようにし
  • to_dict() / from_dict() でCSVとの橋渡しも覚えました。

ここまで来ると、ふとこんな不安がよぎります。

  • 「ちょっとコードを直したら、前に動いていたところが壊れてないかな?」
  • 「スコア4.5のとき、本当にあのコメントが返ってきてる?」
  • 「50アップ判定、本当に50以上だけTrueになってる?」

毎回、手で試してもいいけれど、

“いつでも一発で” 動作確認できる小さな安心装置

があると、ツールを育てるのがずっと楽になります。

今回のテーマは、

pytestというテストツールを使って、
CoffeeLog / FishingLog がちゃんと動いているか自動チェックできるようにすること。

ついでに、pytestを入れるタイミングで

  • 仮想環境(venv)
  • requirements.txt

という “大人のPythonの育て方” にも軽く触れておきます。


この回のゴール

第11回で目指すゴールは次の4つです。

  1. pytestが何をしてくれるツールなのかイメージできる
  2. test_*.pyassert を書いて、テストを実行できる
  3. CoffeeLog / FishingLog のメソッドや to_dict() / from_dict() をテストできる
  4. pytestを入れるための 簡単なvenv+requirements.txtの使い方の雰囲気 をつかむ

細かい理屈よりも、

  • pytest コマンドを打つと、赤か緑かで教えてくれる」

こういう“安心スイッチ”が1つ増えることを感じてもらえたら十分です。


pytestを使うためのシンプル環境づくり(venv+requirements)

pytest を使う為の事前準備をします。
pytest モジュールは Python とは別にインストールする必要があります。
そこで、Python 製の仮想環境(venv)を構築して、そこに pytest をインストールする流れになります。
これをすると、今何のモジュールがインストールされているか?など管理することが容易になってきます。

仮想環境(venv)とは?

仮想環境(venv)は、

「このフォルダ専用のPython環境(ライブラリの箱)」

のようなものです。

  • 別のプロジェクトとライブラリのバージョンが混ざらない
  • 必要なくなったら、そのフォルダごと消せばきれいに片づく

というメリットがあります。

venv — Creation of virtual environments
Source code: Lib/venv/ The venv module supports creating lightweight “virtual environments”, each with their own indepen...

1.プロジェクト用フォルダに移動する

    例として、これまでのコードを置いているフォルダが

    celestial-python/

    とします。ターミナルで:

    cd celestial-python

    2.venv を作る

    python -m venv .venv

    .venv というフォルダに、専用のPython環境が作られます。

    3.venv を有効化する(macOS / Linux)

    Macを使っているなら、まずこれをメインで書いてOK:

    source .venv/bin/activate

    ターミナルの先頭に (.venv) などと表示されればOKです。

    ※Windowsを使っているなら:

    .venv\Scripts\activate

    4.pytest をインストールする

    仮想環境が有効な状態で:

    pip install pytest

    これで、このプロジェクト内で pytest コマンドが使えるようになります。

    Get Started - pytest documentation

    5.requirements.txt に書き出しておく(任意だけどおすすめ)

    pip freeze > requirements.txt

    requirements.txt を開くと、pytest==... などが書かれているはずです。

    別の環境で同じセットを用意したいときは:

    pip install -r requirements.txt

    とするだけで、同じバージョンのライブラリをインストールできます。

    💡 まとめ

    • 「pytestを入れるあたりから、余力があれば venv+requirements に慣れていくのがおすすめ」
    • 「venvを飛ばして pip install pytest だけでもOK」

    pytestとは?— assert が全部まとめてチェックされる

    pytestは、

    assert を書いた関数たちを、まとめて自動で実行してくれるツール」

    です。

    • test_*.py というファイルを用意し
    • 中に test_〜 という関数を書き
    • その中で assert で「こうなっていてほしい」を書く

    たったこれだけで、pytest コマンドから一斉にチェックできます。

    pytest documentation

    最小のサンプル:足し算関数をテストする

    math_utils.py を作る

    # math_utils.py
    
    def add(a, b):
        return a + b

    test_math_utils.py を作る

    # test_math_utils.py
    
    from math_utils import add
    
    
    def test_add_simple():
        assert add(2, 3) == 5
    
    
    def test_add_zero():
        assert add(0, 10) == 10

    pytestを実行する

    ターミナルで(venvが有効な状態で):

    pytest

    すると、こんな感じの出力が出ます:

    ============================= test session starts =============================
    collected 2 items
    
    test_math_utils.py ..                                                   [100%]
    
    ============================== 2 passed in 0.03s ==============================
    • . が1つ=テスト1個成功
    • 2 passed =2つのテストが全部通った

    この感覚を一度味わっておくと、
    「CoffeeLog / FishingLog の動きも、同じようにチェックしたくなる」はず。


    前提:CoffeeLog クラス(第10回のもの)

    CoffeeLog を pytest でテストしてみる

    ファイル:models.py に CoffeeLog があるとします(第10回のものから少し抜粋)。

    # models.py
    
    class CoffeeLog:
        def __init__(self, name, roast, memo, score):
            self.name = name
            self.roast = roast
            self.memo = memo
            self.score = float(score)
    
        def is_favorite(self, threshold=4.0):
            return self.score >= threshold
    
        def comment(self):
            if self.score >= 4.5:
                return "かなり特別な一杯。記念すべきレベル。"
            elif self.score >= 4.0:
                return "安定して美味しい。リピートしたい。"
            elif self.score >= 3.0:
                return "普通に美味しい。気分次第でまた飲むかも。"
            else:
                return "好みとは少し違うかもしれない。"

    CoffeeLog用のテストを書く

    ファイル:test_coffee_log.py

    # test_coffee_log.py
    
    from models import CoffeeLog
    
    
    def test_is_favorite_true_when_score_high():
        log = CoffeeLog("エチオピア浅煎り", "浅煎り", "朝の湖に合う", 4.5)
        assert log.is_favorite() is True
        assert log.is_favorite(threshold=4.5) is True
    
    
    def test_is_favorite_false_when_score_low():
        log = CoffeeLog("ブレンド", "中煎り", "普通においしい", 3.0)
        assert log.is_favorite() is False
    
    
    def test_comment_for_various_scores():
        high = CoffeeLog("スペシャル", "浅煎り", "特別な一杯", 4.6)
        mid = CoffeeLog("いつもの", "中煎り", "安定の味", 4.0)
        normal = CoffeeLog("カフェ", "深煎り", "仕事の相棒", 3.2)
        low = CoffeeLog("微妙", "深煎り", "好みじゃない", 2.5)
    
        assert "特別" in high.comment()
        assert "リピート" in mid.comment()
        assert "普通に美味しい" in normal.comment()
        assert "好みとは少し違う" in low.comment()

    ここでやっていることはシンプルで:

    • CoffeeLogのインスタンスをいくつか作り
    • is_favorite()comment() の結果が「期待どおりか?」を assert で確認する

    だけです。


    前提:FishingLog クラス(第10回のもの)

    FishingLog を pytest でテストする

    # models.py の続き
    
    class FishingLog:
        def __init__(self, date, lake, fish_type, length_cm, is_released):
            self.date = date
            self.lake = lake
            self.fish_type = fish_type
            self.length_cm = int(length_cm)
            self.is_released = bool(is_released)
    
        def is_50up(self):
            return self.length_cm >= 50
    
        def is_brown(self):
            return self.fish_type == "ブラウントラウト"
    
        def summary(self):
            base = f"{self.date} {self.lake} {self.fish_type} {self.length_cm}cm"
            if self.is_released:
                base += "(リリース)"
            return base

    FishingLog用テスト

    ファイル:test_fishing_log.py

    # test_fishing_log.py
    
    from models import FishingLog
    
    
    def test_is_50up_true_when_length_50_or_more():
        log = FishingLog("2025/05/01", "中禅寺湖", "ブラウントラウト", 50, True)
        assert log.is_50up() is True
    
        log2 = FishingLog("2025/05/02", "中禅寺湖", "ブラウントラウト", 60, False)
        assert log2.is_50up() is True
    
    
    def test_is_50up_false_when_under_50():
        log = FishingLog("2025/05/03", "中禅寺湖", "ブラウントラウト", 49, True)
        assert log.is_50up() is False
    
    
    def test_is_brown():
        brown = FishingLog("2025/05/01", "中禅寺湖", "ブラウントラウト", 45, True)
        lake = FishingLog("2025/05/01", "中禅寺湖", "レイクトラウト", 60, False)
    
        assert brown.is_brown() is True
        assert lake.is_brown() is False
    
    
    def test_summary_includes_basic_info():
        log = FishingLog("2025/05/01", "中禅寺湖", "ブラウントラウト", 52, True)
        summary = log.summary()
    
        assert "2025/05/01" in summary
        assert "中禅寺湖" in summary
        assert "ブラウントラウト" in summary
        assert "52cm" in summary
        assert "リリース" in summary

    CoffeeLog の変換が壊れていないか確認する

    to_dict() / from_dict() のテスト

    # models.py(CoffeeLogに to_dict / from_dict を追加済みとする)
    
    class CoffeeLog:
        ...
        def to_dict(self):
            return {
                "name": self.name,
                "roast": self.roast,
                "memo": self.memo,
                "score": self.score,
            }
    
        @classmethod
        def from_dict(cls, row):
            return cls(
                name=row["name"],
                roast=row["roast"],
                memo=row["memo"],
                score=float(row["score"]),
            )

    テスト:test_coffee_log_dict.py

    # test_coffee_log_dict.py
    
    from models import CoffeeLog
    
    
    def test_to_dict_and_from_dict_roundtrip():
        original = CoffeeLog("エチオピア浅煎り", "浅煎り", "朝の湖に合う", 4.5)
    
        data = original.to_dict()
        restored = CoffeeLog.from_dict(data)
    
        assert restored.name == original.name
        assert restored.roast == original.roast
        assert restored.memo == original.memo
        assert restored.score == original.score

    こうしておけば、

    • CSVへ書き出し(dict)
    • CSVから読み込み(dict)
    • クラスへ戻し

    の流れが壊れていないか、一瞬で確認できます。


    pytest.raises を使って「ちゃんと例外が出る」をテスト

    例外処理もテストしてみる(軽く)

    第9回で validate_score() のような関数を考えました。

    def validate_score(score):
        if not (0.0 <= score <= 5.0):
            raise ValueError(f"スコアが範囲外です: {score}")

    これをpytestでテストするときは pytest.raises を使います。

    # test_validation.py
    
    import pytest
    from models import validate_score
    
    
    def test_validate_score_ok():
        validate_score(0.0)
        validate_score(4.5)
        validate_score(5.0)  # ここまでは例外が出ないはず
    
    
    def test_validate_score_raises_for_invalid_value():
        with pytest.raises(ValueError):
            validate_score(-0.1)
    
        with pytest.raises(ValueError):
            validate_score(5.1)

    「このコードは例外を投げてほしい」というケースも
    テストで表現できるのが、pytestの良いところです。

    pytestの実行と、よくあるつまずき

    テストの実行コマンド

    フォルダ構成の一例:

    celestial-python/
      .venv/
      models.py
      math_utils.py
      test_math_utils.py
      test_coffee_log.py
      test_fishing_log.py
      test_coffee_log_dict.py
      test_validation.py
      requirements.txt

    この状態で、プロジェクトルート(celestial-python/)で:

    pytest

    だけでOKです。test_*.py が自動ですべて拾われます。

    よくあるつまずき

    pytestが見つからないと言われる

    • 仮想環境を有効化していない
    • あるいはインストールしていない
    • source .venv/bin/activatepip install pytest

    importエラーになる(modelsが見つからないなど)

    • test_*.pymodels.py が同じフォルダにあるかチェック
    • 別フォルダに分けた場合は、パス設定が必要(発展ネタ)

    テストが1件も見つからない

    • ファイル名が test_〜.py になっているか
    • 関数名が test_〜 で始まっているか

    今日のまとめ – pytestは「湖畔ツールの見守り役」

    今回は、

    • pytestとは何か(assert を自動でまとめてチェックしてくれるツール)
    • 最小サンプル:足し算関数のテスト
    • CoffeeLog / FishingLog のメソッドをテストする方法
    • to_dict() / from_dict() の往復テスト
    • 例外処理(validate_score)のテスト
    • pytest導入のタイミングでの venv+requirements.txt の軽い導入

    を見てきました。

    pytestが1つ入るだけで、

    • 「コードを少し変えても、pytest で一発チェック」
    • 「昔書いたCoffeeLog / FishingLogの挙動が、今も約束どおりかすぐ分かる」

    という状態になります。

    湖畔で釣りをするとき、
    ちゃんと整備されたタックルがあると安心して投げられるように、
    pytestは あなたのPythonツールを静かに見守ってくれる存在 になります。

    コメント