第14回 仕様とテスト#


Open In Colab


この講義で学ぶこと#

これまでの講義では、プログラムが正しく動く「正常系」の書き方を中心に学んできた。 しかし実際のプログラムでは、予期しないエラーが発生することがある。 今回は、プログラムの仕様を明確にし、異常事態に対応する例外処理を学び、プログラムが仕様通り動くことを確認するテストの書き方を学ぶ。

これらは、どのプログラミング言語でも使える汎用的な知識である。 また、生成AIを活用するときにも、仕様とテストの考え方は重要である。

仕様とは#

プログラムの仕様とは、そのプログラムが「何をするものか」を明確に定めたものである。 これまで作成してきた関数にも、実は「仕様」がある。

単純な例として、割り算をする関数を考えてみよう。

def divide(a, b):
    return a / b

この関数の仕様は何だろうか。以下の点を考えてみよう:

  • 何を受け取るのか(引数)

  • 何を返すのか(戻り値)

  • どんな場合に失敗するのか(制約)

実際に使ってみよう。

print(divide(10, 2))  # 正常系: 5.0が期待される
5.0
print(divide(10, 0))  # 異常系: エラーが発生する
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[3], line 1
----> 1 print(divide(10, 0))  # 異常系: エラーが発生する

Cell In[1], line 2, in divide(a, b)
      1 def divide(a, b):
----> 2     return a / b

ZeroDivisionError: division by zero

ZeroDivisionErrorというエラーが発生した。このエラーを防ぐためには、仕様を明確にする必要がある。

仕様を明文化すると、以下のようになる:

関数名: divide
引数: a (数値), b (数値)
戻り値: a を b で割った結果(数値)
前提条件: b は 0 でないこと

Pythonでは、関数の仕様をドキュメンテーション文字列(docstring)として記述するのが一般的である。

def divide(a, b):
    """
    a を b で割った値を返す。

    引数:
        a: 被除数(数値)
        b: 除数(数値、0以外)

    戻り値:
        a / b の計算結果(数値)

    前提条件:
        b は 0 でないこと
    """
    return a / b

ドキュメンテーション文字列を書いておくと、help() 関数で確認できる。

help(divide)
Help on function divide in module __main__:

divide(a, b)
    a を b で割った値を返す。

    引数:
        a: 被除数(数値)
        b: 除数(数値、0以外)

    戻り値:
        a / b の計算結果(数値)

    前提条件:
        b は 0 でないこと

例外処理#

例外とは#

プログラム実行中に発生するエラーのことを例外(exception)という。 先ほどの ZeroDivisionError も例外の一つであり、他にも様々な例外が存在する。

  • FileNotFoundError:存在しないファイルを開こうとしたときに発生する

  • ValueError:不正な値が与えられたときに発生する

  • TypeError:不正な型の値が与えられたときに発生する

  • IndexError:リストや文字列の範囲外のインデックスにアクセスしようとしたときに発生する

例外が発生すると、通常はプログラムが停止してしまう。しかし、例外処理を行うことで、エラーを適切に対処し、プログラムを続行できる。

例外処理は、if文のような制御構造の一種として理解できる。

try-except文#

例外処理を行うには、try-except を使う。基本的な形は以下の通りである:


try:
    # エラーが発生しうるコード
except エラーの種類:
    # エラーが発生したときの処理

divide 関数を例外処理を使って安全にしてみよう。

def safe_divide(a, b):
    """
    a を b で割った値を返す。b が 0 の場合は None を返す。

    引数:
        a: 被除数(数値)
        b: 除数(数値)

    戻り値:
        a / b の計算結果(数値)、または None
    """
    try:
        return a / b
    except ZeroDivisionError:
        print("エラー: 0で割ることはできません")
        return None
print(safe_divide(10, 2))   # 5.0
5.0
print(safe_divide(10, 0))   # エラーメッセージを表示して None を返す
エラー: 0で割ることはできません
None

練習1
ファイルを読み込む以下の関数に、例外処理を追加しなさい。ファイルが見つからない場合は None を返すようにすること。

ヒント: ファイルが見つからないときは FileNotFoundError が発生する。

def read_file(filename):
    """
    ファイルの内容を読み込んで返す。
    ファイルが見つからない場合は None を返す。
    """
    # ここに適切なコードを書く
    pass
# テスト(存在しないファイル名で試す)
result = read_file("存在しないファイル.txt")
print(result)  # None が表示されれば成功

解答例

def read_file(filename):
    try:
        f = open(filename, 'r', encoding='utf-8')
        content = f.read()
        f.close()
        return content
    except FileNotFoundError:
        print(f"エラー: {filename} が見つかりません")
        return None

