AI時代だからこそ、バグの少ないコードを書くためにTDDを学んだ話
はじめに
開発者であれば誰しも、「できるだけバグの少ないコードを書きたい」と思っていると思います。
また、実際にバグの多いコードに苦しんだ経験があるのではないでしょうか。自分自身もそのひとりです。
修正したはずの箇所で別の不具合が起きたり、「どこを変更すると何が壊れるのか」がわからなくなったり、特にコード量が多いと、「たぶん動いているけど、本当に大丈夫だろうか」という不安を抱えながら開発を進めることもあると思います。
そんな中で単体テストを書く機会があり、「そもそも単体テストとは何なのか」「どのように書けばよいのか」という疑問を持ちました。
そこで単体テストについて調べていく中で、「テスト駆動開発(TDD)」という言葉にたどり着きました。
「テスト駆動開発(TDD)」と聞いて
“テストを書いてから実装をする開発手法”という説明はよく見かけます。しかし、有名なエンジニアたちが口をそろえて「TDDは良い」と言っている一方で、実際には何が良いのか、自分の中ではまだ曖昧でした。
- なぜ、わざわざ先にテストを書くのか
- テストを書くことで何が変わるのか
- 設計やコード品質にどんな影響があるのか
そういったことを具体的に知りたくなり、『テスト駆動開発』を読みました。
この記事では、
- TDDとはどのような考え方なのか
- TDDによって何が嬉しいのか
- 本を読みながら実際に感じたこと
- AI時代の開発において、なぜTDDが重要だと思ったのか
以上の点について、自分なりに整理しながらまとめていきます。
※読んだ本はKent Beck氏の書籍「テスト駆動開発」です
TDDの基本サイクル
TDDの基本的な流れは、次のようなサイクルで進みます。
- テストを書く
- コンパイラを通す
- テストを走らせて、失敗を確認する
- テストを通す
- 重複を排除する
この流れだけを見ると、かなりシンプルに見えます。
しかし実際には、このシンプルなサイクルの中にTDDの重要な考え方が詰まっています。
まず、いきなり実装を書きません。最初に「こう動いてほしい」という期待をテストとして書きます。次に、そのテストが失敗することを確認します。失敗を確認することで、今から書く実装に意味があることがわかります。
その後、とにかくテストを通します。最初から完璧な実装を目指す必要はなく、まずは小さなゴールを達成します。そして最後に、重複を排除したり、名前を整えたり、責務を分けたりして、コードを改善していきます。
この「テストを書く → 通す → 整える」という工程が、TDDの基本です。
仮実装・明白な実装・三角測量
実装の進め方にもいくつかの選択肢があります。
TDDでは、常に同じ進め方をするのではなく、実装の見通しがどれくらい立っているかによって進め方を変えます。
明白な実装
何を書くべきかが明確にわかっている場合は、明白な実装を行います。
たとえば、「2つの数値を足し算する関数」を作る場合を考えます。
test("2つの数値を足し算できる", () => {
expect(add(1, 2)).toBe(3);
});
この場合、実装すべき内容はかなり明確です。
function add(a, b) {
return a + b;
}
このように、すでに正しい実装が見えている場合は、無理に遠回りする必要はありません。まず最小のテストを書き、そのテストが通るように自然な実装をします。
仮実装
一方で、実装の正しい形がまだ見えていないときは、仮実装を行います。
たとえば、税込価格を計算する関数を作る場合を考えます。
test("1000円の商品に10%の税率を適用すると1100円になる", () => {
expect(calculateTaxIncludedPrice(1000, 0.1)).toBe(1100);
});
この時点でまだ汎用的な実装が思いついていない場合、とりあえず次のように書いてテストを通します。
function calculateTaxIncludedPrice(price, taxRate) {
return 1100;
}
一見するとかなり雑な実装です。しかし、TDDではまずテストを通すことを優先する場面があります。
その後、別のテストを追加します。
test("2000円の商品に10%の税率を適用すると2200円になる", () => {
expect(calculateTaxIncludedPrice(2000, 0.1)).toBe(2200);
});
すると、return 1100; では対応できなくなります。そこで、次のように実装を改善します。
function calculateTaxIncludedPrice(price, taxRate) {
return price + price * taxRate;
}
最初からきれいな設計を目指すのではなく、まず動く状態を作ります。そしてテストがある状態で、後からリファクタリングしていきます。
この考え方は、自分にとってかなり印象的でした。実装に迷って手が止まるくらいなら、まず小さく動かします。動くものを作った上で、そこから改善していけばよいのだと思いました。
三角測量
正しい実装が見えないときには、三角測量を使います。
三角測量は、1つのテストだけで判断せず、複数の具体例を追加しながら共通するルールを見つけていく方法です。
たとえば、割引後の価格を計算する関数を作る場合を考えます。
最初に、次のテストを書きます。
test("1000円の商品を10%割引すると900円になる", () => {
expect(calculateDiscountPrice(1000, 0.1)).toBe(900);
});
このテストだけなら、次のような仮実装でも通ってしまいます。
function calculateDiscountPrice(price, discountRate) {
return 900;
}
しかし、これでは本当に正しい実装とは言えません。
そこで、別の具体例を追加します。
test("2000円の商品を10%割引すると1800円になる", () => {
expect(calculateDiscountPrice(2000, 0.1)).toBe(1800);
});
さらに、割引率が違うパターンも追加します。
test("1000円の商品を20%割引すると800円になる", () => {
expect(calculateDiscountPrice(1000, 0.2)).toBe(800);
});
複数のテストケースを見ることで、「価格から、価格に割引率を掛けた金額を引けばよい」というルールが見えてきます。
function calculateDiscountPrice(price, discountRate) {
return price - price * discountRate;
}
このように、1つのテストだけでは実装の方向性が見えない場合、別の具体例を追加していきます。複数のテストケースから共通するルールを見つけ、一般化していきます。
これは、いきなり抽象化するのではなく、具体例を増やしながら正しい形を探っていく方法なのだと感じました。
テストはどこまで書くのか
テストを書こうとすると、多くの人が一度は「どこまで書けばいいのか」という疑問にぶつかると思います。
本の中で特に印象に残ったのは、次の言葉でした。
不安が退屈に変わるまでテストを書く
単体テストを書く目的は、自分が不安に感じている部分、壊れそうだと思う部分、仕様として重要な部分を確認するために書きます。
ただ、正直なところ、本を読む前の自分は「壊れそうなところ」と言われても、具体的にどこを指しているのかよくわかっていませんでした。
そんな中で、本書ではテストを書くべき対象として、次のような例が挙げられていました。
少なくとも、自分の中にまだ十分な経験や勘がないので、まずこういった部分から重点的にテストを書くのが良さそうだと感じました。
具体的には、次のような部分はテストを書いた方がよいです。
こうした箇所は、実装の振る舞いが複雑になりやすいです。だからこそテストによって、意図した通りに動いていることを確認する価値があります。
逆に言えば、テストを書くことで不安が減っていきます。そして何度も実行できる状態になることで、確認作業は退屈なものになります。
この「不安を退屈に変える」という感覚が、TDDを進めていく中で大切な感覚の一つだと思いました。
良いテストは、設計の悪さも教えてくれる
TDDを学ぶ中で、テストは単にバグを見つけるためのものではなく、「設計手法である」と書いてありました。
たとえば、テストの前準備に必要なコードが長すぎる場合があります。
アサーションを行うまでに100行以上の準備コードが必要なら、そのテスト対象のオブジェクトは大きすぎる可能性があります。つまり、1つのオブジェクトが多くの責務を抱え込みすぎているのかもしれません。
また、前準備コードがいろいろなテストで重複している場合も注意が必要です。
共通化しづらい前準備が大量に存在する場合、それだけ互いに密結合したオブジェクトが多い可能性があります。これは、オブジェクト同士の結合度が高くなっているサインとも言えます。
このように、テストを書きにくいコードは、設計そのものにも問題を抱えていることが多いです。
逆に言えば、テストを書きやすいコードは、小さく責務が分かれ、依存関係が整理されていることが多いです。
TDDを行うと、自然と小さい単位でテストを書こうとします。その結果、メソッドやオブジェクトも小さく保ちやすくなり、結合度が低く、凝集度が高い設計へ近づいていきます。
これは実際の開発において、かなり重要だと思いました。
コードを書き続けていると、徐々にソースコード全体が肥大化し、「どこを変更すると何が壊れるのか」がわからなくなることがあります。
すると、自分で書いたコードなのに責任を持てなくなるような感覚になります。
「たぶん動いているけど、本当に大丈夫だろうか」「どこか壊れているのではないか」という不安が、常につきまとう状態になります。
しかしTDDでは、小さな単位で動作を確認しながら進めるため、「今この部分はテストによって守られている」という感覚を持ちやすくなります。
つまり、品質を自分でコントロールできている状態に近づけます。
この安心感は、開発を継続していく上でとても大きいと感じました。
TDDは、開発者の不安をコントロールする技術でもある
TDDの良さは、品質面だけではありません。
自分にとって大きいと思ったのは、TDDが開発者の不安をコントロールする技術でもあるという点でした。
実装していると、次のような不安が出てきます。
- この変更で既存機能が壊れていないか
- この設計で本当に大丈夫なのか
- 後から変更できる状態になっているか
- 自分はこのコードを理解し続けられるのか
テストがない状態では、これらの不安を毎回手作業で確認しなければなりません。しかもコードが大きくなるほど、確認は難しくなります。
TDDでは、まず小さなテストを書き、そのテストを通し、リファクタリングします。すると、少なくとも今書いた振る舞いについては確認できる状態になります。
小さな成功を積み重ねながら進められるため、開発中の不安が減っていきます。
テストがない状態だと、「もしかしたらどこかにバグがあるかもしれない」という不安を抱え続けながら開発することになります。
コードが増えるほど、その不安は大きくなります。
一方で、TDDでは小さな単位でテストを書きながら実装を進めるため、「少なくとも自分が定義した振る舞いについては保証できている」という感覚を持ちやすくなります。
また、テストを書きながら少しずつ実装していくため、自分がなぜそのコードを書いたのかも追いやすくなります。
もちろん、テストを書いたから絶対にバグがなくなるわけではありません。しかし、「どこまでが確認できていて、どこに不安が残っているのか」を自分で把握しやすくなります。
バグがあるかもしれないというその時点で目に見えない不安を抱え続けるのではなく、間違いなく保証できている部分が積み重なっていくことで、余計な不安に精神的なリソースを奪われにくくなり、開発全体にも良い影響が生まれると思いました。
作業を中断するときは、失敗したテストを残す
本を読んでいて、実践してみたいと思ったことがあります。
それは、コーディングを中断するときに、テストが失敗した状態で止めるという考え方です。
普通は、作業を終えるならすべてのテストが成功している状態にしたくなります。しかし、あえて失敗しているテストを残しておくと、次に作業を再開するときに「何から始めればよいか」が明確になります。
次にやることは、失敗しているテストを通すことです。
これは、作業再開時のウォーミングアップにもなります。前回どこまで考えていたのかを、失敗しているテストが教えてくれます。テストを通す過程で、自然と前回の内容を復習できます。復習ができることでソースコードへの理解も深まっていきます。
本を読んで印象が変わったこと
この本を読んで一番印象が変わったのは、優れたプログラマーに対するイメージです。
これまで自分は、プログラミングが本当にできる人、特に技術書を書くような人は、最初から完璧にきれいなコードを思いつき、それを正しい形で実装できるのだと思っていました。
しかし本を読みながら、ペアプロのように実装の過程を追っていくと、実際にはそうではありませんでした。
著者も右往左往しながら、少しずつ正しそうな実装やテストを作り上げていました。途中でうまくいかなくなり、コードを捨てて一から書き直すこともあったという記述も印象に残っています。
つまり、できる人は最初から完璧なコードを書ける人なのではありません。
小さく試し、間違え、確認し、直しながら、少しずつ良い形に近づけていきます。その進め方を持っている人なのだと思いました。
この気づきは、自分にとってかなり大きかったです。
大きなソースコードも、結局は小さな機能の集合体です。最初から全体を完璧に理解しようとするのではなく、小さな単位に分けて、テストを書きながら進めていけばよいのだと思いました。
そう考えると、複雑な実装に対する心理的なハードルも少し下がります。
AI時代だからこそ、TDDはさらに重要になる
最近はAIを使うことで、かなり大きなコードを一気に生成できるようになっています。
これはとても便利な一方で、危うさもあると感じています。
AIは一見それっぽい大きな実装をすぐに出してくれます。しかし、そのコードが本当に自分の意図通りに動くのか、変更しやすい設計になっているのか、後から自分が責任を持てるのかは別問題です。
特に、いきなり大きな実装が生成されると、コードの全体像を追いきれなくなることがあります。動いているように見えても、どこが重要なロジックなのか、どこを変更すると壊れるのかがわかりません。
だからこそ、AI時代にはTDDの考え方がさらに重要になるのではないかと思いました。
先にテストを書くことで、自分がAIに何を作らせたいのかを明確にできます。AIが出した実装が正しいかどうかも、テストによって確認できます。
また、テストがあれば、AIにリファクタリングを依頼するときにも安全性が高まります。変更後もテストが通るかを確認できるからです。
AIに任せる部分が増えるほど、人間側には「何を正しいとするのか」を定義する力が求められます。その意味で、TDDはAI時代の開発者にとって、より重要なスキルになると感じました。
自分の開発にどう取り入れるか
今回本を読んで、TDDを積極的に取り入れていきたいと思いました。
特に意識したいのは、「まずテストを書く」ということだけではなく、「何をテストするべきか」をしっかり考えることです。
実装を始める前に、今回の要件に対してどのようなテストが必要なのかを考えます。
- どんな振る舞いが保証されていれば安心できるのか
- どこが壊れやすそうなのか
- この機能の本質的な責務は何なのか
そういったことを整理してから実装に入ることで、コードを書く前に仕様や設計について深く考えられるようになると感じました。
AI時代においては、この「何を保証したいのかを定義する力」がさらに重要になると思っています。
AIを使えば、一般的なテストケースや実装を高速に生成できます。しかし、その中に本当に必要なテストが含まれているのか、今回の要件特有の仕様や業務知識が反映されているのかを判断するのは、まだ人間側の役割です。
AIは大量の知識から“それっぽい最適解”を出すことは得意ですが、実際の業務やプロダクトには細かな前提条件や例外が存在します。
だからこそ、自分自身が「何を正しいとするのか」をテストとして定義し、その上でAIを活用する姿勢が大切だと感じました。
また、TDDは単なるテスト技法ではなく、設計を改善していくための手法でもあります。
実装そのものはAIにある程度任せられる時代になっています。しかし、
- 何を実装するのか
- その設計は妥当なのか
- 本当に要件を満たしているのか
といった部分について責任を持つのは、最終的には人間だと思います。
テストを書きながら進めることで、自分が実装したコードがテストコードによって手中に収まっている感覚を常に持ち続けられるように進めていきたいです。
まとめ
『テスト駆動開発』を読んで、TDDに対する印象が大きく変わりました。
TDDは、単にテストを先に書く手法ではありません。
小さなゴールを決め、失敗を確認し、実装し、リファクタリングする。そのサイクルを通じて、コードの設計を少しずつ良くする設計手法です。
また最初から完璧な実装を目指す必要はありません。優れたプログラマーであっても、試行錯誤しながら少しずつ良い形に近づけています。
この考え方は、自分にとってかなり励みになりました。
ソフトウェアを開発する以上、どうしてもバグは発生してしまうものですが、TDDを使用して小さく着実に実装を積み重ねていけば、ソースコードの質は大きく向上すると思います。
AIでコードを大量に生成できる時代だからこそ、TDDを用いて質の高い成果を生み出していけるように日々学習していこうと思います!!
Discussion