テスト駆動開発(TDD)でコードを書く
テスト駆動開発の本を買って少し読みましたが、実際に自分でコードを書いて表現してみたく、TypeScriptとJestでテスト駆動開発を実践します。
この記事では簡単なコードをテスト駆動開発の手順に沿ってやります。参考にしている書籍には、三角測量やデザインパターンなど書かれていましたが、文字数が膨大になってしまうためここでは割愛します。
テスト駆動開発について
「動作するきれいなコード」がテスト駆動開発のゴールです。「動作しないかも」「きれいなコードを書けないかも」といった不安を抱え込んでしまうのをやめにして、代わりに自動化されたテストによって開発を推し進める開発スタイルのことをテスト駆動開発といいます。
作業の手順は以下の通りです。
- レッド: 動作しない、コンパイルも通らないテストを1つ書く
- グリーン: レッドになったテストを迅速に動作させる
- リファクタリング: テストを通すために発生した重複をすべて除去する
グリーンはテストの成功、レッドはテストの失敗です。リファクタリングは、外部から見た振る舞いを変えずに、コードを書き換えて改善することです。
もう少し具体的な手順を見ていきます。
作業手順
テスト駆動開発では、次のステップを踏んで実装を行います。1つのTODOに対して、2~6を順番に行います。
1. TODOリストを書く(終わったらDONEにする)
2. テストを1つ書く
3. すべてのテストを走らせ、新しいテストの失敗を確認する
4. 小さな変更を行う(実装コードを書く)
5. すべてのテストを走らせ、すべて成功することを確認する
6. リファクタリングを行う
システムの要件
今回は、FizzBuzz問題を題材にします。FizzBuzz問題は1からある範囲までの数を数えて、画面に出力します。その際に以下の制約があります。
- 数が3で割り切れる時、Fizzを出力します
- 数が5で割り切れる時、Buzzを出力します
- 数が3でも5でも割り切れる時、FizzBuzzを出力します
実装
先ほどの1~6の順序で、テスト駆動開発を行います。
1. TODOリストを書く
システムの要件をTODOとして残します。
- 渡された数が3で割り切れる時、Fizzを返す
- 渡された数が5で割り切れる時、Buzzを返す
- 渡された数が3でも5でも割り切れる時、FizzBuzzを返す
TODOが完了したら打ち消し線などを引いて、DONEにしてください。途中で書くべきTODOが思いついた時は、その都度追加してください。
2. テストを1つ書く
TODOリスト1つ目「渡された数が3で割り切れる時、Fizzを返す」のテストを書きます。この時、実装のコードは書きません。テストコードのみ書きます。
describe('FizzBuzz', () => {
test('渡された数が3で割り切れる時、Fizzを返す', () => {
expect(new Counter().print(3)).toEqual('Fizz')
})
})
3. すべてのテストを走らせ、新しいテストの失敗を確認する
テストを実行します。今回はJestを使っているので、yarn test
を実行して確認します。
テストは失敗しました。この時点ではテストコードしか存在しないため、Counterクラスがないと言われエラーになります。次にエラーの修正を行います。
4. 小さな変更を行う(実装コードを書く)
先ほどエラーになった部分のCounterクラスの実装をします。この時、後でリファクタリングを行うので、時間をかけてキレイなコードを書こうとしなくても大丈夫です。
export class Counter {
print(count: number): string {
if (count % 3 === 0) {
return 'Fizz'
}
return ''
}
}
5. すべてのテストを走らせ、すべて成功することを確認する
Counterクラスを作成しましたので、testファイルにimportします。
import { Counter } from '../src/fizzbuzz'
describe('FizzBuzz', () => {
test('渡された数が3で割り切れる時、Fizzを返す', () => {
expect(new Counter().print(3)).toEqual('Fizz')
})
})
yarn test
を実行します。
今度はCounterクラスは正しく処理され、テストは成功しました。
6. リファクタリング
次にリファクタリングを行います。1つ目のTODOはシンプルなコードのため、今回はこのフェーズは省略します。
サイクルの繰り返し
1つ目のTODOが完了しましたので、2つ目のTODOに移り、以下の2~6のサイクルを繰り返します。
1. TODOリストを書く(終わったらDONEにする)
2. テストを1つ書く
3. すべてのテストを走らせ、新しいテストの失敗を確認する
4. 小さな変更を行う(実装コードを書く)
5. すべてのテストを走らせ、すべて成功することを確認する
6. リファクタリングを行う
そして、最後のTODOの5つ目のフェーズ(すべてのテストを走らせ、すべて成功することを確認する)が終わると、以下のようなコードになります。
import { Counter } from '../src/fizzbuzz'
describe('FizzBuzz', () => {
test('渡された数が3で割り切れる時、Fizzを返す', () => {
expect(new Counter().print(3)).toEqual('Fizz')
})
test('渡された数が5で割り切れる時、Buzzを返す', () => {
expect(new Counter().print(5)).toEqual('Buzz')
})
test('渡された数が3でも5でも割り切れる時、FizzBuzzを返す', () => {
expect(new Counter().print(15)).toEqual('FizzBuzz')
})
})
export class Counter {
print(count: number): string {
if (count % 3 === 0 && count % 5 === 0) {
return 'FizzBuzz'
} else if (count % 3 === 0) {
return 'Fizz'
} else if (count % 5 === 0) {
return 'Buzz'
}
return ''
}
}
条件分岐だらけになっていることが分かります。数が3か5のパターンしかないのでまだ良いですが、今後パターンが増える可能性があるかもしれません。(FizzBuzz問題ではなくなってしまいますが)
そうなると更に条件分岐が増えコードの見通しが悪くなるため、ポリモーフィズムを使ってリファクタリングを行います。
interface IFizzBass {
count: number
exec(): string
}
class FizzBuzz implements IFizzBass {
constructor(public count: number) {}
exec() {
return (this.count % 3 === 0 && this.count % 5 === 0) ? 'FizzBuzz' : ''
}
}
class Fizz implements IFizzBass {
constructor(public count: number) {}
exec() {
return this.count % 3 === 0 ? 'Fizz' : ''
}
}
class Buzz implements IFizzBass {
constructor(public count: number) {}
exec() {
return this.count % 5 === 0 ? 'Buzz' : ''
}
}
export class Counter {
print(count: number): string {
for (const iterator of [new FizzBuzz(count), new Fizz(count), new Buzz(count)]) {
if (iterator.exec() !== '') return iterator.exec()
}
return ''
}
}
リファクタリングをしたことにより、SOLID原則のオープン・クロースドの原則(ソフトウェアの振る舞いは既存の成果物を変更せず、新たにコードを追加するだけで対応できるようにするべき)が守られ、柔軟性やメンテナンス性が高いコードになりました。
そしてテストも成功しました。
テスト駆動開発の実装については以上です。
最後に
テスト自体が時間がかかるため、工数がひっ迫すると思われがちですが、リリースを早く出せたとしても、それは将来的に負債になりかねません。1つ1つテストコードを書いていくことで、テスト不足が原因で致命的な不具合に遭遇することをある程度防げますし、他のエンジニアがそのテストコードを見た時に仕様を把握するのが容易になります。
テスト駆動開発には、1つのTODOの区切りの最後にリファクタリングのフェーズがありますが、すでにテストが成功したコードのため、安心して手を入れることが出来る点もメリットが大きいです。
参考
Discussion
最初のほうで「レッドはテストの成功、グリーンはテストの失敗です。」と書かれていますが、逆でしょうか?
仰るとおり、逆でした。修正しました。
ご指摘いただき、ありがとうございます!