テストとは#

テストの必要性#

さて、プログラムが仕様通りに動くことを確認するにはどうすればよいだろうか?

人間がプログラムを実行して結果を目視確認する方法もあるが、人間にはミスがあり、毎回の確認は負担が大きい。そのため、本格的なソフトウェア開発においては、プログラムの動作を自動的に検証する仕組みが有効 である。 そのような、プログラムが仕様通りに動くことを確認するための別のプログラムを、 ソフトウェアテスト(テスト) と呼ぶ。

テストを書くことには、以下のメリットがある:

  1. 仕様が明確になる:どう動くべきかが明確になる

  2. 変更に強くなる:後でプログラムを書き換えたときに、意図せず新たな問題を加えていないことを、確信できる

  3. バグを早期に発見できる

  4. ドキュメントになる

assert文を使ったテスト#

最もシンプルなテストの方法は、assert文を使うことである。 assert は「〜であることを確認する」という意味で、条件が False のときに例外を発生させる。

基本的な形は以下の通り:


assert 条件式

簡単な例で試してみよう。

# これは成功する(何も起きない)
assert 2 + 2 == 4
print("テスト成功")
テスト成功
# これは失敗する(AssertionError が発生)
assert 2 + 2 == 5
print("この行は実行されない")

関数のテスト#

関数をテストする場合は、期待される結果と実際の結果を比較する。 簡単な足し算の関数でテストを書いてみよう。

def add(a, b):
    """2つの数を足す"""
    return a + b

# 正常系のテスト
assert add(2, 3) == 5
assert add(0, 0) == 0
assert add(-1, 1) == 0
assert add(100, 200) == 300

print("全てのテストが成功しました!")
全てのテストが成功しました!

正常系と異常系のテスト#

テストには、正常系(期待通りの入力)と異常系(予期しない入力)の両方を含めるべきである。

先ほどの safe_divide 関数のテストを書いてみよう。

# 正常系のテスト
assert safe_divide(10, 2) == 5
assert safe_divide(7, 2) == 3.5
assert safe_divide(0, 5) == 0

# 異常系のテスト
assert safe_divide(10, 0) is None
assert safe_divide(0, 0) is None

print("全てのテストが成功しました!")
エラー: 0で割ることはできません
エラー: 0で割ることはできません
全てのテストが成功しました!

練習2
以下の仕様を満たす get_first 関数を実装し、その後テストを書いて確認しなさい。

仕様:
  関数名: get_first
  引数: lst (リスト)
  戻り値: リストの最初の要素、リストが空の場合は None
def get_first(lst):
    """リストの最初の要素を返す。空の場合は None を返す。"""
    # ここに実装を書く
    pass
# ここにテストを書く(正常系3つ、異常系1つ以上)
# 例: assert get_first([1, 2, 3]) == 1

解答例

def get_first(lst):
    if len(lst) == 0:
        return None
    return lst[0]

# テスト
assert get_first([1, 2, 3]) == 1
assert get_first(["a", "b"]) == "a"
assert get_first([100]) == 100
assert get_first([]) is None
print("全てのテストが成功しました!")

テストと仕様の関係#

従来の開発フロー#

これまでは、以下のような流れでプログラムを作成してきた:

  1. 仕様を考える

  2. コードを書く

  3. テストする

しかし、実際のソフトウェア開発では、仕様を最初から完璧に決めるのは非常に困難である。

テストファーストの考え方#

参考として、テスト駆動開発(Test-Driven Development, TDD)という開発手法を紹介する。 これは、以下の流れで開発を進める方法である:

  1. テストを先に書く

  2. テストが通るようにコードを書く

  3. コードを改善する

TDDは主流の開発手法ではないが、テストの書き方や仕様の考え方を学ぶには有用な練習方法である。

例:TDDで関数を作る#

文字列から空白を全て削除する関数を、TDDの流れで作ってみよう。

Step 1: まずテストを書く(この時点では関数は存在しない)

# テストを先に書く
def test_remove_spaces():
    assert remove_spaces("hello world") == "helloworld"
    assert remove_spaces("  a  b  ") == "ab"
    assert remove_spaces("") == ""
    assert remove_spaces("abc") == "abc"
    print("全てのテストが成功しました!")

# この時点ではまだ関数が存在しないので、実行するとエラーになる
# test_remove_spaces()  # NameError: name 'remove_spaces' is not defined

Step 2: テストを満たすようにコードを書く

def remove_spaces(text):
    """文字列から空白を全て削除する"""
    return text.replace(" ", "")

