Pythonのテストとデバッグを理解する|unittest・型ヒント・mypyの基本

「Pythonのテストとデバッグを理解する|unittest・型ヒント・mypyの基本」の内容を表す技術イラスト

計算結果が一度正しく表示されても、プログラム全体が正しいとは限りません。入力値を変更したとき、境界値を与えたとき、コードを修正したときにも期待どおり動くことを確認する必要があります。

たとえば、油圧シリンダの推力計算では次の問題が起こり得ます。

  • 単位換算を忘れる
  • 円の面積式を誤る
  • 許容圧力の境界を<と<=で取り違える
  • 0、負数、NaNを計算へ通す
  • 別の修正によって以前の正常な計算を壊す

テストは、入力と期待結果をコードとして残し、同じ確認を繰り返せるようにする仕組みです。デバッグは、問題が起きた場所と原因を絞り込む作業です。型ヒントは、値の種類について意図を伝え、静的型検査やIDEの支援を受けやすくします。

この記事では標準ライブラリのunittestを中心に、正常値・境界値・異常値を分けて小さな工学計算を検証します。

目次

今日の到達点

この記事を読み終えると、次のことができるようになります。

  • 再現可能な最小コードで不具合を切り分けられる
  • print()とbreakpoint()を目的に応じて使い分けられる
  • assertの用途と制約を説明できる
  • unittest.TestCaseで最小テストを書ける
  • 正常値・境界値・異常値を分けてテストできる
  • assertEqual()、assertAlmostEqual()、assertRaises()を使える
  • 型ヒントが実行時検証ではないことを理解できる
  • mypyがプログラムを実行せずに型を検査するツールだと説明できる

まず問題を再現可能にする

不具合を調べる前に、同じ操作で同じ問題を再現できる状態を作ります。

大きなプログラム全体を動かすのではなく、問題に必要な入力、関数、出力だけを残した小さなコードを作ります。これを最小再現例と呼びます。

import math


def calculate_force_kn(
    bore_mm: float,
    pressure_mpa: float,
) -> float:
    area_mm2 = math.pi * bore_mm ** 2 / 4
    force_n = pressure_mpa * area_mm2
    return force_n / 1000


result = calculate_force_kn(80.0, 14.0)
print(result)

実行結果です。

70.37167544041137

この形なら、CSV読込や画面入力などの影響を外し、計算式だけを確認できます。

再現条件には少なくとも次を残します。

  • 入力値と単位
  • 期待する結果
  • 実際の結果
  • 実行したPythonのバージョン
  • 例外が発生する場合はtraceback

「時々おかしい」ではなく、「内径80 mm、圧力14 MPaでこの関数を呼ぶと、この結果になる」と表現できれば、原因を調べやすくなります。

printで値の流れを確認する

最も簡単なデバッグ方法は、途中の値をprint()で表示することです。

import math


def calculate_force_kn(
    bore_mm: float,
    pressure_mpa: float,
) -> float:
    area_mm2 = math.pi * bore_mm ** 2 / 4
    force_n = pressure_mpa * area_mm2

    print(f"bore_mm={bore_mm}")
    print(f"pressure_mpa={pressure_mpa}")
    print(f"area_mm2={area_mm2}")
    print(f"force_n={force_n}")

    return force_n / 1000

print()は、確認したい値が少なく、処理の流れが単純な場合に有効です。ただし、大量に追加すると必要な情報が埋もれます。調査後に不要な表示を残すと、通常の出力と診断用出力を区別しにくくなります。

確認対象を絞り、変数名と単位を一緒に表示しましょう。

breakpointで処理を一時停止する

値を対話的に確認したい場合は、標準のbreakpoint()を使えます。

def calculate_force_kn(
    bore_mm: float,
    pressure_mpa: float,
) -> float:
    area_mm2 = 3.141592653589793 * bore_mm ** 2 / 4

    breakpoint()

    force_n = pressure_mpa * area_mm2
    return force_n / 1000

