RSpecの導入方法の説明は以下の記事に預けます。
Rails6系×PostgreSQLでRSpecの実行環境を作成する
今回は、t_wadaさんのde:code2017 50 分でわかるテスト駆動開発に感動して、テストファーストの開発をしてみたい、TDDを一からやりたい。と思ったので、RailsでRSpecを使い、TDDでFizzBuzz問題を解いてみたという記事になります。
##環境説明
- Rails 6.0.3.2
- postgres (PostgreSQL) 12.4
- rspec-rails 4.0.1
TDDの要諦
書籍:テスト駆動開発やt_wadaさんの講演で詳しく説明されています。
なので、自分が特に大事だと思った箇所をかいつまんで載せます。
「動作する綺麗なコード」がTDDのゴール
◯まず書いて動作させてから、徐々に綺麗なコードにしていく
×綺麗な設計をひたすら考えてからコードを書く
設計を考えに考え抜いても、実際に実装してみると、考え抜いた設計が無駄になる事が多い。
例えば
- 設計を実装に移す段階で細部まで設計を考えられておらず手が止まってしまう
- 今回の要件においてそこまで緻密に設計を考える必要がなかった
- 設計を綺麗にコードに落とせたが、パフォーマンスが悪く、使いものにならない...等
一方で
実際にコードを動かす事で沢山のフィードバックを得られる。
むしろ、現代のソフトウェア開発は予測可能性が低いため、実際に動かしてみないと分からない事が多過ぎる。
TDDたった2つのルール
- 自動化されたテストが失敗したときのみ、新しいコードを書く。
- 重複を除去する。
TDDのサイクル
1.次の目標(TODO)を考える
2.その目標を示すテストを書く
3.そのテストを実行して失敗させる(Red)
4.目的のコードを書く
5.2.で書いたテストを成功させる(Green)
6.テストが通るままでリファクタリングを行う(Refactor)
7.1.~6.を繰り返す
##RSpecを使いTDDでFizzBuzz問題を体験
FizzBuzz問題の仕様
1から100までの数をプリントするプログラムを書け。
ただし3の倍数のときは数の代わりに「Fizz」と、5の倍数のときは「Buzz」とプリントし、3と5の倍数の場合には「FizzBuzz」とプリントすること。
※「ただし」の後ろに来る条件は、異常系、もしくは準正常系である事がほとんど。ただしの前に正常系の条件が書かれている。
仕様を要素に分解してTODOとしてリストアップする
TODO
-
数を文字列にして返す
- 1を渡したら文字列'1'を返す
- 2を渡したら文字列'2'を返す
-
3の倍数のときは数の代わりに「Fizz」と返す
- 3を渡したら文字列'Fizz'を返す
-
5の倍数のときは「Buzz」と返す
- 5を渡したら文字列'Buzz'を返す
-
3と5両方の倍数の場合には「FizzBuzz」と返す
- 15を渡したら文字列'FizzBuzz'を返す
- 1から100までの数
- プリントする
※TODOをリストアップするときは、テスト可能なサイズに要素を分解し、難易度や重要度に沿って並び替える。
※テストは具体的な値じゃないと書けないので、具体的な値を当てはめること。
テストを実行して失敗させる(Red)
require './lib/fizz_buzz'
RSpec.describe 'fizz_buzz' do
context '数は文字列にして返す' do
it "1を渡したら文字列'1'を返す" do
expect(fizz_buzz(1)).to eq '1'
end
end
end
def fizz_buzz(n)
nil
end
$ rspec spec/fizz_buzz_spec.rb
------------------------
1 example, 1 failure
Failed examples:
rspec ./spec/fizz_buzz_spec.rb:5 # fizz_buzz 数は文字列にして返す 1を渡したら文字列'1'を返す
無事にRedです。
テストを成功させる(Green)
fizz_buzz.rbを書き換えます。
def fizz_buzz(n)
'1'
end
$ rspec spec/fizz_buzz_spec.rb
------------------------
1 example, 0 failures
テストが通りGreenになりました!('1'を返してるんだからそりゃあ当たり前じゃないか!と思われるかと思いますが大事な工程です。)
※テストに失敗した時に、テスト自体がおかしいのか、それとも実装がおかしいかの判別できないので、最初は必ず仮実装をして、テストを通過させるのが重要。テストコードのテスト(ミューテーションテスト)を仮実装で最初に仕留めてしまうべきとのことです。
仮実装(検証)
※TDDで一番最初に書くテストは考える事が多いので、一番小さなTODOを選択し、まずは小さなステップを超える事が推奨されている。
※テストコードも読みやすさが大切なので、日本人で構成されている開発チームであれば、日本語でテストコードを書くのがおすすめとのこと。
三角測量
異なる値でテストする事を三角測量という。
1を渡したら文字列'1'を返すのテストが通ったので、次は2を渡したら文字列'2'を返す、のテストを行う。
テストケースを回す上で、複数のテストケースが存在する場合、exampleは別途作成するべき。
なぜなら、複数のテストケースを一つのexampleに詰め込むことをAssert rouletteアンチパターンといい、一つのexampleで複数テストを行うと、どのテストが原因で落ちたかの特定が困難なため。
fizz_buzz_spec.rbとfizz_buzz.rbを書き換えます。
require './lib/fizz_buzz'
RSpec.describe 'fizz_buzz' do
context '数は文字列にして返す' do
it "1を渡したら文字列'1'を返す" do
expect(fizz_buzz(1)).to eq '1'
end
it "2を渡したら文字列'2'を返す" do
expect(fizz_buzz(2)).to eq '2'
end
end
end
def fizz_buzz(n)
n.to_s
end
$ rspec spec/fizz_buzz_spec.rb
------------------------
2 example, 0 failures
2つともGreenになりました。
三角測量も無事に成功です。
明白な実装
同様に3の倍数の場合のテストと実装も書いてしまいます。
これまで、文字列'1'と'2'をfizz_buzz関数に渡したときに'1'と'2'をそれぞれ返したときと、ほとんど変わりない記述で要件を満たすと思います。
fizz_buzz_spec.rbとfizz_buzz.rbを書き換えます。
require './lib/fizz_buzz'
RSpec.describe 'fizz_buzz' do
context '数は文字列にして返す' do
it "1を渡したら文字列'1'を返す" do
expect(fizz_buzz(1)).to eq '1'
end
it "2を渡したら文字列'2'を返す" do
expect(fizz_buzz(2)).to eq '2'
end
it "3を渡したら文字列'Fizz'を返す" do
expect(fizz_buzz(3)).to eq 'Fizz'
end
end
end
def fizz_buzz(n)
if n % 3 == 0
'Fizz'
else
n.to_s
end
end
$ rspec spec/fizz_buzz_spec.rb
------------------------
3 example, 0 failures
要件を満たす実装の仕方が見えている場合は、テストを書いて、実装を一気に書いてしまう。
この事をTDDでは明白な実装と言います。
今回の場合、3の倍数の実装と5の倍数の実装は明白なので、一気に実装をしてしまいます。
5の倍数の場合は以下です。
fizz_buzz_spec.rbとfizz_buzz.rbを書き換えます。
require './lib/fizz_buzz'
RSpec.describe 'fizz_buzz' do
context '数は文字列にして返す' do
it "1を渡したら文字列'1'を返す" do
expect(fizz_buzz(1)).to eq '1'
end
it "2を渡したら文字列'2'を返す" do
expect(fizz_buzz(2)).to eq '2'
end
it "3を渡したら文字列'Fizz'を返す" do
expect(fizz_buzz(3)).to eq 'Fizz'
end
it "5を渡したら文字列'Buzz'を返す" do
expect(fizz_buzz(5)).to eq 'Buzz'
end
end
end
def fizz_buzz(n)
if n % 3 == 0
'Fizz'
elsif n % 5 == 0
'Buzz'
else
n.to_s
end
end
$ rspec spec/fizz_buzz_spec.rb
------------------------
4 example, 0 failures
最後に仕様が読み取れる形にテストを修正する
テストを、動く仕様書として残すように心がける。
テストケースを入れ子にして仕様を明解に表すように書くこと、descriptionは抽象化すること、各テストケースは対称性を保っている状態であることが推奨されています。
fizz_buzz_spec.rbを書き換えます。
require './lib/fizz_buzz'
RSpec.describe 'fizz_buzz' do
context '数は文字列にして返す' do
it "三の倍数の場合" do
expect(fizz_buzz(3)).to eq 'Fizz'
end
it "五の倍数の場合" do
expect(fizz_buzz(5)).to eq 'Buzz'
end
it "その他の場合" do
expect(fizz_buzz(1)).to eq '1'
end
end
end
テストケースの対称性について補足すると、三角測量をしたために、「その他の場合」のテストケースが二つ存在してしまっていたと思います。
三の倍数の場合にはテストケースが一つで、五の倍数の場合にもテストケースが一つ、しかし、その他の場合でテストケースが二つ存在している状態は、初見の人が見た場合、何か意図があって二つテストケースを用意しているのだろうかと誤解を与えかねません。
三角測量は、実装を安全に行うために必要だったものなので、fizz_buzz関数に'2'を渡して'2'が返ってくることのテストは、安全に実装が行えた時点で用済みになっています。
なので、「2を渡したら文字列'2'を返す」テストケースは、「その他の場合」と抽象化したdescriptionに変更して消してしまいましょう。
テストは増やすのは簡単だが、減らすのは百倍難しい、テストのメンテナンスコストを減らすためにもテストケースは必要最小限まで減らすように整理すべきとのことです。
TDDを学んで個人的に響いた内容
-
何か変更したら、どんな些細な変更であろうが、何かしらのテストをしないエンジニアはいないだろう(書籍:テスト駆動開発 p.187)
-
テスト駆動開発は、プログラミングの中の不安をコントロールする手法だ。(書籍:テスト駆動開発 まえがきxii)
他にも心に刺さる言葉が沢山ありましたが、テストを書くという行為に対するあるべき姿勢や、テストを書く意義を説いてくれていて特に響きました。
注意点
テストだけで駆動できないプログラミングタスクも確かにある。例えば、セキュリティと並行性は、ソフトウェアの目標への到達をTDDが機械的に示せるところまで至っていない2大トピックだと言える。(書籍:テスト駆動開発 まえがきxii)
と書いているように、TDDで全ての実装に対して安全性を担保する事は、現時点では出来ない模様です。
##まとめ
t_wadaさんの動画を拝見して、t_wadaさんがデモでJUnitを使いFizzBuzz問題を解いたのと同様に、FizzBuzz問題を自分が普段使用している言語のテストフレームワークで一通りやってみたら、TDDの作法が血肉になるのではと思ったので、RSpecを使って今回やってみる事にしました。
t_wadaさんの講演をなぞった形になり恐縮ですが大変学びになりました。
t_wadaさんが講演の中で、TDDの魅力を一番伝えられる方法は、ライブコーディングをすることだ。とおっしゃっています。
この記事だと端折って説明してしまっている部分もありますが、講演ではフルバージョンで伝えられているので、この記事でTDDに興味を持ってくださった方は、講演も是非聞いてみるべきだと思います!
##参考文献
- テスト駆動開発って何だろう
- de:code2017 50 分でわかるテスト駆動開発
- テスト駆動開発(書籍)
- プロを目指す人のためのRuby入門(書籍)