Step 3: テストを実行

test_remove_spaces()  # 成功!

練習3
TDDの練習として、回文(前から読んでも後ろから読んでも同じ文字列)かどうかを判定する関数を作成しなさい。

まず以下のテストが通るように is_palindrome 関数を実装すること。

ヒント: 文字列を反転するには text[::-1] を使う。

# Step 1: まずテストを書く
def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True

    assert is_palindrome("a") == True
    print("全てのテストが成功しました!")
# Step 2: テストが通るように is_palindrome 関数を実装しなさい
def is_palindrome(text):
    # ここに実装を書く
    pass
# Step 3: テストを実行
test_is_palindrome()

解答例

def is_palindrome(text):
    return text == text[::-1]

テストの品質#

テストの正しさはどう確かめるか#

これまでテストの書き方を学んできたが、テスト自体が間違っていたらどうなるだろうか?

テストにバグがあると、正しいコードを「間違っている」と判定したり、逆に間違ったコードを「正しい」と判定したりしてしまう。

簡単な例で確認してみよう。

# 足し算の関数(正しい実装)
def add(a, b):
    return a + b

# 間違ったテスト(期待値が間違っている)
assert add(2, 3) == 6  # 本当は5なのに6と書いてしまった
print("テスト成功")
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In[14], line 6
      3     return a + b
      5 # 間違ったテスト(期待値が間違っている)
----> 6 assert add(2, 3) == 6  # 本当は5なのに6と書いてしまった
      7 print("テスト成功")

AssertionError: 

上のテストは失敗する。しかし、問題はコードではなくテストの期待値が間違っていることである。

このように、テスト自体にもバグが混入する可能性がある。テストの正しさを確保するための技法として以下が挙げられる。

  1. テストをレビューする:他の人にテストを見てもらう

  2. テストを先に書く:実装前にテストを書くことで、仕様を明確にする

  3. シンプルに保つ:間違いやすい複雑なテストを作らない

テストは負債である#

テストは重要だが、テストもコードであることを忘れてはいけない。

テストが増えると、以下のような問題が発生する:

  • 保守コストがかかる:コードを変更するたびに、関連するテストも更新が必要

  • 実行時間が増える:テストが多いと、全てのテストを実行するのに時間がかかる

  • 読みづらくなる:テストコードが多すぎると、何をテストしているのかわかりにくくなる

そのため、むやみにテストを増やすのではなく、適切なテストケースを選ぶことが重要である。

次のセクションでは、効率的にテストケースを選ぶための技法を学ぶ。

より進んだテスト技法#

同値分割:テストケースを合理的に減らす#

同値分割とは、入力を「同じように振る舞うグループ」に分けて、各グループから1つずつテストケースを選ぶ技法である。

例として、日本での飲酒可否を判定する関数を考えよう。

def can_drink(age):
    """
    年齢から飲酒可否を判定する。

    引数:
        age: 年齢(整数)

    戻り値:
        飲酒可能なら True、不可なら False、ただし0歳未満または150歳を超えるなら None(異常値)
    """
    if age < 0 or age > 150:
        return None  # 異常値
    elif age >= 20:
        return True  # 飲酒OK
    else:
        return False  # 飲酒NG

年齢の入力値を同値分割すると、以下のようなグループに分けられる:

グループ

値の範囲

期待される振る舞い

異常値(負)

age < 0

None を返す

未成年

0 ≤ age < 20

False を返す

成人

20 ≤ age ≤ 150

True を返す

異常値(正)

age > 150

None を返す

各グループから1つずつ代表的な値を選んでテストする。

# 同値分割によるテスト(各グループから1つずつ)
assert can_drink(-5) is None    # 異常値(負)の代表
assert can_drink(10) == False   # 未成年の代表
assert can_drink(30) == True    # 成人の代表
assert can_drink(200) is None   # 異常値(正)の代表

print("同値分割テスト成功!")
同値分割テスト成功!

境界値分析:バグが潜みやすい箇所を狙い撃つ#

境界値分析とは、バグは境界で起きやすいという経験則に基づき、同値分割の境界付近の値を重点的にテストする技法である。

プログラマは、>=> を間違えたり、-1 すべきところを忘れたりといったミスをしやすい。 そのため、境界値とその前後の値をテストすることで、こうしたバグを効率的に発見できる。

先ほどの can_drink 関数の境界値は、0, 20, 150 である。 これらの境界とその前後をテストしてみよう。

# 境界値分析によるテスト
# 境界値 0 の前後
assert can_drink(-1) is None
assert can_drink(0) == False
assert can_drink(1) == False

