なぜTDDでは、わざわざテストを失敗させるのか?
はじめに
社内でTDD(テスト駆動開発)の勉強会を開催することになり、事前説明用の資料を作成していました。
TDDの基本サイクルは次の3ステップです。
- Red(失敗するテストを書く)
- Green(最小限の実装でテストを通す)
- Refactor(コードを整理する)
私自身、これまでTDDを実践する中で、特に疑問を持つことなくRedから始めていました。
しかし、資料に「なぜ最初に失敗するテストを書くのか?」という説明を書こうとしたとき、ふと手が止まりました。わざわざ失敗させる理由を、自分の中で十分に言語化できていなかったのです。
そこで、勉強会の準備や実際の演習を通して気付いたことを整理してみたいと思います。
勉強会で見つけたテストの落とし穴
勉強会で、異なる通貨同士を加算したときに例外が発生することを確認するテストを書いていました。
def test_cannot_add_different_currency():
dollar = Money.dollar(5)
franc = Money.franc(5)
with pytest.raises(PlusDifferentCurrencyError) as error:
dollar.plus(franc)
assert "USD" in str(error.value)
assert "CHF" in str(error.value)
このテストで確認したかったことは次の3つです。
- 異なる通貨を加算すると例外になること
- エラーメッセージに USD が含まれること
- エラーメッセージに CHF が含まれること
ぱっと見では問題なさそうに見えます。
しかし、実はバグがありました。
assert が with pytest.raises() の中に入ってしまっています。
with pytest.raises(PlusDifferentCurrencyError) as error:
dollar.plus(franc)
assert "USD" in str(error.value)
assert "CHF" in str(error.value)
dollar.plus(franc) で PlusDifferentCurrencyError が発生すると、その時点で with ブロック内の後続処理は実行されません。
pytest.raises() が例外を捕捉してブロックを抜けるため、後続の assert は一度も実行されないのです。
正しくは、assert を with ブロックの外に出します。
with pytest.raises(PlusDifferentCurrencyError) as error:
dollar.plus(franc)
assert "USD" in str(error.value)
assert "CHF" in str(error.value)
つまり、最初のコードでは、エラーメッセージを検証しているつもりでも、実際には一度も検証できていませんでした。
テストが通ることと、テストが正しいことは別
この出来事を振り返っていて気付いたのは、テストが通ることと、テストが正しいことは別だということです。
今回の例では、異なる通貨の加算時に例外さえ発生すればテストは成功します。
しかし実際には、
assert "USD" in str(error.value)
assert "CHF" in str(error.value)
は一度も実行されていませんでした。
つまり、テストは通っているのに、本当に確認したかったことは確認できていない状態だったのです。
テストコードもプログラムである以上、テスト自身にもバグが入り込む可能性があります。
例えば、
-
assertの書き間違い - 到達しないテストコード
などです。今回のインデントミスは、まさにこの「到達しないテストコード」に当たります。
そして、このとき改めて「そういえば、なぜTDDでは最初にRedを確認するのだろう?」と考えるようになりました。
Redの役割① テストが意図したところで失敗することを確認する
今回の例から考えると、Redの重要な役割の一つは、テストが意図したところで失敗することを確認することだと思います。
テストを書いた直後にRedになることを確認すれば、
- テストが実際に実行されているか
- 期待した箇所で失敗しているか
- 書いたテストに明らかな問題がないか
を早い段階で確認できます。
例えば、テストを書いたのに最初からGreenだったとします。
その場合、「実装がすでに正しいからGreenなのか?」だけではなく、「そもそもテストが実行されているのか?」「本当に確認したいことをテストできているのか?」と疑うことができます。
今回の例も、Redを確認していれば、テストの書き方に問題があることに早く気付けた可能性があります。
もちろん、Redになったからといって、そのテストが正しいことが完全に保証されるわけではありません。
それでも、テストを書いたあとに一度Redになることを確認することで、書いたテストが、自分の意図した通りに失敗することを確かめてからGreenに進むことができます。
今回の例では、「異なる通貨を加算すると例外が発生する」だけでなく、エラーメッセージまで確認したかったにもかかわらず、その assert は実行されていませんでした。
Redを確認するという一手を入れることで、こうしたテスト自身の問題にも気付くことができます。
Redの役割② 次に何を実装すればよいか分かる
Redには、次に何を実装すればよいかを明確にする役割もあります。
例えば、Dollarクラスに掛け算を実装するとします。
まず、次のテストを書きます。
def test_multiplication():
five = Dollar(5)
product = five.times(2)
assert product.amount == 10
まだ何も実装していなければ、当然このテストは失敗します。
例えば最初は、
NameError: name 'Dollar' is not defined
となるかもしれません。
ここではまだ、掛け算のロジックを考える必要はありません。
まずは Dollar クラスを作ります。
class Dollar:
pass
すると、次は別のエラーになります。
AttributeError: 'Dollar' object has no attribute 'times'
ここまで来ると、次に times() が必要だと分かります。
class Dollar:
def times(self, multiplier):
...
さらに実装を進めると、今度は product.amount に関するエラーが出ます。
AttributeError: 'Dollar' object has no attribute 'amount'
そこで、コンストラクタで amount を保持し、times() が新しい Dollar を返すようにします。
class Dollar:
def __init__(self, amount):
self.amount = amount
def times(self, multiplier):
return Dollar(self.amount * multiplier)
ここまで来て、ようやくテストはGreenになります。
このように、Redで表示されるエラーは、次に行うべき実装を教えてくれます。
「今このテストを通すために、まず何が必要なのか」 を小さな単位で考えながら進められます。
テストを書いた時点で「何を作るのか」が明確になり、RedからGreenへ進む過程では、それを少しずつ実装していくことができます。
Redは、実装を始める前に「今回作るもの」を具体的にしてくれるのです。
おわりに
勉強会資料を作るまでは、Redから始めること自体に特別な疑問を持つことなくTDDを実践していました。
しかし、改めて「なぜ最初に失敗するテストを書くのか?」と考えてみると、その理由を十分に言語化できていませんでした。
今回整理してみて、Redには次のような役割があると考えるようになりました。
- テストが意図したところで失敗することを確認する
- エラーを手がかりに、次に何を実装すればよいかが分かる
特に今回のインデントミスを通して、テストが通ったことと、テストが正しいことは別だと実感しました。
テストを書いて、実装に着手する前に、「このテスト、本当に失敗するんだっけ?」 と一度立ち止まってみる。
そのために、TDDでは最初にRedを確認するのだと思います。
Redは単なる「失敗」ではなく、書いたテストが意図通りに失敗するかを確かめ、次に何を実装すればよいかを教えてくれる最初のフィードバックなのかもしれません。
Discussion