この行へ到達するとデバッガーが起動し、通常は(Pdb)というプロンプトが表示されます。

代表的なコマンドは次のとおりです。

コマンド用途
p name変数や式の値を表示する
n現在の関数内で次の行へ進む
s呼び出す関数の中へ入る
c次の停止位置まで続行する
l周辺のソースコードを表示する
qデバッグを終了する

たとえばp area_mm2を実行すると、その時点の受圧面積を確認できます。

breakpoint()は対話操作を必要とするため、この記事では起動例を自動実行していません。調査後は、意図しない停止を防ぐため不要なbreakpoint()を削除します。

assertは内部前提の確認に使う

assertは、条件が偽の場合にAssertionErrorを発生させます。

area_mm2 = 5026.548

assert area_mm2 > 0, "area_mm2 must be positive"

計算途中で「ここまで来たなら面積は必ず正数である」といった、開発者が想定する内部前提の確認に使えます。

ただし、assertをユーザー入力の検証へ使うべきではありません。Pythonを最適化オプション-Oで実行すると、assert文は除去される可能性があるためです。

公開関数の入力値を検証する場合は、明示的に例外を発生させます。

if bore_mm <= 0:
    raise ValueError("bore_mm must be positive")

整理すると、次のように使い分けます。

  • assert:開発中の内部前提や、到達するはずのない状態の確認
  • raise ValueError:呼び出し側が与えた値の不正を通知
  • unittestのassert系メソッド:期待結果を自動テストとして判定

テスト対象を関数へ分離する

テストしやすいコードは、入力、計算、出力が分離されています。

次のコードをcylinder_force.pyとして保存します。

import math


def calculate_force_kn(
    bore_mm: float,
    pressure_mpa: float,
    max_pressure_mpa: float = 16.0,
) -> float:
    """Return theoretical push force in kN."""
    values = (
        bore_mm,
        pressure_mpa,
        max_pressure_mpa,
    )

    if not all(math.isfinite(value) for value in values):
        raise ValueError("all inputs must be finite")

    if bore_mm <= 0:
        raise ValueError("bore_mm must be positive")

    if not 0 < pressure_mpa <= max_pressure_mpa:
        raise ValueError(
            "pressure_mpa is outside the allowed range"
        )

    area_mm2 = math.pi * bore_mm ** 2 / 4
    force_n = pressure_mpa * area_mm2
    return force_n / 1000

この関数は計算結果を表示せず、数値として返します。そのため、テスト側から戻り値を直接比較できます。

ここでの許容圧力16 MPaは学習用の判定ルールです。実務では機器仕様、回路、運転条件、規格に基づいて決めます。

unittestで最小テストを書く

Python標準ライブラリのunittestでは、unittest.TestCaseを継承してテストクラスを作ります。名前がtestで始まるメソッドがテストとして認識されます。

次のコードをtest_cylinder_force.pyとして保存します。

import math
import unittest

from cylinder_force import calculate_force_kn


class TestCalculateForceKn(unittest.TestCase):
    def test_normal_value(self) -> None:
        actual = calculate_force_kn(80.0, 14.0)
        self.assertAlmostEqual(
            actual,
            70.371675,
            places=6,
        )

    def test_upper_boundary_is_allowed(self) -> None:
        actual = calculate_force_kn(80.0, 16.0)
        expected = (
            16.0 * math.pi * 80.0 ** 2 / 4 / 1000
        )
        self.assertAlmostEqual(
            actual,
            expected,
            places=9,
        )

    def test_invalid_values_raise_value_error(self) -> None:
        cases = [
            (0.0, 14.0),
            (80.0, 0.0),
            (80.0, 16.1),
            (80.0, math.nan),
        ]

        for bore_mm, pressure_mpa in cases:
            with self.subTest(
                bore_mm=bore_mm,
                pressure_mpa=pressure_mpa,
            ):
                with self.assertRaises(ValueError):
                    calculate_force_kn(
                        bore_mm,
                        pressure_mpa,
                    )


if __name__ == "__main__":
    unittest.main()

