
テスト駆動開発を読んでみた感想
-
2023年1月19日
みなさん、こんにちは!cocone connect でサーバーサイドエンジニアを担当している山中です!普段は主に kotlin でサーバー開発をやっていて、最近は web3 関係の技術をちょこちょこ触っています。
今回は会社の書籍補助制度で購入した「テスト駆動開発」を読み終えたので、第一部の多国籍通貨について章ごとにポイントや感想を述べていきたいと思います。
また本記事では、書籍のサンプルコードを
- Java → Kotlin
- JUnit → Kotest
と読み替えて記載しています。
テスト駆動開発について
原著:ケント・ベック氏(テスト駆動開発の提唱者)
翻訳:和田卓人氏(日本のテスト駆動開発の第一人者)
当書はテスト関連の技術書では大変有名な書籍のため、聞いたことがある方が多いのではないでしょうか。
1-1 仮実装
当書の第一部では、ある通貨を扱うプロジェクトに多国籍通貨に対応する機能追加をテスト駆動でどう実装していくかが書かれています。
まず、通貨単位の違う金額を扱うにはどうすればよいかを考えます。ここで
どのようなオブジェクトが必要かを考えるのではない。テストから始めるんだと常に自分に言い聞かせている。
と書かれていますが、この考えがまさにテスト駆動開発です。
今後おそらく何度も出てくると思いますが、テスト駆動開発とは
- コードを書く前に必ず失敗するコードを書く。
- 重複を削除する。
この 2 つのルールがあるだけです。とにかくまずは失敗するテストコードを書く。そして、それを一刻も早くグリーンにしていく。
それまではテストを書いた時点で適切な変数名や修飾詞でなくても、コンパイルさえ通らなくてもいいとされてます。
当書にあるような簡単な要件であればともかく、要件が複雑な機能を実装するときには、すぐに正解が思いつかない事もあるでしょう。そんなときはこの章で書かれているように、まずテストを通すようにしてから少しずつ正解に近づける方法が役に立つかもしれません。そのためにはベタ書きだろうがなんだろうが問題ありません。
1-2 明白な実装
前章はだいぶ細かいステップを踏んでテストを書き、実装を正解へと近づける方法が書かれていました。この章では思いついた方法をそのまま記載してテストが通るかどうか、通ればラッキー実装完了というようなことが書かれています。
『テスト駆動開発では、全ての実装において細かなステップを何度も踏んで開発しなくてよい』ということです。明確なアイデアが頭に思いつけばそのままテストを書いて通れば良いですし、逆に何も思いつかないようであれば前章のように不恰好な実装を少しずつ正解へと近づければいいということです。少しホッとしませんか?(私はホッとしました。)
1-3 三角測量
この章では副作用のない Value Object の話と三角測量の話がされています。
Value Object とは、外部から Object の値を変更できず、equals メソッドで等価比較できるオブジェクトのことです。
例えば、以下のような通貨オブジェクトがあり、とりあえずテストを通すために常に true を返す equals メソッドをオーバーライドさせます。
class Dollar(var amount: Int) {
fun times(num: Int): Dollar {
return Dollar(this.amount * num)
}
override fun equals(other: Any?): Boolean {
return true
}
}
internal class MoneyTest : StringSpec({
"test equal" {
(Dollar(5) == Dollar(5)) shouldBe true
}
})
テストには成功しますが、これだけで equals メソッドの実装が正しいことを担保できるでしょうか?このテストケースだけでは不十分なので、テストを追加します。
internal class MoneyTest : StringSpec({
"test equal" {
(Dollar(5) == Dollar(5)) shouldBe true
+ (Dollar(5) == Dollar(6)) shouldBe false
}
})
これはテストが失敗するので、実装を修正します。
class Dollar(var amount: Int) {
override fun equals(other: Any?): Boolean {
return when(other) {
is Dollar -> this.amount == other.amount
else -> false
}
}
}
これで、テストも通り equals メソッドを一般化することができました。この考え方を三角測量というようです。当書でも三角測量を使う機会はそう多くないとされており、どうリファクタリングしていいかわからない時などに使えるかもしれないとあります。
頭の片隅に置いておくことでいつか役に立つかもしれません。
1-4 意図を語るテスト
本章で伝えたいことはシンプルです。
"test Multiplication" {
val five = Dollar(5)
var dollar = five.times(2)
dollar.amount shouldBe 10
dollar = five.times(3)
dollar.amount shouldBe 15
}
このテストからは
「times メソッドが Dollar オブジェクトを返す」ということが少しわかりにくいですよね?
なので以下のようにテストを修正します。
"test Multiplication" {
val five = Dollar(5)
five.times(2) shouldBe Dollar(10)
five.times(3) shouldBe Dollar(15)
}
これで「Dollar インスタンスが戻り値で返ること」が明確になり、テストがスッキリしました。
注意したいのが、
「このテストが可能なのは、前章のテストで等価性を保証しているため」
という点です。等価性の検証ができていなければこのテストが成功することはありません。これがテスト駆動開発のリスクだと当書では記載されています。
1-5 原則をあえて破る時
ここで多国籍通貨の通貨対応の小さなステップとして Franc クラスを作成します。
Dollar クラスをコピペしますか??コピペするのはクソコードだと思いますか??
勇気を持ってコピペしましょう。ペアプロでなければだれも見ていません。
ただし、テスト駆動開発の大原則に重複を削除することがあります。コピペしテストが通ることが確認できたら、次は重複に対応していきましょう。
これは実プロダクトでもよくあるのではないでしょうか?外部で公開されているコードや別プロジェクトからのコピペは悪く言われがちですが、ちゃんと理解したうえでコピペするならありだと思います。その場合、リファクタリングをちゃんとしてからプルリクを出しましょう。さもなければそれはクソコードになってしまう可能性があります。
1-6 テスト不足に気づいたら
前章で重複を許容しコードをコピペしたため、共通化を行います。
open class Money(protected val amount: Int) {
override fun equals(other: Any?): Boolean {
return when(other) {
is Money -> this.amount == other.amount
else -> false
}
}
}
ここで Franc オブジェクトの等価性のテストがないことに気づきます。実プロジェクトでも、リファクタリングや実装をしている最中に期待するようなテストが存在しないことがあるでしょう。そのようなときはテストを追加すればいいだけです。
テストはいつ追加しても問題ありません。気づいたらどんどんテストを書きましょう。
1-7 疑念をテストに翻訳する
この章までの間に「Dollar と Franc を比較するとどうなるのか」という疑念がありました。そういった疑念はあらゆるシチュエーションで思いつくでしょう。そのときは、TODO リストに忘れないように書いておき、その時がきたらテストを書きましょう。
これはテストを書く上でとても大事なことだと思います。
例えば「この関数の引数に渡ってくる値が null だったらどうなるのだろう」とか「引数の数値がマイナスのときはどうなるだろう」とかです。
私はどうなるか 1%でも不安な要素があればテストを書くようにしています。複雑なロジックが混じったコードは不吉な気配を感じます。そのような気配を感じたら、そこは積極的にテストを書きます。とにかく、不安なところがあればどんどんテストを書きましょう。それで無駄な不安から解放されます。
1-8 実装を隠す
Dollar クラスと Franc の times メソッドが同じような処理のため 、Money クラスに移したい、と考えます。
そこでこの章では、 移行の前段階としてサブクラスのファクトリメソッドを Money クラスに実装し、サブクラスの存在を隠蔽します。
ここはテストというよりはリファクタリングですね。ただし、このようなコード修正もテストが壊れていないかを確認しつつ行うことで気兼ねなく修正できます。逆にテストがなければ少しの修正でも実装を壊していないことを担保できません。
1-9 歩幅の調整
この章ではサブクラスをなくすために通貨単位の概念を実装します。まずどのように実装したらいいか考えますか??違いますね。TDD では通貨単位の概念をどうテストしたらいいか考えます。書くべきテストはこのような感じでしょうか。
"test Currency" {
Money.dollar(1).currency() shouldBe "USD"
Money.franc(1).currency() shouldBe "CHF"
}
ここからはこのテストを通すこと、通したら重複を無くすことを念頭に、細かいステップを踏んでゴールに近づけていきます。ただし、必ずしも細かいステップを踏む必要はありません。それが窮屈と思うのならばもう少し大きなステップにしていいのです。大きすぎると感じるなら細かくすればいいのです。
TDD だからといってマニュアルのような手順を必ずしも毎回踏まなくていいのです!
1-10 テストに聞いてみる
この章までで Dollar, Franc というサブクラスとその抽象クラスである Money クラスができています。サブクラスたちはもうほとんど仕事をしていないので times メソッドを親クラスに移します。そうすると Money クラスは具象クラスに変更となります。この時点での Money クラスはこんな感じです。
open class Money(protected val amount: Int, private val currency: String) {
companion object {
fun dollar(amount: Int) = Dollar(amount)
fun franc(amount: Int) = Franc(amount)
}
fun times(num: Int): Money {
return Money(this.amount * num, this.currency)
}
fun currency(): String {
return this.currency
}
override fun equals(other: Any?): Boolean {
return when(other) {
is Dollar -> if(this.javaClass == Dollar::class.java) this.amount == other.amount else false
is Franc -> if(this.javaClass == Franc::class.java) this.amount == other.amount else false
else -> false
}
}
}
ここまでの実装は正しいでしょうか?もしもテストがなければ、すぐに答えを出すのは難しいでしょう。ただし、今回はテストが存在します。動くかどうかはテストを実行して確認しましょう。
テストを動かすと失敗します。equals メソッドがクラス比較になっているため、 Money クラスと Dollar クラスの比較では通貨が一致していたとしてもtrueにならないからです。
これも実際のプロジェクトではよくあるのではないでしょうか?テストがあるのなら、バグを発見してリファクタリングをしたとき、動くかどうかはテストに聞いてみればよいということです。
1-11 不要になったら消す
サブクラスのメソッドを親クラスに移していったのでもうサブクラスにはコンストラクタしかなくなったため、クラスを削除します。対象のクラスが1つとなったことで過剰テストになっている箇所があるのでそういったところは削除します。
プロジェクトが進むと役割を果たしてない不要なテストが出てくるかもしれません。そういった、テストはどんどん削除しましょう。「書いたテストは削除してはいけない」といったことはないのです!
1-12 設計とメタファー
この章では異種通貨の計算をするための実装として足し算の機能を実装します。そして異種通貨の計算をするために
- 最初に共通的な値を計算結果として返すようにする
- その値を指定の通貨に換算して表現できるようにする
この 2 つの発想を TDD を用いて実現していきます。(実際には計算結果を Expression というインターフェースに、換算を Bank というクラスに仕事をさせるよう実装します。)
このような設計を TDD で実現できるわけではありませんが、思いついたアイデアを実現に近づけていくことができる手法が TDD なのだと思います。
1-13 実装を導くテスト
この章でやりたいことは、前章仮実装した足し算の機能を本来あるべき姿に実装することです。そのために新たに Sum というクラスを作成します。ここで書いたテスト内容は以下の二つです。
- Bank クラスの reduce(足し算)メソッドに Sum オブジェクトを引数に渡し足し算された Money クラスが返ること
- Bank クラスの reduce メソッドに Money オブジェクトを引数に渡すと Money クラスが返ること
このテストを通しつつ、実装したコードを適切な場所に移していきます。最終的に Expression インターフェースに足し算のメソッドを作成することができます。だいぶ構造と設計が複雑になってきたので、このようなインタフェース設計を瞬時にするのは思っているよりも難しいのではないでしょうか??
こういった設計、実装の手助けをするのが TDD です。
1-14 学習用テストと回帰テスト
この章では 2 フランを 1 ドルに換算するような実装をします。その過程で配列の比較で同じ要素であれば等価とみなすかどうかをテストを実際に書いて確かめます。
これは私もよくやるのですが、言語仕様・外部 API・ライブラリで挙動がわからないものがあれば実際にテストを書いて挙動を確かめます。今は大抵のことは調べればわかりますが、実際に動かした方が早いこともあるかもしれません。当書ではこれを学習用テストと呼んでいます。
これも不具合を修正するときによくやりますが、不具合の原因がわかったとしても、一度本当に再現するかどうかのテストを書きます。そして、不具合を修正することでテストが成功するかどうかを確認します。
このように、「テスト失敗の原因を突き止めた時、再現するか確認するためのテスト」のことを回帰テストと呼んでいます。
1-15 テスト任せとコンパイラ任せ
この章では、最初に実装しようとした「$5 + 10CHF = $10」のような計算ができるようにします。このためのテストはすでに書いていますがコンパイルエラーが出るため、コンパイルエラーが出ないように修正していくことでゴールまで辿り着けます。
(当書は Java で書かれているので型エラーが出ますが、Kotlin で書くと型推論のためにエラー出ず実装できてしまう)
1-16 将来の読み手を考えたテスト
テストを書いている時にあえて変数宣言したほうが意図を伝えやすかったりする時があれば変数宣言をしたほうがよいです。テストはチームメンバーなど人に見られるものとして書くものであり、なぜそのテストを書いているのか、どのようなテストなのかがわかりやすくなるように書くべきです。
Ruby のチェリー本著者の@jnchitoさんが
「テストにおいては過度な DRY は避けるべき」
と言っておりましたが、これもテストで使用する値などはベタ書きしてあったほうが意図が伝わりやすいからです。
1-17 多国通貨の全体ふりかえり
これで一通りの実装は終わりましたが、これで完璧でしょうか?そんなことはなく重複している箇所やまだ修正する余地のある箇所は多くあると当書では書かれています。
著者のケント・ベック氏は
「この多国通貨の実装を 20 回近くしてきたが、書くたびに設計が大きく変わったことに驚いた」
と述べています。TDD で実装することで戸惑うことなくリファクタリングできますし、閃いたメタファーを具現化していくことができるのです。
まとめ
本記事ではテスト駆動開発の第一部である多国通貨に関して記述していますが、実際は第三部まであり、第二部では現在さまざまな言語やフレームワークで使われる JUnit のようなテスティングフレームワークの実装をします。そして、付録とあとがきでは TDD の背景や歴史、時代と共に TDD がどのように捉えられてきたかなどについて書かれています。
当書の付録内で訳者である和田氏が TDD の現在について書いていますが、その中で
テスト駆動開発のテストは本当にテストだろうか。テストではないとしたら、それは何だろうか。
というテスト駆動開発への問いかけが現実化してきたと書かれています。テスト駆動開発はテストを書くための手法ではないからです。
この問いに関しての答えの1つとして、「それはCheckingである」という考えがあります。テストを行っているのではなく、高速でチェックしているという考えです。
この考え方に対して私は非常に納得感がありました。テスト駆動開発(というよりも機能開発における単体テスト)に対して誤解が生じていそうに感じていたからです。
開発者が単体テストを書くのはなぜでしょうか?正常に動作をすることを担保するためでしょうか?
私の考えを述べると「テストを書かないと機能実装できないから」であり、厳密に言うと「リファクタリングができないから」です。
テストを書かずに実装したコードは、不具合が発生した時に何もできません。
修正前後で
- 本当に修正できているか
- 修正箇所が他の今まで動いていた箇所に何も影響を与えていないか
も分かりません。
機能が増えてきてコードの重複に気づいたとしても、それをいい感じにリファクタリングすることもできません。(一番楽しいのに!!)
リファクタリングが与える影響がまったくわからないのですから。だから、レガシーコードとは何かの問いに「テストがないコード」と言われるのだと思います。
書いたテストが結果的にコードの動作を担保することにもなってはいると思いますが、それ以上に、コーディング中に思いついたこと・不安要素をテストで確認しながら進めることで、安心して実装を進めることができるため、Chaeckingの意味合いがしっくりくると感じます。
当書の中ではしばしば「精神衛生的に」や「心理的安全性」のようなメンタルにまつわる言葉が出てきますが、その根底には
テスト駆動開発の本質は精神状態のコントロール
という考えがあります。テストなしに実装を進めることは手探りで暗いトンネルを進むようなもので、大変なストレスです。(少なくとも私にとっては)
不安を感じたらすぐにテストを動かしグリーンを確認することで安心して前に進めます。だからテストを書くのです。
最後に和田氏が書いていますが、テスト駆動はあくまで個人のスキルであって、必ずしもテスト駆動を実施しなければならないわけではありません。正直なところ、私もテストファーストでテストは書けていません。(最初にざっくり動くものを実装してしまう)
もし、まだテストを書けていないという方や、テスト駆動について難しそうといった感想を持っている方はぜひ一度当書を読んでみてください。(当書を読む場合は、実際に書かれているコードを写経することをお勧めします。当書でも書かれていますが、テスト駆動を知るには言葉で説明するのは難しく、追体験をするのが一番理解できるからです)
テスト駆動開発は思っているよりも難しいものではないですし、堅苦しいものではないです!
今回は以上になります!それでは、良いテストライフを!
ココネでは一緒に働く仲間を募集中です。
ご興味のある方は、ぜひこちらの採用特設サイトをご覧ください。