テスト駆動開発入門
テスト駆動開発とは
これまでは、まずコードを実装し、その後意図した通りに動いているかチェックしていたと思う。しかし、それだとチェックの抜け漏れが発生したり、リファクタリングで意図せずコードの振る舞いが変わってしまったりして、品質が低下してしまう可能性がある。
そこで、しばしば、品質をプロセスに組み込む方法としてテスト駆動開発が用いられる。テスト駆動開発ではまずテストコード(コードをチェックするコード)を書き、その後で実際にコード(テストコードと区別して以下「プロダクトコード」と呼ぶ)を実装する。より具体的には、以下の順序で開発を進めていく。
テスト駆動開発には、以下に挙げるようなメリットがある。
テストコードは、準備(Arrange)、実行(Act)、検証(Assert)の3段階で構成される(AAAパターン)。テストコードの実装する際には、ゴールを明確にするため、検証、実行、準備の順で記述するとわかりやすい。例として、Stackクラスのsizeメソッドのテストコードは以下に示す。
def test_sample():
stack = Stack() # 準備(Arrange)
stack.push('aaa') # 実行(Act)
assert stack.size() == 1 # 検証(Assert)
実装がシンプルな場合には、準備、実行、検証を一行でまとめて記述しても良い。
def test_sample():
assert stack(['aaa', 'bbb', 'ccc']).size() == 3
テストコードが複雑だと感じるときは、扱いにくいモジュールを作ってしまっている場合が多く、テストコードがシンプルになるようにプロダクトコードの設計を見直した方が良い。
テスト駆動開発を体験する(準備編)
まずテスト駆動開発の準備として、テストの実行方法、およびテスト成功時と失敗時の挙動を確認しておく。
以下のようなファイル構成で設定ファイル群を作成していく
venv_sample
├ src
| ├ __init__.py
| └ sample.py
└ tests
├ __init__.py
└ test_sample.py
# src/__init__.py
# src/sample.py
def add(a, b):
return a + b
# tests/__init__.py
# tests/test_sample.py
from src import sample
def test_add():
assert sample.add(1, 2) == 3
テストが成功すると、緑のチェックマークが表示される。
テストが失敗するようにコードを書き換えて再度テストを実行すると、赤いバツマークとエラーメッセージが表示される。
# tests/test_sample.py
from src import sample
def test_add():
assert main.add(1, 2) == 4 # ←テストが失敗するように変更
テスト駆動開発を体験する(実践編)
例として「じゃんけんプログラム」をテスト駆動開発で実装していく。
【振る舞いリスト】
・場に出ている手に対して、どの手が強いのか判定する(対象)
・各プレイヤーの勝ち負け引き分けを判定する(対象)
# tests/test_main.py
from src import main
def test_get_strong_hand():
assert main.get_strong_hand(['Goo','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki']) == 'Goo'
assert main.get_strong_hand(['Choki','Par']) == 'Choki'
assert main.get_strong_hand(['Goo','Goo']) is None
assert main.get_strong_hand(['Choki','Choki']) is None
assert main.get_strong_hand(['Par','Par']) is None
assert main.get_strong_hand(['Goo','Choki','Par']) is None
assert main.get_strong_hand(['Goo','Par','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki','Choki']) == 'Goo'
def test_judge_win_lose():
assert main.judge_win_lose('Goo', strong_hand='Goo') == "Win"
assert main.judge_win_lose('Choki', strong_hand='Choki') == "Win"
assert main.judge_win_lose('Par', strong_hand='Par') == "Win"
assert main.judge_win_lose('Par', strong_hand='Choki') == "Lose"
assert main.judge_win_lose('Par', strong_hand=None) == "Drow"
テストを実行すると、get_strong_hand関数とjudge_win_lose関数はまだ実装していないのでAttributeErrorがでる。
AttributeError: module 'src.main' has no attribute 'get_strong_hand'
AttributeError: module 'src.main' has no attribute 'judge_win_lose'
# src/main.py
# 強い手を返す関数の定義
def get_strong_hand(hands):
unique_hands = set(hands)
if unique_hands == set(['Goo', 'Choki']):
return 'Goo'
elif unique_hands == set(['Choki', 'Par']):
return 'Choki'
elif unique_hands == set(['Goo', 'Par']):
return 'Par'
else:
return None
# 勝敗を判定する関数の定義
def judge_win_lose(hand, strong_hand):
if strong_hand is None:
return 'Drow'
elif hand == strong_hand:
return 'Win'
else:
return 'Lose'
※ 上のコードをそのまま実行すると、すぐにテストを成功させるられるが、実際の実装時は何回もテストに失敗し、エラーメッセージをみながら試行錯誤した後にテストを成功させることになる。
【振る舞いリスト】
・場に出ている手に対して、どの手が強いのか判定する(済)
・各プレイヤーの勝ち負け引き分けを判定する(済)
・次の手の生成する関数を作成する(対象)
・次の手の生成する関数を実行する関数を作成する(対象)
# tests/test_main.py
from src import main
def test_get_strong_hand():
assert main.get_strong_hand(['Goo','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki']) == 'Goo'
assert main.get_strong_hand(['Choki','Par']) == 'Choki'
assert main.get_strong_hand(['Goo','Goo']) is None
assert main.get_strong_hand(['Choki','Choki']) is None
assert main.get_strong_hand(['Par','Par']) is None
assert main.get_strong_hand(['Goo','Choki','Par']) is None
assert main.get_strong_hand(['Goo','Par','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki','Choki']) == 'Goo'
def test_judge_win_lose():
assert main.judge_win_lose('Goo', strong_hand='Goo') == "Win"
assert main.judge_win_lose('Choki', strong_hand='Choki') == "Win"
assert main.judge_win_lose('Par', strong_hand='Par') == "Win"
assert main.judge_win_lose('Par', strong_hand='Choki') == "Lose"
assert main.judge_win_lose('Par', strong_hand=None) == "Drow"
### ↓ 以下にテストコードを追加 ###
def test_constant_hand_generator():
hand_generator = main.constant_hand_generator('Goo')
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
hand_generator = main.constant_hand_generator('Choki')
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
hand_generator = main.constant_hand_generator('Par')
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
def test_get_player_snapshot():
player = {'name':'Hogehoge', 'hand_generator':main.constant_hand_generator('Par')}
player_snapshot = main.get_player_snapshot(player)
assert player_snapshot == {'name':'Hogehoge', 'hand':'Par'}
テストを実行すると、constant_hand_generator関数はまだ実装していないのでAttributeErrorがでる。
AttributeError: module 'src.main' has no attribute 'constant_hand_generator'
# src/main.py
# 強い手を返す関数の定義
def get_strong_hand(hands):
unique_hands = set(hands)
if unique_hands == set(['Goo', 'Choki']):
return 'Goo'
elif unique_hands == set(['Choki', 'Par']):
return 'Choki'
elif unique_hands == set(['Goo', 'Par']):
return 'Par'
else:
return None
# 勝敗を判定する関数の定義
def judge_win_lose(hand, strong_hand):
if strong_hand is None:
return 'Drow'
elif hand == strong_hand:
return 'Win'
else:
return 'Lose'
### ↓ 以下にプロダクトコードを追加 ###
# 次の手を生成する関数の定義
def constant_hand_generator(hand):
while True:
yield hand
# 次の手を生成する関数を実行する関数の定義
def get_player_snapshot(player):
player_snapshot = {
'name': player['name'],
'hand': next(player['hand_generator'])
}
return player_snapshot
※ 上のコードをそのまま実行すると、すぐにテストを成功させるられるが、実際の実装時は何回もテストに失敗し、エラーメッセージをみながら試行錯誤した後にテストを成功させることになる。
【振る舞いリスト】
・場に出ている手に対して、どの手が強いのか判定する(済)
・各プレイヤーの勝ち負け引き分けを判定する(済)
・次の手の生成する関数を作成する(済)
・次の手の生成する関数を実行する関数を作成する(済)
・次の手を「グー→チョキ→パー」の順に生成する関数を追加する(対象)
・次の手をランダムに生成する関数を追加する(対象)
# tests/test_main.py
from src import main
def test_get_strong_hand():
assert main.get_strong_hand(['Goo','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki']) == 'Goo'
assert main.get_strong_hand(['Choki','Par']) == 'Choki'
assert main.get_strong_hand(['Goo','Goo']) is None
assert main.get_strong_hand(['Choki','Choki']) is None
assert main.get_strong_hand(['Par','Par']) is None
assert main.get_strong_hand(['Goo','Choki','Par']) is None
assert main.get_strong_hand(['Goo','Par','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki','Choki']) == 'Goo'
def test_judge_win_lose():
assert main.judge_win_lose('Goo', strong_hand='Goo') == "Win"
assert main.judge_win_lose('Choki', strong_hand='Choki') == "Win"
assert main.judge_win_lose('Par', strong_hand='Par') == "Win"
assert main.judge_win_lose('Par', strong_hand='Choki') == "Lose"
assert main.judge_win_lose('Par', strong_hand=None) == "Drow"
def test_get_constant_hand_generator():
hand_generator = main.constant_hand_generator('Goo')
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
hand_generator = main.constant_hand_generator('Choki')
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
hand_generator = main.constant_hand_generator('Par')
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
def test_get_player_snapshot():
player = {'name':'Hogehoge', 'hand_generator':main.constant_hand_generator('Par')}
player_snapshot = main.get_player_snapshot(player)
assert player_snapshot['name'] == 'Hogehoge'
assert player_snapshot['hand'] == 'Par'
### ↓ 以下にテストコードを追加 ###
player = {'name':'Hogehoge', 'hand_generator':main.turn_hand_generator()}
player_snapshot = main.get_player_snapshot(player)
assert player_snapshot['name'] == 'Hogehoge'
assert player_snapshot['hand'] == 'Goo'
def test_get_turn_generator():
hand_generator = main.turn_hand_generator()
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Par'
def test_get_randam_generator():
hand_generator = main.randam_hand_generator()
assert next(hand_generator) in ['Goo','Choki','Par']
assert next(hand_generator) in ['Goo','Choki','Par']
assert next(hand_generator) in ['Goo','Choki','Par']
テストを実行すると、turn_hand_generator関数とrandam_hand_generator関数はまだ実装していないのでAttributeErrorがでる。
AttributeError: module 'src.main' has no attribute 'turn_hand_generator'
AttributeError: module 'src.main' has no attribute 'randam_hand_generator'
# src/main.py
import random
# 強い手を返す関数の定義
def get_strong_hand(hands):
unique_hands = set(hands)
if unique_hands == set(['Goo', 'Choki']):
return 'Goo'
elif unique_hands == set(['Choki', 'Par']):
return 'Choki'
elif unique_hands == set(['Goo', 'Par']):
return 'Par'
else:
return None
# 勝敗を判定する関数の定義
def judge_win_lose(hand, strong_hand):
if strong_hand is None:
return 'Drow'
elif hand == strong_hand:
return 'Win'
else:
return 'Lose'
# 次の手を生成する関数の定義
def constant_hand_generator(hand):
while True:
yield hand
# 次の手を生成する関数を実行する関数の定義
def get_player_snapshot(player):
player_snapshot = {
'name': player['name'],
'hand': next(player['hand_generator'])
}
return player_snapshot
### ↓ 以下にプロダクトコードを追加 ###
# 次の手を「グー→チョキ→パー」の順に生成する関数
def turn_hand_generator():
count = 0
turn_hands = ['Goo', 'Choki', 'Par']
while True:
yield turn_hands[count % 3]
count += 1
# 次の手をランダムに生成する関数の定義
def randam_hand_generator():
while True:
yield random.choice(['Goo', 'Choki', 'Par'])
※ 上のコードをそのまま実行すると、すぐにテストを成功させるられるが、実際の実装時は何回もテストに失敗し、エラーメッセージをみながら試行錯誤した後にテストを成功させることになる。
【振る舞いリスト】
・場に出ている手に対して、どの手が強いのか判定する(済)
・各プレイヤーの勝ち負け引き分けを判定する(済)
・次の手の生成する関数を作成する(済)
・次の手の生成する関数を実行する関数を作成する(済)
・次の手を「グー→チョキ→パー」の順に生成する関数を追加する(済)
・次の手をランダムに生成する関数を追加する(済)
・複数の関数をまとめてじゃんけんを実施する関数を作成する(対象)
# tests/test_main.py
from src import main
def test_get_strong_hand():
assert main.get_strong_hand(['Goo','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki']) == 'Goo'
assert main.get_strong_hand(['Choki','Par']) == 'Choki'
assert main.get_strong_hand(['Goo','Goo']) is None
assert main.get_strong_hand(['Choki','Choki']) is None
assert main.get_strong_hand(['Par','Par']) is None
assert main.get_strong_hand(['Goo','Choki','Par']) is None
assert main.get_strong_hand(['Goo','Par','Par']) == 'Par'
assert main.get_strong_hand(['Goo','Choki','Choki']) == 'Goo'
def test_judge_win_lose():
assert main.judge_win_lose('Goo', strong_hand='Goo') == "Win"
assert main.judge_win_lose('Choki', strong_hand='Choki') == "Win"
assert main.judge_win_lose('Par', strong_hand='Par') == "Win"
assert main.judge_win_lose('Par', strong_hand='Choki') == "Lose"
assert main.judge_win_lose('Par', strong_hand=None) == "Drow"
def test_get_constant_hand_generator():
hand_generator = main.constant_hand_generator('Goo')
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Goo'
hand_generator = main.constant_hand_generator('Choki')
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Choki'
hand_generator = main.constant_hand_generator('Par')
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Par'
def test_get_player_snapshot():
player = {'name':'Hogehoge', 'hand_generator':main.constant_hand_generator('Par')}
player_snapshot = main.get_player_snapshot(player)
assert player_snapshot['name'] == 'Hogehoge'
assert player_snapshot['hand'] == 'Par'
def test_get_turn_generator():
hand_generator = main.turn_hand_generator()
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Par'
assert next(hand_generator) == 'Goo'
assert next(hand_generator) == 'Choki'
assert next(hand_generator) == 'Par'
def test_get_randam_generator():
hand_generator = main.randam_hand_generator()
assert next(hand_generator) in ['Goo','Choki','Par']
assert next(hand_generator) in ['Goo','Choki','Par']
assert next(hand_generator) in ['Goo','Choki','Par']
### ↓ 以下にテストコードを追加 ###
def test_fight():
player1 = {'name':'Emi', 'hand_generator':main.constant_hand_generator('Par')}
player2 = {'name':'Bob', 'hand_generator':main.constant_hand_generator('Par')}
player3 = {'name':'Ken', 'hand_generator':main.turn_hand_generator()}
players = [player1, player2, player3]
# 1回戦
result = main.fight(players)
assert result == [
{'name':'Emi', 'hand':'Par', 'judge':'Win'},
{'name':'Bob', 'hand':'Par', 'judge':'Win'},
{'name':'Ken', 'hand':'Goo', 'judge':'Lose'},
]
# 2回戦
result = main.fight(players)
assert result == [
{'name':'Emi', 'hand':'Par', 'judge':'Lose'},
{'name':'Bob', 'hand':'Par', 'judge':'Lose'},
{'name':'Ken', 'hand':'Choki', 'judge':'Win'},
]
# 3回戦
result = main.fight(players)
assert result == [
{'name':'Emi', 'hand':'Par', 'judge':'Drow'},
{'name':'Bob', 'hand':'Par', 'judge':'Drow'},
{'name':'Ken', 'hand':'Par', 'judge':'Drow'},
]
# Bobが手を変更して4回戦
player2 = {'name':'Bob', 'hand_generator':main.constant_hand_generator('Choki')}
players = [player1, player2, player3]
result = main.fight(players)
assert result == [
{'name':'Emi', 'hand':'Par', 'judge':'Drow'},
{'name':'Bob', 'hand':'Choki', 'judge':'Drow'},
{'name':'Ken', 'hand':'Goo', 'judge':'Drow'},
]
テストを実行すると、fight関数はまだ実装していないのでAttributeErrorがでる。
AttributeError: module 'src.main' has no attribute 'fight'
# src/main.py
import random
# 強い手を返す関数の定義
def get_strong_hand(hands):
unique_hands = set(hands)
if unique_hands == set(['Goo', 'Choki']):
return 'Goo'
elif unique_hands == set(['Choki', 'Par']):
return 'Choki'
elif unique_hands == set(['Goo', 'Par']):
return 'Par'
else:
return None
# 勝敗を判定する関数の定義
def judge_win_lose(hand, strong_hand):
if strong_hand is None:
return 'Drow'
elif hand == strong_hand:
return 'Win'
else:
return 'Lose'
# 次の手を生成する関数の定義
def constant_hand_generator(hand):
while True:
yield hand
# 次の手を生成する関数を実行する関数の定義
def get_player_snapshot(player):
player_snapshot = {
'name': player['name'],
'hand': next(player['hand_generator'])
}
return player_snapshot
# 次の手を「グー→チョキ→パー」の順に生成する関数
def turn_hand_generator():
count = 0
turn_hands = ['Goo', 'Choki', 'Par']
while True:
yield turn_hands[count % 3]
count += 1
# 次の手をランダムに生成する関数の定義
def randam_hand_generator():
while True:
yield random.choice(['Goo', 'Choki', 'Par'])
### ↓ 以下にプロダクトコードを追加 ###
# じゃんけんを実施する関数
def fight(players):
players_snapshot = [get_player_snapshot(player) for player in players]
hands = [player_snapshot['hand'] for player_snapshot in players_snapshot]
strong_hand = get_strong_hand(hands)
result = [{
'name': player_snapshot['name'],
'hand': player_snapshot['hand'],
'judge': judge_win_lose(player_snapshot['hand'], strong_hand)
} for player_snapshot in players_snapshot]
return result
if __name__ == "__main__":
player1 = {'name':'Emi', 'hand_generator':constant_hand_generator('Par')}
player2 = {'name':'Ken', 'hand_generator':randam_hand_generator()}
player3 = {'name':'Bob', 'hand_generator':turn_hand_generator()}
players = [player1, player2, player3]
result = fight(players)
print(result)
※ 上のコードをそのまま実行すると、すぐにテストを成功させるられるが、実際の実装時は何回もテストに失敗し、エラーメッセージをみながら試行錯誤した後にテストを成功させることになる。
# src/main.py
import random
from typing import Optional, Literal, TypedDict, Callable
from collections.abc import Iterator
# 型の定義
Hand = Literal['Goo', 'Choki', 'Par']
Judge = Literal['Win', 'Drow', 'Lose']
class Player(TypedDict):
name: str
hand_generator: Iterator[Hand]
class PlayerSnapshot(TypedDict):
name: str
hand: Hand
class Result(TypedDict):
name: str
hand: Hand
judge: Judge
# 強い手を返す関数の定義
def get_strong_hand(hands: list[Hand]) -> Optional[Hand]:
unique_hands = set(hands)
if unique_hands == set(['Goo', 'Choki']):
return 'Goo'
elif unique_hands == set(['Choki', 'Par']):
return 'Choki'
elif unique_hands == set(['Goo', 'Par']):
return 'Par'
else:
return None
# 勝敗を判定する関数の定義
def judge_win_lose(hand: Hand, strong_hand: Optional[Hand]) -> Judge:
if strong_hand is None:
return 'Drow'
elif hand == strong_hand:
return 'Win'
else:
return 'Lose'
# 次の手を生成する関数の定義
def constant_hand_generator(hand: Hand) -> Iterator[Hand]:
while True:
yield hand
# 次の手を「グー→チョキ→パー」の順に生成する関数
def turn_hand_generator() -> Iterator[Hand]:
count = 0
turn_hands: list[Hand] = ['Goo', 'Choki', 'Par']
while True:
yield turn_hands[count % 3]
count += 1
# 次の手をランダムに生成する関数の定義
def randam_hand_generator() -> Iterator[Hand]:
while True:
yield random.choice(['Goo', 'Choki', 'Par'])
# 次の手を生成する関数を実行する関数の定義
def get_player_snapshot(player: Player) -> PlayerSnapshot:
player_snapshot: PlayerSnapshot = {
'name': player['name'],
'hand': next(player['hand_generator'])
}
return player_snapshot
# じゃんけんを実施する関数
def fight(players: list[Player]) -> list[Result]:
players_snapshot = [get_player_snapshot(player) for player in players]
hands = [player_snapshot['hand'] for player_snapshot in players_snapshot]
strong_hand = get_strong_hand(hands)
results: list[Result] = [{
'name': player_snapshot['name'],
'hand': player_snapshot['hand'],
'judge': judge_win_lose(player_snapshot['hand'], strong_hand)
} for player_snapshot in players_snapshot]
return results
if __name__ == "__main__":
player1: Player = {'name':'Emi', 'hand_generator':constant_hand_generator('Par')}
player2: Player = {'name':'Ken', 'hand_generator':randam_hand_generator()}
player3: Player = {'name':'Bob', 'hand_generator':turn_hand_generator()}
players = [player1, player2, player3]
results = fight(players)
print(results)
自動テストから始める
テスト駆動開発は強力だが、最初は実践するのが難しく感じるかもしれない。テスト駆動開発の中でもっとも重要なのは自動テストである。最初のうちは、必ずしもテストファーストでなくてもかまわないので、自動テストを書くように心がけよう。
どこまでテストするか
テストはバグの発見を早めるために実施される。そのため、怪しい部分を集中的にテストして、単純すぎてバグが入り込む余地がないと思えるならテストする必要はないかもしれない。テストによって得られるメリットとテストを書いたり実行したりする時にかかるコストのバランスを考慮して、どこまでテストするか検討する必要がある。