フリーランス
エンジニア
やはりお前らの言うテスト駆動開発は間違っている
oskn259と申します。
昨今ではテスト駆動開発という言葉も浸透して、全くテストの無いシステムというのは減ってきているように思います。
筆者は最近、テスト駆動開発の名著であるこちらを読み終わったのですが、世にあるイメージと本に書かれている内容に差があるように感じました。
テストが書かれているからTDDなのでしょうか?
テストを先に書くからTDDなのでしょうか?
正確にはそのどちらでもないようです。
今回はそのさわりの部分を解説していきます。
今回の記事は、こちらで書いた内容の修正版でもあります。
https://blog.oskn259.com/article/review_tdd
テスト駆動開発の進め方
イメージとして僕も知ってはいましたが、その時の僕のTDDに対する認識としてはこうでした。
- テストを始めに全て書く
- それをパスするようなコードを以って完成とする
- 仕様をテストという形で記載するので、開発者感での認識の齟齬を防止できる
実はこの認識こそ、TDDのごく一側面だけを切り取った代物だったのです。
開発の進め方
フィボナッチ数列を生成するメソッドを作ってみましょう。
まずは、これぐらいなら即作れるだろう、というレベルのテストを書きます。
describe('fib()', () => {
it('フィボナッチ数を返す', async () => {
expect(fib(0)).toBe(0);
});
});
0を入力したら、そのフィボナッチ数である0が返るというテストです。
これを満たす実装は以下の通りです。
function fib(n: number) {
return 0;
}
はい、簡単ですね。
言いたいことは分かります。
でもTDDではこれで良いんです。
常にテスト結果をグリーンに保ち、その後、すぐにグリーンにできる一歩を踏み出すのです。
はじめの一歩としてfib(0) === 0
を完成させたわけです。
では次なる一歩としてfib(1) === 1
となるテストを追加しましょう。
describe('fib()', () => {
it('フィボナッチ数を返す', async () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
});
});
追加した部分のテストが落ちてしまうようです。
これが通るように実装を修正してみましょう。
function fib(n: number) {
if (n === 0) return 0;
return 1;
}
・・・
言いたいことは分かります。
でもTDDではこれで良いんです。
常にテスト結果をグリーンに保ち、その後、すぐにグリーンにできる一歩を踏み出すのです。
同じように進めていきます。
describe('fib()', () => {
it('フィボナッチ数を返す', async () => {
expect(fib(0)).toBe(0);
expect(fib(1)).toBe(1);
expect(fib(2)).toBe(1);
expect(fib(3)).toBe(2);
});
});
function fib(n: number) {
if (n === 0) return 0;
if (n <= 2) return 1;
return 2;
}
次なる一歩として、fib(2), fib(3)
を追加しました。
テストが揃ってきたので、ここでリファクタを行います。
TDDにおけるリファクタとは、重複を排除することです。
このケースにおける重複とはなんでしょうか?
例えば、テスト上の2という数字と実装での2という数字が重複しています。
このように、実装とテスト両方にわたって観察し、重複を探し出します。
では、重複を解消するにはどうするのでしょうか?
まず、実装上のreturn 2
の2とは何でしょうか?
これはフィボナッチ数を計算する機能ですから、この2はそれ以前のフィボナッチ数である1
と1
を加算したものとなります。
function fib(n: number) {
if (n === 0) return 0;
if (n <= 2) return 1;
return 1 + 1;
このときreturn 1 + 1
の前者の1は、expect(fib(1)).toBe(1);
としてテスト上に記載されているfib(1)
の結果をベタ書きしたものです。
後者の2についてはfib(2)
の結果です。
つまり、これらの1
は重複しているのです!
重複しない形に書き直しましょう。
function fib(n: number) {
if (n === 0) return 0;
if (n <= 2) return 1;
return fib(n-2) + fib(n-1);
より小さなステップでTDDを進めたければ、return fib(2) - fib(1);
を挟んでも良いですが、ここでは少し大股に歩を進めました。
このようにして、フィボナッチ数を計算する機能がテストと共に完成します。
これまでの認識との差
先のフィボナッチ数のケースが示す通り、TDDにおいてテストと実装はどちらが先ということはなく、両輪で進めていくものです。
これは僕が知っている「テストファースト」の概念を大きく修正してくれました。
要件全てをテストとして表現してそれをパスするコードを模索するというのは、(やり方としてアリだとは思いますが)それはTDDではないようです。
最初の一歩を踏み出すために小さなテストを書くことこそがテストファーストなのです。
またこれまで、完成したコードをより良いものにしていくという意味でリファクタという言葉を使っていましたが、TDDの文脈においてリファクタとは、重複を排除する開発過程のことを指しています。
言うなれば、TDDは単なる制約や手続きではなく思考のプロセスと言った方が近そうです。
こうした思考プロセスがなぜ有効なのかは100%解明されていませんし、本書でも明確にはされていませんが、TDDのサイクルを回すことで優れた設計に辿り着いたという例は多いようです。
筆者が得た知識
振る舞い駆動開発(BDD) と テスト駆動開発(TDD)
そもそもテストとは、エラーになるであろう操作を意図的に行ってそうしたケースの存在を示すことですが、TDDにおけるテストでは現状の立ち位置を確認する役割と言えそうです。
それは本当にテストか?
間違った理解を世に広めていないか?
という議論があり用語が一新されました。
その結果生まれたのがBDDで、assertion
-> expect
やtest class
-> spec
のように、振る舞いを定義するという前提の単語が使用されるようになっています。
機能として両者は等しく、用語のみが入れ替わっています。
TDDなのでテストは完璧、ではない
リリースのために必要なテストは、 以下の軸に沿って4象限に分類できます。
- 技術面 <-> ビジネス面
- チームの支援 <-> 製品の批評
TDDで記述するテストは、技術面でチームを支援するものと言えそうです。
要するに、まだ3種類のテストが残っているのです。
具体的には、以下のようなテストが残されています。
- セキュリティ検査
- 負荷検証
- 顧客による手動受け入れテスト
- 自動化されたストーリーテスト
二層構造のテスト
フィボナッチ数の例で記述したようなユニットテストだけでなく、受け入れ条件のような大きい単位に対してもTDDは可能であるとされています。
アジャイルにおけるユーザーストーリーをテストとして記述するのです。
この場合、ユーザーストーリーを表す 「外側」のテストと、コーディング時に並行して記述する「内側」のテストの2つが存在することになります。
外側テストをパスするために、内側テストと実装の両輪でコードを書き進めるというイメージですね。
この「外側」テスト向けフレームワークとして、cucumberなどが挙げられます。
https://cucumber.io/
熟練が必要
今となっては随分浸透したTDDですが、今回本を読んでみて、簡単に習得できるものではないなという感想でした。
感覚や経験の占める割合が多いと思います。
フィボナッチ数の例にしても、以下のような事柄について決まった方法がなく、コーダーの経験に委ねられているのです。
- 一歩の大きさはどれぐらいが適切か?
- そもそもその一歩として何を選ぶべきか?
- どのタイミングでリファクタを始めるのか?
この感覚を掴んでTDDの恩恵を真に受けるためには、いくらか経験が必要そうです。
まとめ
本書にも記載がありましたが、重要なのは自分やPJにとっての最適解を見つけることです。
TDDでないからイケてない、ではありませんし、足し算のメソッドに100個のテストを書くのが適切とも思えません。
みんなが言ってるからではなく、自身の置かれた現状に最適な使い方を模索できると良いですね。
その結果、TDDは今回は不要ということもあり得るでしょう。
重要なのは、一つのスキルとして持っておくことです。