# 境界値 20 の前後
assert can_drink(19) == False
assert can_drink(20) == True
assert can_drink(21) == True

# 境界値 150 の前後
assert can_drink(149) == True
assert can_drink(150) == True
assert can_drink(151) is None

print("境界値分析テスト成功!")
境界値分析テスト成功!

デシジョンテーブル:複数の条件を整理する#

複数の条件が組み合わさる場合、デシジョンテーブル(決定表)を使うと、テストケースを漏れなく整理できる。

例として、じゃんけんの勝敗判定を考えよう。自分の手と相手の手の組み合わせは 3 × 3 = 9通りある。

def judge_janken(my_hand, opp_hand):
    """
    じゃんけんの勝敗を判定する。

    引数:
        my_hand: 自分の手("グー", "チョキ", "パー")
        opp_hand: 相手の手("グー", "チョキ", "パー")

    戻り値:
        "勝ち", "負け", "引き分け"
    """
    if my_hand == opp_hand:
        return "引き分け"
    elif (my_hand == "グー" and opp_hand == "チョキ") or \
         (my_hand == "チョキ" and opp_hand == "パー") or \
         (my_hand == "パー" and opp_hand == "グー"):
        return "勝ち"
    else:
        return "負け"

デシジョンテーブルで整理すると以下のようになる:

自分の手

相手の手

結果

グー

グー

引き分け

グー

チョキ

勝ち

グー

パー

負け

チョキ

グー

負け

チョキ

チョキ

引き分け

チョキ

パー

勝ち

パー

グー

勝ち

パー

チョキ

負け

パー

パー

引き分け

この表から、9通り全てのテストケースを作成できる。

# デシジョンテーブルに基づくテスト(9通り全て)
assert judge_janken("グー", "グー") == "引き分け"
assert judge_janken("グー", "チョキ") == "勝ち"
assert judge_janken("グー", "パー") == "負け"
assert judge_janken("チョキ", "グー") == "負け"
assert judge_janken("チョキ", "チョキ") == "引き分け"
assert judge_janken("チョキ", "パー") == "勝ち"
assert judge_janken("パー", "グー") == "勝ち"
assert judge_janken("パー", "チョキ") == "負け"
assert judge_janken("パー", "パー") == "引き分け"

print("デシジョンテーブルテスト成功!")
デシジョンテーブルテスト成功!

練習4
BMI値から肥満度を判定する関数を考える。以下の仕様に基づいて、同値分割と境界値分析を行い、適切なテストケースを作成しなさい。

仕様:
  関数名: judge_bmi
  引数: bmi (数値)
  戻り値: 肥満度(文字列)
    - 18.5未満: "低体重"
    - 18.5以上25未満: "普通体重"
    - 25以上: "肥満"
    - 0以下または100超: None(異常値)
def judge_bmi(bmi):
    """BMI値から肥満度を判定する"""
    # ここに実装を書く
    pass

# ここに同値分割のテストを書く(各グループから1つずつ)


# ここに境界値分析のテストを書く(境界値とその前後)

解答例

def judge_bmi(bmi):
    if bmi <= 0 or bmi > 100:
        return None
    elif bmi < 18.5:
        return "低体重"
    elif bmi < 25:
        return "普通体重"
    else:
        return "肥満"

# 同値分割のテスト(各グループから1つ)
assert judge_bmi(-5) is None        # 異常値(負)
assert judge_bmi(17) == "低体重"    # 低体重グループ
assert judge_bmi(22) == "普通体重"  # 普通体重グループ
assert judge_bmi(30) == "肥満"      # 肥満グループ
assert judge_bmi(150) is None       # 異常値(大)

# 境界値分析のテスト(境界値 0, 18.5, 25, 100 とその前後)
assert judge_bmi(0) is None
assert judge_bmi(0.1) == "低体重"
assert judge_bmi(18.4) == "低体重"
assert judge_bmi(18.5) == "普通体重"
assert judge_bmi(18.6) == "普通体重"
assert judge_bmi(24.9) == "普通体重"
assert judge_bmi(25) == "肥満"
assert judge_bmi(25.1) == "肥満"
assert judge_bmi(100) == "肥満"
assert judge_bmi(100.1) is None

print("全てのテストが成功しました!")

生成AIとの関係#

仕様が曖昧だとAIも困る#

次回の講義では、生成AI(ChatGPTやClaude等)との付き合い方を学ぶ。 生成AIにコードを書いてもらう際、仕様が曖昧だと、AIも何を作ればいいか分からない

例えば、以下のような曖昧な指示を考えてみよう:

