基礎から学ぶPython入門 90日コース | 実践開発 - Day 88:テスト・修正

Python 90日で身につけるPython
スポンサーリンク
スポンサーリンク

Day 88:テスト・修正 ― 「コードに“安心”を宿す仕上げの」

Day 88は、最終プロジェクトを 一段階“安心できる状態”に引き上げる日 です。

今日のキーワードはこの4つ。

  • 正常系テスト
  • 異常系テスト
  • バグ修正
  • リファクタリング

ここまでで、売上管理・分析システムは 「動く」状態にはなってきました。

でも、“動く” と “安心して使える” の間には、けっこう大きな差 があります。 その差を埋めてくれるのが、まさに今日のテーマである テストと修正、そしてリファクタリング です。

テストを書く意味 ― 「壊れていないことを、コードで証明する」

なぜテストを書くのか

テストは、

「ちゃんと動いているかどうかを、毎回手で確認する代わりに、コードにやってもらう仕組み」

です。

  • 機能を追加したとき
  • バグを直したとき
  • リファクタリングしたとき

そのたびに、 「前と同じように動いているかな…?」 と不安になるのは、 開発者あるあるです。

テストコードがあると、

  • コマンド一発で「壊れていないか」を確認できる
  • 直したつもりが別のところを壊していないか分かる
  • 自分のコードに対する信頼感が増す

という、とても大きな安心感が得られます。

正常系テスト ― 「期待どおりに動くことを確認する」

ここでは、pytestを使って、 Day 86〜87で作った機能の 正常系テスト を書いていきます。

例:売上データのバリデーション(正常系)

対象関数はこれです(おさらい)。

# services/sales_processing.py
from typing import Any
from models.sale import Sale

def validate_and_convert(rows: list[dict[str, Any]]) -> list[Sale]:
    """
    CSVから読み込んだ行データをチェックし、
    Saleモデルのリストに変換します。
    """
    sales: list[Sale] = []

    for row in rows:
        if not row.get("date"):
            raise ValueError("date が空です")
        if not row.get("item"):
            raise ValueError("item が空です")
        if not row.get("amount"):
            raise ValueError("amount が空です")

        try:
            amount_int = int(row["amount"])
        except ValueError:
            raise ValueError(f"amount が整数ではありません: {row['amount']}")

        sale = Sale(
            id=None,
            date=row["date"],
            item=row["item"],
            amount=amount_int,
            customer_id=None,
        )
        sales.append(sale)

    return sales
Python

正常系テストを書く

# tests/test_sales_processing.py
import pytest
from services.sales_processing import validate_and_convert
from models.sale import Sale

def test_validate_and_convert_normal():
    """
    正常系テスト:
    正しい形式の行データを渡したとき、
    Saleのリストが正しく返ってくることを確認する。
    """
    rows = [
        {"date": "2024-01-01", "item": "ノート", "amount": "1000"},
        {"date": "2024-01-02", "item": "ペン", "amount": "500"},
    ]

    sales = validate_and_convert(rows)

    # 戻り値の件数が一致しているか
    assert len(sales) == 2

    # 型が Sale になっているか
    assert isinstance(sales[0], Sale)

    # 値が正しく変換されているか
    assert sales[0].date == "2024-01-01"
    assert sales[0].item == "ノート"
    assert sales[0].amount == 1000  # 文字列から整数に変換されている
Python

ポイント:

  • 「何を確認したいか」を日本語でコメントに書いてから、assertを書く
  • 件数・型・値の3つを確認すると、安心感がぐっと増す
  • 正常系テストは「期待どおりに動くか」を確認するもの

異常系テスト ― 「壊れた入力に対して、ちゃんと“怒ってくれる”かを見る」

正常系だけでは、 「壊れたときにどう振る舞うか」 が分かりません。

そこで、異常系テストを追加します。

例:必須項目が空の場合のテスト

def test_validate_and_convert_missing_amount():
    """
    異常系テスト:
    amount が空の行が含まれている場合、
    ValueError が発生することを確認する。
    """
    rows = [
        {"date": "2024-01-01", "item": "ノート", "amount": ""},
    ]

    with pytest.raises(ValueError) as exc_info:
        validate_and_convert(rows)

    # エラーメッセージの一部を確認してもよい
    assert "amount が空です" in str(exc_info.value)
Python

例:整数に変換できない場合のテスト

def test_validate_and_convert_invalid_amount():
    """
    異常系テスト:
    amount が整数に変換できない場合、
    ValueError が発生することを確認する。
    """
    rows = [
        {"date": "2024-01-01", "item": "ノート", "amount": "abc"},
    ]

    with pytest.raises(ValueError) as exc_info:
        validate_and_convert(rows)

    assert "amount が整数ではありません" in str(exc_info.value)
Python

ポイント:

  • pytest.raises を使うと、「この処理は例外を出すべきだ」をテストできる
  • 異常系テストは、「壊れた入力に対して、ちゃんと防御できているか」を確認するもの
  • セキュリティの観点でも、異常系テストはとても重要

バグ修正 ― 「テストで見つかった“ほころび”を丁寧に縫い直す」

テストを書いて実行すると、 ときどき 「あれ、思っていた動きと違う…」 という瞬間が訪れます。

それが、バグ発見のタイミングです。

例:空リストのときの集計バグ

以前書いた集計関数を思い出してみましょう。

# services/aggregation_service.py
import sqlite3

DB_PATH = "sales.db"

