Hello, World!から学ぶテスト駆動開発
記事の目的
プログラマなら一度は書いたことがあるであろうHello, World!をprintするだけのプログラム…。
この簡単なプログラムを例にとって、テスト駆動開発で重要な考え方を整理したい。
テスト駆動開発とは
テスト駆動開発とは以下のサイクルを繰り返す開発方法である。
- RED: テストコードを作成し、失敗することを確認する
- GREEN: テスト対象のコードを実装/修正をして、テストが通過することを確認する
- REFACTOR: テスト対象のコード/テストコードを改善する
まず、特に重要な部分がテストコードの実装である。
なぜなら、テストコードとは「こうあるべき」という関数の仕様を表すものだからだ。
REDのステップでは、「このような文字列を返して欲しい」「演算結果はこの数値であって欲しい」というゴールを決める。
次に、このゴールを達成するためのコードを実装する。
どんなに汚い実装でも構わない。
重複する処理が関数として切り出されていなくても問題無い。
とにかく、テストコードの結果がGREENとなれば良い。
テストが通過したら、最後はRefactorである。
ここまでで実装したコードはきっと汚い。
しかし安心してほしいことは、テストコードは変わらないため「関数の入出力はこうあるべき」という指標は見えているという点だ。
だから関数が壊れることを恐れずに、安心してリファクタを進めることができるのである。
具体例: Hello, World!から学ぶテスト駆動開発
ここで例として取り上げる言語はGoである。
Goを知らない方でもわかるように整理していくため、「Goを知らないこと」がノイズにならないよう心掛ける。
まず最も簡単なHello, World!をprintするだけのプログラムを書こう。
Goではmain関数を定義することでプログラムのエントリーポイントとなる。
適当なディレクトリにhello.goというファイルを作成しよう。
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
さて、このmain関数が正しく動いているかをテストしたい。
このプログラムを実行するとコンソールにHello, World!と表示される。
しかし、どうやってテストしよう…と考えたとき、実はこのままだとテストが難しいことに気づく。
なぜなら、このmain関数は主要な機能と副次的な機能が組み合わさっているためだ。
ドメインと副作用という概念
ここで主要な機能を「ドメイン」、副次的な機能を「副作用」と呼ぶ。
- ドメイン
- プログラムが解決したい目的そのもののこと
- 何かの文字列を返す、何かを演算する…などの純粋な処理であることが多い
- この例では
"Hello, World!"を返す文字列そのものがドメインに該当する
- 副作用
- 外界との接続のこと
- 例えば以下
- 標準出力をする
- ファイルを読み書きする
- ネットワークを介してデータを送受信する
- データベースにアクセスする
副作用はテストがしにくいって話
標準出力をテストすることは容易ではない。目視確認をする必要が出てくる。
したがって、ドメインと副作用は分離してテストをする必要性が出てくる。
副作用となる既存の関数fmt.Println()の挙動は信じて、自分が実装した関数のテストにのみ集中しようというわけだ。
また、ドメインと副作用を分けて実装することで、出力先をコンソールからファイルに変更することも容易に行えるという利点もある。
RED
では、まずテストが失敗するテストコードを書く。
hello.goと同じディレクトリにhello_test.goというファイルを作成する。
GoではXX.goのテストをしたいときは、XX_test.goという命名をすることが義務付けられている。
package main
import "testing"
func TestHello(t *testing.T) {
got := hello("World")
want := "Hello, World!"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
引数のt *testing.Tなど細かいことは気にしないでほしい。
重要なのは以下の点である。
- 変数
gotに実際の値を代入する - 変数
wantに期待する値を代入する -
gotとwantが等しいかを確認する- 等しくなければ、テストが失敗する
- 等しい場合は、テストが成功する
テストコードの中で、未定義のhello関数を呼び出していることに気づく。
これはまさにドメインと対応している関数である。
got, wantを比較すると、この関数は文字列Worldを引数として受け取って、文字列Hello, World!を返すという処理を期待していることがわかる。
さて、このテストを実行すると当然失敗する。
当然だ。hello.goにはhello関数が定義されていないからだ。
.\hello_test.go:6:9: undefined: hello
FAIL helloWorld [build failed]
だが、これでいいのだ。
「この関数はこうあるべき」という思いを込めた失敗するテストコードを書くだけで、最初のREDステップは達成される。
GREEN
次にテストを通過するコードを書く。
まずは上記のテスト結果を参考に、hello.goを修正する。
Goではテスト結果をみて、指摘事項を直していくだけでGREENとなる。
まずundefined: helloと指摘されているため、hello.goにhello関数を定義する。
package main
import "fmt"
// ドメイン
func hello() string {
return "Hello, World!"
}
// 副作用
func main() {
fmt.Println(hello())
}
しかしこれではまだ、テストが失敗する。
.\hello_test.go:6:15: too many arguments in call to hello
have (string)
want ()
FAIL helloWorld [build failed]
too many arguments in call to helloと指摘されているように、定義されているhello関数は引数を受け取ることを想定していないからだ。
最後に修正しよう。すでに言及している通り、hello関数は文字列Worldを引数として受け取って、文字列Hello, World!を返すという処理を期待している。
package main
import "fmt"
// ドメイン
func hello(str string) string {
return "Hello, " + str + "!"
}
// 副作用
func main() {
fmt.Println(hello("World"))
}
これはテストが成功する。
hello関数は文字列"World"を受け取って文字列"Hello, World!"を返す。これでテストコードの期待値wantと合致した。
Refactor
今回は大きくないコードだったためリファクタの必要性は低い。
しかし、もしコードが大きくなったり複雑になったりする場合は、第3ステップとしてリファクタを行うことが重要である。
まとめ
テストコードから書くことが重要である[RED]。
このテストは転んでもいい。
とにかく、関数のあるべき姿から決めて、その思いをテストコードへ入れ込むことを意識する。
これを後追いするように関数を実装する[GREEN]。
実装は汚くても良い。テストを通過することだけを考える。
テストがしやすい実装も重要である。
ドメインと副作用をごっちゃにしていると、テストがしにくいため保守性が下がるリスクがある。
最後に、コードを成型する[REFACTOR]。
DRY原則を守っているか? 関数の責任は単一か? 関数や変数の命名は適切か?…などなど。