テストファイルと対象ファイルが同じフォルダにある状態で、次のように実行します。

python -m unittest -v test_cylinder_force.py

環境によって起動コマンドがpython3やpyの場合は、自分のPython 3環境に合わせて読み替えてください。

実行結果です。

test_invalid_values_raise_value_error ... ok
test_normal_value ... ok
test_upper_boundary_is_allowed ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

このテストはPython 3.12.14で実行し、全3件が成功することを確認しました。

正常値・境界値・異常値を分ける

一つの代表値だけでは、条件式の間違いを見落とします。

正常値

通常の利用範囲にある値です。

内径:80 mm
圧力:14 MPa

既知の式や手計算結果と比較します。

境界値

判定が切り替わる直前、直後、ちょうど境界にある値です。

この記事のルールは次のとおりです。

0 < pressure_mpa <= 16.0

したがって、少なくとも次を考えます。

入力期待結果
16.0許可される
16.1ValueError
0.0ValueError

境界値テストは、<、<=、>、>=の取り違えを見つけるのに有効です。

異常値

形式や物理条件に反する値です。

  • 内径0 mm
  • 負の圧力
  • 上限超過
  • NaN
  • 無限大
  • 数値ではない値

異常値のテストでは、単に「失敗すること」ではなく、想定した例外が発生することをassertRaises()で確認します。

主なテスト用メソッド

unittest.TestCaseには判定用のメソッドがあります。

メソッド確認内容
assertEqual(a, b)a == bである
assertTrue(value)値が真である
assertFalse(value)値が偽である
assertIsNone(value)値がNoneである
assertIn(a, b)aがbに含まれる
assertAlmostEqual(a, b)数値が指定桁までほぼ等しい
assertRaises(Error)指定した例外が発生する

浮動小数点計算では、表現誤差があるため完全一致が適さない場合があります。テストの許容差は、表示桁数ではなく計算目的、測定精度、設計公差に基づいて決めます。

失敗・エラー・成功を区別する

unittestの結果には主に次の状態があります。

  • OK:すべてのテストが成功
  • FAIL:期待値と実際の値が一致しないなど、判定が失敗
  • ERROR:テスト中に想定外の例外が発生

テストが失敗したときは、最後の行だけでなく、テスト名、traceback、期待値、実際の値を確認します。

失敗したテストに合わせて期待値を安易に書き換えてはいけません。先に次を確認します。

  • 仕様が正しいか
  • テストの期待値が正しいか
  • 実装の式と単位が正しいか
  • 入力データが想定どおりか

型ヒントで入出力の意図を示す

次の関数では、引数と戻り値へ型ヒントを付けています。

def calculate_force_kn(
    bore_mm: float,
    pressure_mpa: float,
) -> float:
    ...

これは「bore_mmとpressure_mpaには浮動小数点数を想定し、結果として浮動小数点数を返す」という意図を表します。

型ヒントには次の利点があります。

  • 関数の入出力を読み取りやすくする
  • IDEの補完や警告を受けやすくする
  • 静的型検査ツールで不整合を発見しやすくする
  • 関数の利用方法を共有しやすくする

ただし、Pythonの実行環境は型注釈を通常の実行時検証として強制しません。

def double(value: float) -> float:
    return value * 2


print(double("14"))

型ヒントに反して文字列を渡しても、Pythonは呼び出し時に自動拒否しません。この例は文字列の繰り返しとして1414を返します。

型ヒント、入力検証、テストは役割が異なります。

手段主な役割
型ヒント想定する型を記述し、静的検査を支援する
入力検証実行時に値や範囲を確認する
テスト具体的な入力に対する結果を繰り返し確認する

どれか一つで他のすべてを代替することはできません。

mypyの位置づけ

mypyは、型ヒントを利用する静的型検査ツールです。静的検査では、対象プログラムを通常実行せずに型の不整合を調べます。

mypyはPython標準ライブラリではないため、利用する場合はプロジェクトの仮想環境へ導入します。