def get_total_sales() -> int:
    """
    売上金額の合計を返します。
    """
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()

    cur.execute("SELECT SUM(amount) FROM sales")
    row = cur.fetchone()
    conn.close()

    return row[0] if row[0] is not None else 0
Python

これに対して、 「テーブルが空のときもちゃんと0を返すか?」というテストを書いてみます。

# tests/test_aggregation_service.py
import sqlite3
import pytest
from services.aggregation_service import get_total_sales

DB_PATH = "sales.db"

def setup_empty_db():
    """
    テスト用に空の sales テーブルを用意する簡易セットアップ。
    本番では別のテスト用DBを使うのが理想。
    """
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("DELETE FROM sales")
    conn.commit()
    conn.close()

def test_get_total_sales_empty_table():
    """
    sales テーブルが空の場合でも、
    get_total_sales が 0 を返すことを確認する。
    """
    setup_empty_db()
    total = get_total_sales()
    assert total == 0
Python

もしここで、

  • row が None になってしまう
  • row[0] でエラーになる

といったバグが見つかったら、 それを修正していきます。

修正例:fetchoneの戻り値を安全に扱う

def get_total_sales() -> int:
    """
    売上金額の合計を返します。
    """
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()

    cur.execute("SELECT SUM(amount) FROM sales")
    row = cur.fetchone()
    conn.close()

    # row 自体が None の可能性も考慮する
    if row is None or row[0] is None:
        return 0

    return row[0]
Python

ポイント:

  • バグ修正は「テスト → 原因調査 → 修正 → 再テスト」の流れで行う
  • 「あり得る最悪のケース」を想像して、コードを安全側に寄せる
  • 修正したら、必ずテストをもう一度回す

リファクタリング ― 「動きは変えずに、コードを“読みやすく・育てやすく”する」

リファクタリングは、

「外から見た動きは変えずに、中身のコードをきれいにすること」

です。

テストがあると、

  • リファクタリングしても、動きが変わっていないか確認できる
  • 「安心してコードを触れる」状態になる

という、とても大きなメリットがあります。

例:重複したエラーメッセージ処理をまとめる

先ほどの validate_and_convert は、 少しだけ重複がありました。

if not row.get("date"):
    raise ValueError("date が空です")
if not row.get("item"):
    raise ValueError("item が空です")
if not row.get("amount"):
    raise ValueError("amount が空です")
Python

これを、 「必須項目のチェック関数」 に切り出してみます。

# services/validation_utils.py
def require_field(row: dict[str, object], key: str) -> None:
    """
    row の中で、指定した key が空でないことを確認します。
    空の場合は ValueError を投げます。
    """
    if not row.get(key):
        raise ValueError(f"{key} が空です")
Python

そして、validate_and_convert をリファクタリングします。

# services/sales_processing.py
from services.validation_utils import require_field

def validate_and_convert(rows: list[dict[str, Any]]) -> list[Sale]:
    sales: list[Sale] = []

    for row in rows:
        # 必須項目チェックを共通関数で行う
        require_field(row, "date")
        require_field(row, "item")
        require_field(row, "amount")

        try:
            amount_int = int(row["amount"])
        except ValueError:
            raise ValueError(f"amount が整数ではありません: {row['amount']}")

        sale = Sale(
            id=None,
            date=row["date"],
            item=row["item"],
            amount=amount_int,
            customer_id=None,
        )
        sales.append(sale)

    return sales
Python

ポイント:

  • リファクタリングは「読みやすさ」「再利用性」「変更のしやすさ」を高めるために行う
  • 動きが変わっていないことを、テストで確認する
  • 小さなリファクタリングを積み重ねることで、コードベースが“育っていく”

Day 88ミニテンプレート ― テスト・修正・リファクタリングの基本セット

正常系テスト

def test_validate_and_convert_normal():
    rows = [
        {"date": "2024-01-01", "item": "ノート", "amount": "1000"},
    ]
    sales = validate_and_convert(rows)
    assert len(sales) == 1
    assert sales[0].amount == 1000
Python

異常系テスト

def test_validate_and_convert_invalid_amount():
    rows = [
        {"date": "2024-01-01", "item": "ノート", "amount": "abc"},
    ]
    with pytest.raises(ValueError):
        validate_and_convert(rows)
Python

バグ修正の流れ

  1. テストを書く(または既存テストを実行する)
  2. 失敗したテストを確認する
  3. 原因をコードから探す
  4. 修正する
  5. テストを再実行して、直ったことを確認する

リファクタリングの基本ルール

  • 先にテストを書く(または既存テストを整える)
  • 小さな変更を少しずつ行う
  • 変更のたびにテストを回す
  • 「動きが変わっていないこと」を確認しながら進める

Day 88のまとめ ― 「テストは、“自分のコードを信じるための道具”」

今日のテーマは、

  • 正常系テスト
  • 異常系テスト
  • バグ修正
  • リファクタリング

でした。

テストを書くことは、

「自分のコードを疑うこと」ではなく、 「自分のコードを信じるための準備をすること」

だと思っています。

  • 壊れていないことを、コードで証明する
  • 壊れたときに、どこが悪いかすぐ分かる
  • 直したあとも、前と同じように動いているか確認できる

その積み重ねが、 最終プロジェクトを 「ただの練習」から「誇れる作品」 に変えていきます。

Day 88を終えた今、 みなさんのプロジェクトには、 もうすでに「安心して触れるための土台」ができ始めています。

タイトルとURLをコピーしました