「数を足す関数を作って」

AIは以下のどれを作るべきか判断できない:

# 2つの数を足す?
def add(a, b):
    return a + b

# 複数の数を足す?
def add(*args):
    return sum(args)

# リストの要素を全て足す?
def add(numbers):
    return sum(numbers)

仕様を明確にすることで、AIに正確な指示を出せる。

テストがあればAIの出力を検証できる#

テストを書いてAIに渡すことで、AIが生成したコードが正しいかを自動的に検証できる。

例えば、以下のようにテストと一緒に指示を出す:

「以下のテストが通る関数を作ってください:

assert count_vowels("hello") == 2
assert count_vowels("AEIOU") == 5
assert count_vowels("xyz") == 0

このようにすれば、AIが何を作るべきかが明確になり、生成されたコードが正しいかもテストで確認できる。

AI時代における人間の役割#

生成AIが発達しても、人間には以下の役割がある:

  1. 仕様を考える:何を作りたいのかを明確にする

  2. テストを考える:期待する動作を定義する

  3. 検証する:AIの出力が正しいかを確認する

  4. 問題を分解する:複雑な問題を小さな部分に分ける

これらはいずれも、プログラミングの基礎的な知識がないとできないことである。

演習#

課題1
以下の仕様を満たす calculate_grade 関数を実装しなさい。

仕様:
  関数名: calculate_grade
  引数: score (整数)
  戻り値: 評価(文字列)
    - 90以上: "A"
    - 80以上90未満: "B"  
    - 70以上80未満: "C"
    - 60以上70未満: "D"
    - 60未満: "F"
    - 0未満または100超: None(異常値)
def calculate_grade(score):
    """
    点数から成績評価を返す。
    0未満または100超の場合は None を返す。
    """
    # ここに実装を書く
    pass
# テストを実行(このセルは変更しないこと)
def test_calculate_grade():
    # 正常系のテスト
    assert calculate_grade(95) == "A"
    assert calculate_grade(90) == "A"
    assert calculate_grade(85) == "B"
    assert calculate_grade(80) == "B"
    assert calculate_grade(75) == "C"
    assert calculate_grade(70) == "C"
    assert calculate_grade(65) == "D"
    assert calculate_grade(60) == "D"
    assert calculate_grade(55) == "F"
    assert calculate_grade(0) == "F"

    # 異常系のテスト
    assert calculate_grade(-1) is None
    assert calculate_grade(101) is None

    print("PASS")

test_calculate_grade()

課題2
以下の仕様を満たす find_max 関数を実装しなさい。

仕様:
  関数名: find_max
  引数: numbers (リスト)
  戻り値: リストの中の最大値(数値)
         リストが空の場合は None
def find_max(numbers):
    """
    リストの中の最大値を返す。
    リストが空の場合は None を返す。
    """
    # ここに実装を書く
    pass
# テストを実行(このセルは変更しないこと)
def test_find_max():
    assert find_max([1, 5, 3, 2]) == 5
    assert find_max([10]) == 10
    assert find_max([-1, -5, -3]) == -1
    assert find_max([100, 200, 150]) == 200
    assert find_max([]) is None
    print("PASS")

test_find_max()

課題3(デバッグ練習)
以下の is_sorted 関数は、リストが昇順にソートされているかを判定する関数である。 しかし、実装にバグがある

テストを実行して、どこが間違っているかを見つけ、正しく修正しなさい。

仕様:
  関数名: is_sorted
  引数: lst (リスト)
  戻り値: リストが昇順にソートされていれば True、そうでなければ False
  注意: 同じ値が連続していても昇順とみなす(例: [1, 2, 2, 3] は昇順)
# バグのある実装を修正しなさい

def is_sorted(lst):
    """リストが昇順にソートされているか判定する"""

    for i in range(len(lst) - 1):
        if lst[i] >= lst[i + 1]:
            return False
    return True
# テストを実行(このセルは変更しないこと)
def test_is_sorted():
    assert is_sorted([1, 2, 3, 4]) == True
    assert is_sorted([1, 2, 2, 3]) == True
    assert is_sorted([1, 1, 1]) == True
    assert is_sorted([3, 2, 1]) == False
    assert is_sorted([]) == True
    print("PASS")

test_is_sorted()
ヒント テストを実行すると `assert is_sorted([1, 2, 2, 3]) == True` で失敗する。 この入力に対して、関数は何を返しているだろうか?

また、仕様をもう一度読んでみよう:「同じ値が連続していても昇順とみなす」

条件式の >=> のどちらを使うべきだろうか?