41. コードのデバッグとテスト
コードを書くのは戦いの半分にすぎません。もう半分は、コードが正しく動くことを確認し、動かないときに問題を見つけることです。初心者からエキスパートまで、すべてのプログラマはバグのあるコードを書きます。違いは、経験豊富なプログラマが、それらのバグを見つけて修正するための体系的なアプローチを身につけていることです。
この章では、コードが実際に何をしているのかを理解し、問題を素早く特定し、意図したとおりに動作することを検証するのに役立つ、実践的なデバッグ(debugging)技法を学びます。これらのスキルは、あなたをより自信に満ちた、効果的なプログラマにしてくれます。
41.1) エラー箇所を特定するためのトレースバックの読み方(クイックレビュー)
第24章で学んだように、Python は何かがうまくいかないときに トレースバック(tracebacks) と呼ばれる詳細なエラーメッセージを提供します。これはデバッグの際の第一防衛線なので、効果的な読み方を復習しましょう。
41.1.1) トレースバックの構造
Python がエラーに遭遇すると、問題が起きた場所とエラーの種類を正確に示します。次は典型的なトレースバックです。
def calculate_average(numbers):
total = sum(numbers)
count = len(numbers)
return total / count
def process_student_grades(grades):
average = calculate_average(grades)
return f"Average: {average:.1f}"
# これはエラーを引き起こします
student_grades = []
result = process_student_grades(student_grades)
print(result)出力:
Traceback (most recent call last):
File "grades.py", line 12, in <module>
result = process_student_grades(student_grades)
File "grades.py", line 7, in process_student_grades
average = calculate_average(grades)
File "grades.py", line 4, in calculate_average
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zeroこのトレースバックが何を教えてくれているのか、分解してみましょう。
下から上へ読む:
- エラーの種類とメッセージ(最下部):
ZeroDivisionError: division by zeroが、何が起きたのかを正確に教えてくれます - エラーが発生した正確な行: 4行目の
return total / count - そこに至るまでの呼び出し連鎖(call chain): 12行目から始まり、7行目を経て、4行目で終わっています
41.1.2) トレースバックを使って根本原因を見つける
トレースバックは 症状(エラーが発生した場所)を示しますが、あなたは 原因(なぜそれが起きたのか)を見つける必要があります。問題を追ってみましょう。
# エラーはここで起きています
return total / count # count は 0 です
# しかし本当の問題はここです
student_grades = [] # 空のリストが関数に渡されていますゼロ除算が起きたのは、空のリストを渡したためです。トレースバックは4行目を指していますが、修正はそれより前に行う必要があります。入力を検証するか、空のリストケースを処理するかです。
def calculate_average(numbers):
"""numbers の平均を返します。リストが空なら None を返します。"""
if not numbers:
return None
return sum(numbers) / len(numbers)
def process_student_grades(grades):
"""学生の成績を処理し、整形された文字列を返します。"""
average = calculate_average(grades)
if average is None:
return "No grades to process"
return f"Average: {average:.1f}"
# これで安全に動作します
student_grades = []
result = process_student_grades(student_grades)
print(result) # Output: No grades to process
# こちらも動作します
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result) # Output: Average: 86.2重要ポイント:
- トレースバックは下から上へ読む
- エラー箇所(症状)が必ずしも根本原因とは限らない
- 後段でのエラーを防ぐために、入力は早めに検証する
- より安全なコードにするために、防御的プログラミング(defensive programming)(
.get()、長さチェック)を使う
エラーの種類によってトレースバックは異なりますが、読み方のプロセスは常に同じです。最下部から始めて何が起きたのかを見て、その後に上へ辿って、そこに至るまでの経路を理解します。特定の例外タイプについて復習したい場合は、第24章を参照してください。
トレースバックを効果的に読めるようになったので、次はコードが何をしているのかをステップごとに理解するために、頭の中でコードを追跡する方法を学びましょう。
41.2) コード実行を頭の中でトレースする
ときには、バグに遭遇してもすぐにコードを実行できないことがあります。紙の上でコードをレビューしている、誰かの pull request を読んでいる、あるいは関数が期待どおりに振る舞わない理由を理解しようとしている、などです。こうした状況では、メンタル実行(mental execution)(頭の中でコードを1行ずつ進め、各変数に何が起きるかを追跡すること)が非常に役立ちます。
経験豊富なプログラマでさえ、この技法を日常的に使います。print 文を追加したりデバッガを動かしたりする前に、いくつかの反復を頭の中でトレースして、問題がありそうな場所について仮説を立てることがよくあります。これは試行錯誤より速く、コードをより深く理解する助けになります。
メンタル実行は特に次のような場面で有用です:
- 見慣れないコードを読んで、何をしているか理解するとき
- 実行する前に小さな関数(5〜15行)を見直すとき
- コードは動くが誤った結果を出すロジックエラーをデバッグするとき
- パターンがすぐに分からないときにループ(loop)の挙動を理解するとき
- 自分で簡単に実行できないコードレビューのとき
より大きい、または複雑なコードでは、この章の後半で扱う他の技法とメンタル・トレースを組み合わせることになります。しかし、このスキルを習得すれば、あなたははるかに効果的なデバッガになります。
41.2.1) メンタル実行のプロセス
コードをメンタル実行するとき、あなたは Python インタプリタの役割を担い、Python が従うのと同じルールに従います。簡単な例で練習してみましょう。
def find_maximum(numbers):
max_value = numbers[0]
for num in numbers:
if num > max_value:
max_value = num
return max_value
result = find_maximum([3, 7, 2, 9, 5])
print(result) # Output: 9このコードをどのようにトレースするかは次のとおりです。
ステップごとのトレース:
Initial state:
numbers = [3, 7, 2, 9, 5]
max_value = 3 (numbers[0])
Iteration 1: num = 3
Check: 3 > 3? → False
max_value remains 3
Iteration 2: num = 7
Check: 7 > 3? → True
max_value = 7 ✓
Iteration 3: num = 2
Check: 2 > 7? → False
max_value remains 7
Iteration 4: num = 9
Check: 9 > 7? → True
max_value = 9 ✓
Iteration 5: num = 5
Check: 5 > 9? → False
max_value remains 9
Return: 941.2.2) トレーステーブルを作る
より複雑なコードでは、変数が時間とともにどう変化するかを示す トレーステーブル(trace table) を作ってください。これは特にループやネストした構造で役立ちます。
def calculate_running_totals(numbers):
totals = []
running_sum = 0
for num in numbers:
running_sum += num
totals.append(running_sum)
return totals
result = calculate_running_totals([10, 20, 30, 40])
print(result) # Output: [10, 30, 60, 100]トレーステーブル:
この表は、各ステップでの変数の状態を示します。各加算の「前」から「後」へ running_sum がどう変わるかに注目してください。
| 反復 | num | running_sum(前) | running_sum(後) | totals |
|---|---|---|---|---|
| 開始 | - | 0 | 0 | [] |
| 1 | 10 | 0 | 10 | [10] |
| 2 | 20 | 10 | 30 | [10, 30] |
| 3 | 30 | 30 | 60 | [10, 30, 60] |
| 4 | 40 | 60 | 100 | [10, 30, 60, 100] |
この表を作ることで、データがコードを通じてどのように流れるかを正確に把握できます。出力が期待と一致しない場合、どこで問題が起きているかを正確に特定できます。
41.2.3) 条件分岐ロジックをトレースする
条件文では、どの分岐が実行されるかに注意深く目を向ける必要があります。より複雑な例をトレースしてみましょう。
def categorize_grade(score):
if score >= 90:
category = "Excellent"
bonus = 10
elif score >= 80:
category = "Good"
bonus = 5
elif score >= 70:
category = "Satisfactory"
bonus = 0
else:
category = "Needs Improvement"
bonus = 0
final_score = score + bonus
return category, final_score
result = categorize_grade(85)
print(result) # Output: ('Good', 90)score = 85 のメンタル・トレース:
85 >= 90をチェック → False、最初のブロックをスキップ85 >= 80をチェック → True、2つ目のブロックへ入るcategory = "Good"とbonus = 5をセット- 残りの elif と else ブロックはスキップ(すでに一致が見つかったため)
final_score = 85 + 5 = 90を計算("Good", 90)を返す
41.2.4) 関数呼び出しと return をトレースする
関数(function)が別の関数を呼び出す場合、コールスタック(call stack)(関数呼び出しの順序と、それぞれのローカル変数)を追跡する必要があります。
def calculate_tax(amount, rate):
tax = amount * rate
return tax
def calculate_total(price, quantity, tax_rate):
subtotal = price * quantity
tax = calculate_tax(subtotal, tax_rate)
total = subtotal + tax
return total
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}") # Output: Total: $162.00コールスタック付きのトレース:
┌─ calculate_total(50, 3, 0.08)
│ price = 50, quantity = 3, tax_rate = 0.08
│ subtotal = 150
│
│ ┌─ calculate_tax(150, 0.08)
│ │ amount = 150, rate = 0.08
│ │ tax = 12.0
│ │ return 12.0
│ └─
│
│ tax = 12.0 (from calculate_tax)
│ total = 162.0
│ return 162.0
└─
result = 162.0このステップごとのトレースは、データが関数間をどう流れるかを正確に示します。デバッグ時に最終結果が間違っている場合、どの関数が誤った中間値を生成したのかを遡って確認できます。
メンタル・トレースは強力ですが、複雑なコードでは面倒になることがあります。次のセクションでは、コードが実行されている間に何が起きているのかを実際に見るために、print 文を戦略的に使う方法を学びます。これはメンタル実行だけに頼るより、速くて信頼できることが多いです。
41.3) print を使ったデバッグ: f"{var=}" と repr()
メンタル実行は小さな関数ではうまく機能しますが、より大きい、または複雑なコードでは現実的ではなくなります。ループの内部で何が起きているか分からないときや、計算が予期しない結果を生むとき、調査の最速手段は戦略的に print() 文を追加することが多いです。
print デバッグには、他の技法に対していくつかの利点があります:
- 特別なツールが不要: どの Python 環境でも動く
- 実装が速い: 数秒で print 文を追加できる
- 出力が明確: 依頼した内容をそのまま確認できる
- 削除が簡単: 終わったら print を消すだけ
プロの開発者も print デバッグを常に使っています。これは「初心者向け」の技法ではありません。効果的な使い方を学びましょう。
41.3.1) 基本的な print デバッグ
最もシンプルなデバッグ手法は、コード中の重要なポイントで変数の値を出力することです。
def process_order(items, discount_rate):
print(f"Starting process_order")
print(f"Items: {items}")
print(f"Discount rate: {discount_rate}")
subtotal = sum(item['price'] * item['quantity'] for item in items)
print(f"Subtotal: {subtotal}")
discount = subtotal * discount_rate
print(f"Discount amount: {discount}")
total = subtotal - discount
print(f"Final total: {total}")
return total
order_items = [
{'name': 'Book', 'price': 25.99, 'quantity': 2},
{'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
result = process_order(order_items, 0.10)出力:
Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999これらの print 文は、実行の流れと各ステップでの値を示してくれます。最終結果が間違っている場合、どこで計算が狂ったのかを正確に確認できます。
41.3.2) すばやい確認のために f"{var=}" を使う
Python 3.8 では便利なデバッグ構文 f"{var=}" が導入されました。これは変数名と値の両方を出力します。
def calculate_compound_interest(principal, rate, years):
# 従来のアプローチ
print(f"principal: {principal}")
print(f"rate: {rate}")
print(f"years: {years}")
# f"{var=}" を使ったよりすっきりしたアプローチ
print(f"{principal=}")
print(f"{rate=}")
print(f"{years=}")
# 変数だけでなく式も使えます
print(f"{principal * rate=}")
print(f"{(1 + rate) ** years=}")
amount = principal * (1 + rate) ** years
print(f"{amount=}")
return amount
result = calculate_compound_interest(1000, 0.05, 10)出力:
principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.89462677744241.3.3) repr() を使ってデータの本当の形を見る
ときには、print した見た目が、実際にそれだと思っているものと違うことがあります。repr() 関数は、隠れた文字も含めてオブジェクトの 正確な表現 を示します。
# これらの文字列は print すると同じに見えます
text1 = "Hello"
text2 = "Hello\n" # 末尾に改行があります
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")出力:
Using print():
text1: Hello
text2: Hello
Using repr():
text1: 'Hello'
text2: 'Hello\n'repr() の出力を見ると、text2 に隠れた改行文字があることが分かります。これは文字列処理をデバッグする際に重要です。
def clean_user_input():
# ユーザー入力には隠れた空白が含まれることがよくあります
username = input("Enter username: ") # ユーザーは "Alice " と入力します
print(f"Username with print(): {username}")
print(f"Username with repr(): {repr(username)}")
# 入力をクリーンアップする
cleaned = username.strip()
print(f"Cleaned with repr(): {repr(cleaned)}")
return cleanedユーザーが "Alice" の後ろにスペースを入れて Enter を押した場合、次のように見えるかもしれません。
出力:
Enter username: Alice
Username with print(): Alice
Username with repr(): 'Alice '
Cleaned with repr(): 'Alice'repr() の出力は、print() でははっきり見えない末尾のスペースを明らかにします。
repr() と str() を使い分けるタイミング:
repr() は開発者向けに設計されており、オブジェクトを再生成できるような「公式」の文字列表現を示します。str()(print() がデフォルトで使うもの)はエンドユーザー向けに設計されており、読みやすく親しみやすい表示になります。
デバッグでは、データの真の構造を明らかにするため、たいてい repr() のほうが役立ちます。
41.3.4) print を置く場所を戦略的に選ぶ
print 文を闇雲にあちこちへ散らさないでください。戦略的に配置します。
def calculate_shipping_cost(weight, distance, express=False):
print(f"=== calculate_shipping_cost called ===")
print(f"Input: {weight=}, {distance=}, {express=}")
# 基本コストを計算する
base_rate = 0.50
base_cost = weight * distance * base_rate
print(f"Calculated: {base_cost=}")
# 速達の追加料金を適用する
if express:
surcharge = base_cost * 0.50
print(f"Express surcharge: {surcharge=}")
total = base_cost + surcharge
else:
print("No express surcharge")
total = base_cost
print(f"Final: {total=}")
print(f"=== calculate_shipping_cost returning ===\n")
return total
# さまざまなシナリオをテストする
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)出力:
=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===分かりやすいマーカー(===)と整理された出力により、実行の流れを追いやすくなります。
41.3.5) デバッグ用 print を削除する
バグを見つけて修正したら、デバッグ用の print を削除することを忘れないでください。いくつかの戦略を紹介します。
戦略 1: 区別しやすい接頭辞を使う
# 検索/置換で見つけて削除しやすい
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")戦略 2: デバッグフラグを使う
DEBUG = True
def calculate_total(items):
if DEBUG:
print(f"Processing {len(items)} items")
total = sum(item['price'] for item in items)
if DEBUG:
print(f"{total=}")
return total
# すべてのデバッグ出力を一度にオフにする
DEBUG = False戦略 3: コメントアウトして残す
def process_data(data):
# print(f"DEBUG: {data=}") # 将来のデバッグで役立つ
result = transform(data)
# print(f"DEBUG: {result=}")
return result本番コードに残せる、より洗練されたログ出力には Python の logging モジュールがありますが、開発中の素早いデバッグには単純な print 文が最適です。
print デバッグは変数の値を示しますが、ときにはオブジェクトの 構造(どんなメソッドがあり、どんな型で、何ができるのか)を理解する必要があります。次のセクションでは、type() と dir() を使ってオブジェクトを調べる方法を学びます。
41.4) オブジェクトの検査: type() と dir()
print デバッグは変数の 値 を示しますが、ときには問題は値ではなく、扱っているオブジェクトの 型 にあります。リストを期待していたのに文字列を受け取っていたり、見慣れないオブジェクトを扱っていて、どのメソッドをサポートしているか分からなかったりします。
Python にはオブジェクトを検査する組み込みツールがあります。type() はどんな種類のオブジェクトかを教えてくれ、dir() はどんな操作をサポートしているかを示します。これらの関数は次のような場合に不可欠です:
- 型に関連するエラー(TypeError, AttributeError)のデバッグ
- 見慣れないライブラリや API を扱うとき
- サードパーティコードが返すオブジェクトを理解するとき
- コードが期待する型を受け取っているか検証するとき
これらの検査ツールを効果的に使う方法を学びましょう。
41.4.1) type() でオブジェクトの型を特定する
type() 関数は、どんな種類のオブジェクトかを正確に教えてくれます。これは型に関連するエラーをデバッグする際に重要です。
def process_data(data):
print(f"Received data: {data}")
print(f"Data type: {type(data)}")
if isinstance(data, list):
print("Processing as list")
return sum(data)
elif isinstance(data, dict):
print("Processing as dictionary")
return sum(data.values())
else:
print("Unexpected type!")
return None
# 異なる型でテストする
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
result3 = process_data("123")
print(f"Result: {result3}")出力:
Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None41.4.2) 型の取り違えをデバッグする
型の混乱(type confusion)はバグの一般的な原因で、特に複数のソース(ユーザー入力、ファイル読み込み、API 応答、他の関数など)からデータを受け取る可能性がある関数を扱うときに起こりがちです。数値のリストを期待していたのに誤って文字列を受け取ったり、辞書を期待していたのにリストを受け取ったりします。
type() を使うと、間違った型を持っていることを特定できます。関数の早い段階で型を出力することで、コードの深い場所で分かりにくいエラーメッセージが出る前に、型の不一致をすぐに見つけられます。
def calculate_average(numbers):
print(f"{type(numbers)=}")
print(f"{numbers=}") # 実際に何を受け取ったかを表示する
# numbers が数値のリストでない場合は失敗します
total = sum(numbers)
count = len(numbers)
return total / count
# よくあるミス: 文字列をリストに変換するのを忘れた
scores = "85" # [85] か 85 のどちらかであるべき
try:
avg = calculate_average(scores)
print(f"Average: {avg}")
except TypeError as e:
print(f"TypeError: {e}")
print(f"Expected list of numbers, got {type(scores)}")
print(f"The string contains: {repr(scores)}")出力:
type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'type() チェックにより問題がすぐに明らかになります。リストが必要なのに文字列を渡していました。このデバッグ出力がなければ、sum() がなぜ失敗したのかを理解しようとして時間を使ってしまうかもしれませんが、実際の問題は、そもそも間違った型のデータが最初に関数へ入ってきたことです。
41.4.3) dir() を使って利用可能なメソッドを見つける
見慣れないオブジェクト(学習中のライブラリのもの、API 応答、あるいは Python の組み込み型)を扱うとき、しばしば「このオブジェクトで何ができるのか?」を知る必要があります。dir() 関数は、オブジェクトで利用可能な属性とメソッドをすべて列挙して、この疑問に答えてくれます。
これは特に次の場合に価値があります:
- 新しいライブラリを探索していて、オブジェクトが提供するメソッドを知りたいとき
- サードパーティコードからオブジェクトを受け取り、その能力を理解する必要があるとき
- 使いたいメソッド名を正確に思い出せないとき
- デバッグ中に、オブジェクトが期待するメソッドを持っているか確認したいとき
文字列がどんなメソッドを持っているかを調べてみましょう。
# 文字列がどんなメソッドを持っているかを探索する
text = "Python Programming"
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]: # Show first 10
print(f" {method}")
print(f" ... and {len(methods) - 10} more")出力:
Type: <class 'str'>
Available string methods (showing first 10):
capitalize
casefold
center
count
encode
endswith
expandtabs
find
format
format_map
... and 37 moreこれで、文字列で利用可能な操作がすべて分かります。文字列に count メソッドや endswith メソッドがあるか確信が持てなかったとしても、dir() で存在が分かります。その後、Python の help() 関数を使って任意の特定メソッドについて詳しく学べます。
# 特定のメソッドについて詳しく学ぶ
help(text.count)これは count メソッドのドキュメントを表示します:
Help on built-in function count:
count(sub[, start[, end]], /) method of builtins.str instance
Return the number of non-overlapping occurrences of substring sub in string S[start:end].
Optional arguments start and end are interpreted as in slice notation.dir() 関数は、Python にドキュメントが組み込まれているようなものです。扱っているどんなオブジェクトに対しても、何ができるかを示してくれます。
41.4.4) カスタムオブジェクトを検査する
カスタムクラスを扱うとき、type() と dir() は相手が何者かを理解する助けになります。さらに Python には、特定の属性が存在するかをアクセス前に確認できる hasattr() があります。これにより AttributeError 例外を防げます。
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def get_status(self):
return "Passing" if self.grade >= 60 else "Failing"
student = Student("Alice", 85)
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
if not attr.startswith('_'):
print(f" {attr}")
# 特定の属性が存在するかをチェックする
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
# 存在すると分かっている属性に安全にアクセスできます
if hasattr(student, 'name'):
print(f"\nStudent name: {student.name}")
else:
print("\nNo name attribute found")
if hasattr(student, 'get_status'):
print(f"Status: {student.get_status()}")
else:
print("No get_status method found")
# これは次のようなエラーを防ぎます:
# print(student.age) # Would raise AttributeError!出力:
Object type: <class '__main__.Student'>
Available attributes and methods:
get_status
grade
name
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
Student name: Alice
Status: Passinghasattr() 関数は、防御的なコード(操作が安全かどうかを実行前に確認するコード)を書くために不可欠です。この関数は属性が存在すれば True、存在しなければ False を返します。これにより、属性にアクセスする前に判断できます。これは外部ライブラリのオブジェクトやユーザー入力を扱うときのように、どんな属性が存在するか保証できない場合に特に重要です。
41.4.5) getattr() を使って安全に属性へアクセスする
属性が存在するか確信が持てないときは、デフォルト値付きで getattr() を使います。
def display_student_info(student):
"""いくつかの属性が欠けていても安全に学生情報を表示します。"""
print(f"Type: {type(student)}")
# デフォルト値付きの安全な属性アクセス
name = getattr(student, 'name', 'Unknown')
grade = getattr(student, 'grade', 0)
age = getattr(student, 'age', 'Not specified')
print(f"Name: {name}")
print(f"Grade: {grade}")
print(f"Age: {age}")
# 呼び出す前にメソッドが存在するかをチェックする
if hasattr(student, 'get_status'):
status = student.get_status()
print(f"Status: {status}")
# 上と同じ Student クラスを使う
student = Student("Bob", 72)
display_student_info(student)出力:
Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: Passingこのアプローチは、すべての期待される属性を持っていない可能性があるオブジェクトを扱うときに AttributeError 例外を防ぎます。getattr() 関数は特に次のような場合に便利です:
- 外部 API のオブジェクトを扱っていて、バージョンにより属性が異なる可能性があるとき
- 自分のクラスにおけるオプション属性を扱うとき
- 欠けたデータを適切に扱える防御的コードを組み立てるとき
どんな型のオブジェクトを持っているか、そしてどんなメソッドをサポートしているかを理解することは、デバッグに不可欠です。しかし、ときにはコードが動くだけでなく、正しい結果を出すことを検証する必要があります。次のセクションでは、assert 文を使って仮定をテストし、早い段階でバグを捕捉する方法を学びます。
41.5) assert 文によるテスト
ここまでで、問題が起きたときにコードをデバッグする方法(トレースバックの読み方、メンタルでの実行追跡、print 文の使用、オブジェクトの検査)を学びました。しかし、バグが現れてから修正するより良い方法があります。それは テストによって、最初からバグを防ぐ ことです。
assert 文は Python の最もシンプルなテストツールです。重要なポイントで仮定をチェックすることで、コードが正しく振る舞うことを確認できます。アサーションが失敗すると、Python は何が間違っていてどこで起きたのかをすぐに教えてくれるため、バグを早期に捕捉しやすくなります。多くの場合、メインプログラムを実行する前に発見できます。
アサーションは特に次のような用途で価値があります:
- 関数が期待どおりの結果を生成することの検証
- 入力が要件を満たしているかのチェック
- コードを壊しうるエッジケースのテスト
- コードが依存する仮定のドキュメント化
アサーションは、コードが意図どおりに動いていることを継続的に検証する 自動チェック だと考えてください。効果的な使い方を学びましょう。
41.5.1) assert がすること
assert 文は条件が真かどうかをチェックします。条件が真なら何も起きず、コードは通常どおり進みます。偽なら Python は AssertionError を送出し、実行を停止します。
構文:
assert condition, "Optional error message"condition: True または False に評価される任意の式"Optional error message": アサーションが失敗したときに表示される有用なテキスト
実際の動作は次のとおりです:
# 単純なアサーション
x = 10
assert x > 0 # 問題なく通る(x は確かに > 0)
assert x < 5 # 失敗!AssertionError を送出する
# エラーメッセージ付き(はるかに役立ちます!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}" # 明確なメッセージで失敗するでは、実際の関数でアサーションを見てみましょう。
def calculate_discount(price, discount_percent):
# 入力が妥当であることを検証する
assert price >= 0, "Price cannot be negative"
assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
# 出力が筋が通っていることを検証する
assert final_price >= 0, "Final price cannot be negative"
return final_price
# 妥当な入力は問題なく動きます
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}") # Output: Price after 20% discount: $80.0
# 不正な入力はアサーションを発火させます
try:
result = calculate_discount(-50, 20)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Price cannot be negative
try:
result = calculate_discount(100, 150)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Discount must be between 0 and 10041.5.2) アサーションを使って関数の振る舞いを検証する
アサーションは、関数が期待どおりの結果を出すことをテストするのに最適です。
def calculate_average(numbers):
if not numbers:
return 0.0
return sum(numbers) / len(numbers)
# さまざまな入力でテストする
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")出力:
Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0どれかのアサーションが失敗すれば、どのテストケースが問題を露呈したのかが即座に分かります。
41.5.3) エッジケースをテストする
エッジケース(edge cases)とは、関数が扱うべき入力の境界にある値のことです。これらをテストすると、通常の入力では見逃すかもしれないバグが見つかります。
def get_first_and_last(items):
"""シーケンスから先頭と末尾の要素を返します。"""
assert len(items) > 0, "Cannot get first and last from empty sequence"
return items[0], items[-1]
# 通常ケースをテストする
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
# エッジケース: 要素が1つ
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
# エッジケース: 要素が2つ
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
# エッジケース: 空のシーケンス(失敗するはず)
try:
result = get_first_and_last([])
print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
print(f"Empty list correctly rejected: {e}")出力:
Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence41.5.4) データ変換をテストする
関数がデータを変換する場合は、変換が正しいことを assert してください。
def remove_duplicates(items):
"""順序を保ったまま重複を削除します。"""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
# 基本的な重複削除をテストする
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
# 順序が保持されることをテストする
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
# 重複がないケースをテストする
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
# すべて重複のケースをテストする
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")出力:
Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]41.5.5) シンプルなテスト関数を作る
コードが成長すると、メインコード全体に assert 文を散らすのは雑然として管理しづらくなります。より良いアプローチは、テストを専用のテスト関数に整理する ことです。これにより、テストコードを本番コードから分離でき、すべてのテストを一度に実行しやすくなります。
なぜ専用のテスト関数を使うのですか?
- 整理: 1つの関数に対する全テストが1か所にまとまる
- 再利用性: コードを変更するたびにいつでもテストを実行できる
- ドキュメント化: テストが関数の期待挙動を示す
- デバッグ: テストが失敗したら、どのシナリオが壊れたかすぐ分かる
- 開発ワークフロー: まずテストし、その後に実装・修正する
実例を見てみましょう:
def calculate_grade(score):
"""数値スコアを文字評価に変換します。"""
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
def test_calculate_grade():
"""calculate_grade 関数をテストします。
この関数は、期待される振る舞いをすべてテストします:
- 各評価レンジ(A, B, C, D, F)
- 境界値(90, 80, 70, 60)
- エッジケース(各境界の直下)
"""
print("Testing calculate_grade...")
# A 評価をテストする
assert calculate_grade(95) == 'A', "95 should be A"
assert calculate_grade(90) == 'A', "90 should be A (boundary)"
print(" ✓ A grades: passed")
# B 評価をテストする
assert calculate_grade(85) == 'B', "85 should be B"
assert calculate_grade(80) == 'B', "80 should be B (boundary)"
print(" ✓ B grades: passed")
# C 評価をテストする
assert calculate_grade(75) == 'C', "75 should be C"
assert calculate_grade(70) == 'C', "70 should be C (boundary)"
print(" ✓ C grades: passed")
# D 評価をテストする
assert calculate_grade(65) == 'D', "65 should be D"
assert calculate_grade(60) == 'D', "60 should be D (boundary)"
print(" ✓ D grades: passed")
# F 評価をテストする
assert calculate_grade(55) == 'F', "55 should be F"
assert calculate_grade(0) == 'F', "0 should be F"
print(" ✓ F grades: passed")
# 境界のエッジケース(各しきい値の1つ下)をテストする
assert calculate_grade(89) == 'B', "89 should be B (just below A)"
assert calculate_grade(79) == 'C', "79 should be C (just below B)"
assert calculate_grade(69) == 'D', "69 should be D (just below C)"
assert calculate_grade(59) == 'F', "59 should be F (just below D)"
print(" ✓ Boundary cases: passed")
print("All tests passed! ✓\n")
# テストを実行する
test_calculate_grade()
# これで自信を持って関数を使えます
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")出力:
Testing calculate_grade...
✓ A grades: passed
✓ B grades: passed
✓ C grades: passed
✓ D grades: passed
✓ F grades: passed
✓ Boundary cases: passed
All tests passed! ✓
Student score 87 = Grade Bこのアプローチの利点:
- テストが明確に整理される: すべてのテストケースを一目で確認できる
- 実行が簡単: 関数を変更するたびに
test_calculate_grade()を呼ぶだけ - 段階的なフィードバック: 実行中にどのテストグループが通ったかが分かる
- 自己文書化: テスト関数が
calculate_grade()の期待動作を正確に示す
テストを実行するタイミング:
- 変更前: 現在のコードでテストが通ることを確認する
- 変更後: 何も壊していないことを検証する
- 機能追加時: 新機能のテストを先に書く(テスト駆動開発(test-driven development))
- バグ修正時: バグを再現するテストを追加し、その後に修正する
このシンプルなパターン(アサーションを使ったテスト関数を書くこと)は、プロフェッショナルなソフトウェアテストの基礎です。上達するにつれて pytest や unittest のようなテストフレームワークも学びますが、中核となる考え方は同じです。コードが正しく動作することを検証する関数を書きます。
41.5.6) アサーションと例外を使い分けるタイミング
アサーションと例外(exceptions)をいつ使うかを理解することは重要です。両者は根本的に異なる目的を持っています。
アサーションは開発中にバグを見つけるためのものです:
- コードが正しく書かれているなら 決して 偽にならないはずのことをチェックします
- 自分のコード内部の仮定やロジックを検証します
- コードを書いてテストしている間に、プログラミングミスを捕捉するのに役立ちます
- 例:「関数のこの時点で、このリストが空であるはずがない」
- 例:「このリストの要素は、いまフィルタしたばかりなので、すべて整数のはずだ」
例外は通常運用中に起こり得るエラーを扱うためのものです:
- 自分では制御できない外部条件に対処します
- コードが完璧でも起こり得る状況を扱います
- プログラムが適切に回復する、または有用な情報を出して失敗することを可能にします
- 例: 数値を期待しているのにユーザーがテキストを入力する
- 例: 開こうとしているファイルが存在しない
- 例: ネットワークリクエストがタイムアウトする
重要な違い: アサーションは「これはあり得ないはずだ」と言い、例外は「これは起こり得るので、こう扱う」と言います。
実例を見てみましょう:
# 例 1: ユーザー入力で使われる関数
# ユーザーは 0 を含め何でも入力し得ます
def calculate_user_ratio(numerator, denominator):
"""ユーザー提供の数値から比率を計算します。"""
# ユーザーが 0 を入力する可能性があるので、例外処理を使う
if denominator == 0:
raise ValueError("Denominator cannot be zero")
return numerator / denominator
# 例 2: 0 が起こり得ないはずの内部計算
def calculate_percentage(part, total):
"""'part' が 'total' の何パーセントかを計算します。"""
# これは total > 0 を検証した後に内部から呼び出されます
# total が 0 なら、それはコードのプログラミングバグです
assert total > 0, "total must be positive - check calling code"
return (part / total) * 100それぞれが扱うべきものの例をさらに挙げます:
| 状況 | アサーションを使う | 例外を使う |
|---|---|---|
| ユーザーが不正な入力をする | ❌ No | ✅ Yes |
| ファイルが存在しない | ❌ No | ✅ Yes |
| ネットワークリクエストが失敗する | ❌ No | ✅ Yes |
| 自分のコードから間違った引数型が渡される | ✅ Yes | ❌ No |
| ロジックエラーでリストが空になってはいけないのに空 | ✅ Yes | ❌ No |
| バグでデータ構造が想定外の状態になる | ✅ Yes | ❌ No |
| データベース接続に失敗する | ❌ No | ✅ Yes |
| API が想定外フォーマットを返す | ❌ No | ✅ Yes |
| アルゴリズムが数学的にあり得ない結果を出す | ✅ Yes | ❌ No |
アサーションの重大な制約:
Python を最適化付きで実行すると、アサーションは完全に無効化できます:
python -O script.py # All assert statements are ignored!アサーションが無効化されると、単に消えます。Python はそれらを一切チェックしません。つまり:
- ❌ 決して ユーザー入力の検証にアサーションを使わない
- ❌ 決して セキュリティチェックにアサーションを使わない
- ❌ 決して 本番環境で常に動作する必要があるものにアサーションを使わない
# 危険 - こうしないでください:
def process_payment(amount):
assert amount > 0, "Amount must be positive" # WRONG! -O で無効化されます
# Process payment...
# 正しい - こうしてください:
def process_payment(amount):
if amount <= 0:
raise ValueError("Amount must be positive") # 常にチェックされます!
# Process payment...まとめ:
-
アサーション = 「開発中に自分のコードのバグをチェックしている」
- 考え方:「正しくコーディングしていればあり得ないはず」
- ロジックの誤りを見つける助けになります
-
例外 = 「現実世界で実際に起こり得る条件に対処している」
- 考え方:「通常利用でも起こり得るので、対処しなければならない」
- 予測不能な状況にプログラムを対応させる助けになります
アサーションは、正しいコードを書くための開発・デバッグツールです。例外は、ユーザー入力、ファイルシステム、ネットワークなど、制御できない外部要因の混沌にプログラムを対処させるための本番ツールです。
これで、プログラミングの旅を通して役立つ、必須のデバッグとテストの技法を学びました:
- エラーが起きた場所を素早く特定するための トレースバックの読み取り
- コードがステップごとに何をしているかを理解するための メンタルでのコード追跡
- 実行時の値と流れを見るために print 文を戦略的に使う
- 扱っているものを理解するために
type()とdir()で オブジェクトを検査する - コードが正しく動作することを検証し、早期にバグを捕捉するための アサーションによるテスト
これらのスキルは、完全なデバッグツールキットとして連携します。問題に遭遇したら:
- トレースバックを読み、どこで失敗したかを見つける
- print デバッグまたはメンタル・トレースを使い、なぜ失敗したかを理解する
- オブジェクトに何ができるか分からないときは type/dir の検査を使う
- バグが再発しないようにアサーションを書く
練習を重ねると、状況ごとにどの技法を使うべきか直感が育ちます。覚えておいてください。すべてのプログラマはコードをデバッグします。違いは、経験豊富なプログラマがそれを体系的かつ効率的に行うことです。これらの技法は、あなたをその仲間入りさせてくれます。