python -m pip install mypy

対象ファイルを検査する基本コマンドです。

python -m mypy cylinder_force.py

たとえば次の呼び出しは、静的検査で問題として報告されます。

calculate_force_kn("80", 14.0)

第1引数はfloatを想定していますが、strを渡しているためです。

一方、mypyが成功しても、計算式や物理単位が正しいことは保証されません。float同士を掛けるコードが型として正しくても、MPaとPaを取り違えている可能性は残ります。

本記事のコードは、実行環境にmypyが導入されていないためmypyでは未実行です。unittestによる実行検証と、mypyによる静的検査を混同しないようにしています。

よくある失敗

正常値だけをテストする

代表値が通っても、0、上限ちょうど、上限直後、NaNで問題が起こる可能性があります。条件式がある場所では境界の両側を確認します。

printの出力を目で確認するだけ

手作業の確認は忘れたり、見落としたりします。期待値をunittestへ書けば、変更後も同じ条件を再実行できます。

浮動小数点数を常にassertEqualで比較する

浮動小数点演算には近似誤差があります。目的に合った許容差を決め、assertAlmostEqual()などを使います。

assertを入力検証に使う

assertは最適化実行で除去される可能性があります。外部入力や公開関数の条件は、ifとraiseで明示的に検証します。

型ヒントが値を守ると思う

pressure_mpa: floatだけでは、負数、無限大、過大な圧力を防げません。型と値の妥当性は別々に確認します。

実装と同じ式で期待値を作る

実装とテストで同じ誤りを繰り返すと、誤ったコードでもテストが成功します。重要な工学計算では、手計算、別式、既知の基準値など、独立した根拠から期待値を作ります。

練習問題

単位換算関数をテストする

MPaをPaへ変換する関数とテストを書いてください。

解答例です。

import unittest


def mpa_to_pa(pressure_mpa: float) -> float:
    return pressure_mpa * 1_000_000


class TestMpaToPa(unittest.TestCase):
    def test_conversion(self) -> None:
        self.assertEqual(
            mpa_to_pa(14.0),
            14_000_000.0,
        )

0を境界値として確認する

長さが0以下ならValueErrorを発生させる関数をテストしてください。

解答例です。

import unittest


def validate_length_mm(length_mm: float) -> None:
    if length_mm <= 0:
        raise ValueError("length_mm must be positive")


class TestValidateLength(unittest.TestCase):
    def test_zero_raises_value_error(self) -> None:
        with self.assertRaises(ValueError):
            validate_length_mm(0.0)

型ヒントの誤りを見つける

次のコードで型検査ツールが報告する問題を考えてください。

def format_force(force_kn: float) -> str:
    return f"{force_kn:.2f} kN"


message = format_force("70.37")

解答です。

format_force()はfloatを想定していますが、呼び出し側はstrを渡しています。次のように数値として渡します。

message = format_force(70.37)

外部入力が文字列なら、変換できることを確認してからfloatへ変換します。

まとめ

  • 不具合は入力、期待結果、実際の結果を含む最小コードで再現する
  • print()は少数の途中値を素早く確認するのに向く
  • breakpoint()を使うと処理を止めて変数や実行順序を調べられる
  • assertは内部前提の確認に使い、外部入力の検証には使わない
  • unittest.TestCaseのtestで始まるメソッドがテストとして認識される
  • 浮動小数点数にはassertAlmostEqual()を検討する
  • 例外条件はassertRaises()で確認できる
  • 正常値だけでなく、境界値と異常値もテストする
  • 型ヒントは実行時の型や値を自動検証しない
  • mypyは型ヒントを利用して、コードを通常実行せずに型を検査する
  • 型検査が成功しても、数式、単位、設計条件の正しさは別に検証する

テストは「正しいと思う」状態を、「どの入力で、何を期待し、実際にどうなったか」という再実行可能な記録へ変えます。工学計算ではコードの構文だけでなく、式、単位、境界条件、異常時の動作を一組として確認することが重要です。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次