エピソード3
初めに
この記事は テスト駆動開発から始めるRuby入門 ~2時間でTDDとリファクタリングのエッセンスを体験する~ の続編です。
前提として エピソード1を完了して、テスト駆動開発から始めるRuby入門 ~ソフトウェア開発の三種の神器を準備する~ で開発環境を構築したところから始まります。 別途、セットアップ済み環境 を用意していますのでこちらからだとすぐに始めることが出来ます。
本記事は一応オブジェクト指向プログラム入門者向けとなっていますが、入門者の方は用語についてはわからなくても結構です、コードを繰り返し写経することで感覚を掴んでもらえば自ずと書いてあることはわかるようになってきますので。あと、概要はオブジェクト指向プログラム経験者に向けて書いたのものなので読み飛ばしてもらって結構です(ネタバレ内容です)、経験者の方からのツッコミお待ちしております。
概要
本記事では、 オブジェクト指向プログラム から オブジェクト指向設計 そして モジュール分割 を テスト駆動開発 を通じて実践していきます。
オブジェクト指向プログラム
エピソード1で作成したプログラムの追加仕様を テスト駆動開発 で実装します。 次に 手続き型コード との比較から オブジェクト指向プログラム を構成する カプセル化 ポリモフィズム 継承 という概念をコードベースの リファクタリング を通じて解説します。
具体的には フィールドのカプセル から setterの削除 を適用することにより カプセル化 を実現します。続いて、 ポリモーフィズムによる条件記述の置き換え から State/Strategyによるタイプコードの置き換え を適用することにより ポリモーフィズム の効果を体験します。そして、 スーパークラスの抽出 から メソッド名の変更 メソッドの移動 の適用を通して 継承 の使い方を体験します。さらに 値オブジェクト と ファーストクラス というオブジェクト指向プログラミングに必要なツールの使い方も学習します。
オブジェクト指向設計
次に設計の観点から 単一責任の原則 に違反している FizzBuzz
クラスを デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え を適用してクラスの責務を分割します。オブジェクト指向設計のイデオムである デザインパターン として Commandパターン 以外に Value Objectパターン Factory Methodパターン Strategyパターン を リファクタリング を適用する過程ですでに実現していたことを説明します。そして、オープン・クローズドの原則 を満たすコードに リファクタリング されたことで既存のコードを変更することなく振る舞いを変更できるようになることを解説します。
加えて、正常系の設計を改善した後 アサーションの導入 例外によるエラーコードの置き換え といった例外系の リファクタリング を適用します。最後に ポリモーフィズム の応用として 特殊ケースの導入 の適用による Null Objectパターン を使った オープン・クローズドの原則 に従った安全なコードの追加方法を解説します。
モジュールの分割
仕上げは、モノリシック なファイルから個別のクラスモジュールへの分割を ドメインオブジェクト の抽出を通して ドメインモデル へと整理することにより モジュール分割 を実現することを体験してもらいます。最後に 良いコード と 良い設計 について考えます。
Before
After
オブジェクト指向から始めるテスト駆動開発
テスト駆動開発
エピソード1ので作成したプログラムに以下の仕様を追加します。
仕様
1 から 100 までの数をプリントするプログラムを書け。
ただし 3 の倍数のときは数の代わりに「Fizz」と、5 の倍数のときは「Buzz」とプリントし、
3 と 5 両方の倍数の場合には「FizzBuzz」とプリントすること。
タイプごとに出力を切り替えることができる。
タイプ1は通常、タイプ2は数字のみ、タイプ3は FizzBuzz の場合のみをプリントする。
早速開発に取り掛かりましょう。エピソード2で開発環境の自動化をしているので以下のコマンドを実行するだけで開発を始めることができます。
$ rake
guard
が起動するとコンソールが使えなくなるのでもう一つコンソールを開いておきましょう。もしくは .
を使うことで guard
内でコンソールのコマンドを呼び出すことができます。
[1] guard(main)> . ls
coverage Gemfile.lock lib provisioning README.md tmp
Gemfile Guardfile main.rb Rakefile test Vagrantfile
[2] guard(main)> . pwd
/workspace/tdd_rb
[3] guard(main)> . git status
TODOリスト作成
まずは追加仕様を TODOリスト に落とし込んでいきます。
TODOリスト
-
タイプ1の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
タイプ1の場合
テストファースト アサートファースト で最初に失敗するテストから始めます。テストを追加しましょう。
ここでは既存の FizzBuzz.generate
メソッドにタイプを 引数 として追加することで対応できるように変更してみたいと思います。まず、 fizz_buzz_test.rb
ファイルに以下のテストコードを追加します。
...
end
describe 'タイプごとに出力を切り替えることができる' do
describe 'タイプ1の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', FizzBuzz.generate(1, 1)
end
end
end
describe '配列や繰り返し処理を理解する' do
...
...
05:32:51 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 11 LOC (36.36%) covered.
Started with run options --guard --seed 37049
ERROR["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005623e6a24260 @name="タイプごとに出力を切り替えることができる::タイプ1の場合">, 0.0019176720088580623]
test_1を渡したら文字列1を返す#タイプごとに出力を切り替えることができる::タイプ1の場合 (0.00s)
Minitest::UnexpectedError: ArgumentError: wrong number of arguments (given 2, expected 1)
/workspace/tdd_rb/lib/fizz_buzz.rb:6:in `generate'
/workspace/tdd_rb/test/fizz_buzz_test.rb:74:in `test_1を渡したら文字列1を返す'
25/25: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00796s
25 tests, 26 assertions, 0 failures, 1 errors, 0 skips
...
ArgumentError: wrong number of arguments (given 2, expected 1)
引数 が違うと指摘されていますね。 FizzBuzz.generate
メソッドの引数の変更したいのですが既存のテストを壊したくないのでここは デフォルト引数 使ってみましょう。
メソッドの引数にはデフォルト値を指定する定義方法があります。これは、メソッドの引数を省略した場合に割り当てられる値です。
— かんたんRuby
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
end
...
...
05:32:52 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb Guardfile
2/2 files |====================================== 100 =======================================>| Time: 00:00:00
2 files inspected, no offenses detected
05:32:54 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |====================================== 100 =======================================>| Time: 00:00:00
0 files inspected, no offenses detected
05:37:29 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
lib/fizz_buzz.rb:6:29: W: [Corrected] Lint/UnusedMethodArgument: Unused method argument - type. If it's necessary, use _ or _type as an argument name to indicate that it won't be used.
def self.generate(number, type = 1)
^^^^
1/1 file |======================================= 100 =======================================>| Time: 00:00:00
1 file inspected, 1 offense detected, 1 offense corrected
05:37:31 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |======================================= 100 =======================================>| Time: 00:00:00
1 file inspected, no offenses detected
[1] guard(main)>
05:39:37 - INFO - Run all
05:39:37 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 11 LOC (36.36%) covered.
Started with run options --guard --seed 8607
25/25: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00723s
25 tests, 27 assertions, 0 failures, 0 errors, 0 skips
...
ちなみにここでは 引数に type=1
と入力したのですがコードフォーマットによって以下のように自動修正されます。
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, _type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
end
...
case式 を使って 引数 を判定できるように変更しましょう。ちなみに _type
をメソッド内で変数として使うと警告されるので type
に変更しています。
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
case type
when 1
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
end
end
...
...
Started with run options --seed 51330
Progress: |=============================================================|
Finished in 0.00828s
25 tests, 27 assertions, 0 failures, 0 errors, 0 skips
04:27:12 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |=================== 100 ====================>| Time: 00:00:00
1 file inspected, no offenses detected
04:27:13 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |=================== 100 ===================>| Time: 00:00:00
0 files inspected, no offenses detected
...
テストは無事通りました。ここでコミットしておきます。
$ git add .
$ git commit -m 'test: タイプ1の場合'
追加仕様の取っ掛かりができました。既存のテストを流用したいので先程作成したテストを削除して以下のように新しいグループ内に既存テストコードを移動しましょう。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz
end
describe '三の倍数の場合' do
def test_3を渡したら文字列Fizzを返す
assert_equal 'Fizz', @fizzbuzz.generate(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列Buzzを返す
assert_equal 'Buzz', @fizzbuzz.generate(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.generate(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1)
end
end
describe '1から100までのFizzBuzzの配列を返す' do
def setup
@result = FizzBuzz.generate_list
end
def test_配列の初めは文字列の1を返す
assert_equal '1', @result.first
end
def test_配列の最後は文字列のBuzzを返す
assert_equal 'Buzz', @result.last
end
def test_配列の2番目は文字列のFizzを返す
assert_equal 'Fizz', @result[2]
end
def test_配列の4番目は文字列のBuzzを返す
assert_equal 'Buzz', @result[4]
end
def test_配列の14番目は文字列のFizzBuzzを返す
assert_equal 'FizzBuzz', @result[14]
end
end
end
end
...
テストコードが壊れていないことを確認したらコミットしておきます。
$ git add .
$ git commit -m 'refactor: メソッドのインライン化'
TODOリスト
-
タイプ1の場合
-
数を文字列にして返す
1を渡したら文字列"1"を返す
-
3 の倍数のときは数の代わりに「Fizz」と返す_
3を渡したら文字列"Fizz"を返す
-
5 の倍数のときは「Buzz」と返す_
5を渡したら文字列"Buzz"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す_
15を渡したら文字列"FizzBuzz"を返す
-
-
タイプ2の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には数を文字列にして返す
- 15を渡したら文字列"15"を返す
-
-
タイプ3の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す
- 15を渡したら文字列"FizzBuzz"を返す
-
タイプ2の場合
TODOリスト
-
タイプ1の場合 -
タイプ2の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には数を文字列にして返す
- 15を渡したら文字列"15"を返す
-
-
タイプ3の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す
- 15を渡したら文字列"FizzBuzz"を返す
-
続いて、タイプ2の場合に取り掛かりましょう。
...
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1, 2)
end
end
end
...
...
FAIL["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005555ec747100 @name="数を文字列にして返す::タイプ2の場合::その他の場合">, 0.002283181995153427]
test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ2の場合::その他の場合 (0.00s)
Expected: "1"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:75:in `test_1を渡したら文字列1を返す'
24/24: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00437s
24 tests, 26 assertions, 1 failures, 0 errors, 0 skips
...
まだ 引数 に2を渡した場合は何もしないので case式 に2を渡した場合の処理を追加します。
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
case type
when 1
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
end
end
...
...
Started with run options --seed 19625
Progress: |=============================================================================|
Finished in 0.00894s
24 tests, 26 assertions, 0 failures, 0 errors, 0 skips
...
テストが通ったのでテストケースを追加します。ここはタイプ1の場合をコピーして編集すれば良いでしょう。
...
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3, 2)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5, 2)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.generate(15, 2)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1, 2)
end
end
end
end
...
...
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 13 LOC (30.77%) covered.
Started with run options --guard --seed 898
27/27: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00900s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
06:27:40 - INFO - Inspecting Ruby code style of all files
test/fizz_buzz_test.rb:11:3: C: Metrics/BlockLength: Block has too many lines. [70/62]
describe '数を文字列にして返す' do ...
^^^^^^^^^^^^^^^^^^^^^^^^
7/7 files |====================================== 100 =======================================>| Time: 00:00:00
7 files inspected, 1 offense detected
...
テストは通りましたが何やら警告が表示されるようになりました。
Metrics/BlockLength:Block has too many lines. これは 数を文字列にして返す
テストケースのコードブロックが長いという警告のようですがテストコードはチェックの対象から外しておきたいので .rubocop_todo.yml
に以下コードを追加してチェック対象から外しておきます。
...
# Offense count: 2
# Configuration parameters: CountComments, ExcludedMethods.
# ExcludedMethods: refine
Metrics/BlockLength:
Max: 62
Exclude:
- 'test/fizz_buzz_test.rb'
...
ちなみに guard(main)>
にカーソルを合わせてエンターキーを押すと自動化タスクが実行されます。
[1] guard(main)>
02:03:15 - INFO - Run all
/home/gitpod/.rvm/rubies/ruby-2.6.3/bin/ruby -w -I"lib" -I"/workspace/.rvm/gems/rake-13.0.1/lib" "/workspace/.rvm/gems/rake-13.0.1/lib/rake/rake_test_loader.rb" "./test/fizz_buzz_test.rb"
/home/gitpod/.rvm/rubies/ruby-2.6.3/bin/ruby -w -I"lib" -I"/workspace/.rvm/gems/rake-13.0.1/lib" "/workspace/.rvm/gems/rake-13.0.1/lib/rake/rake_test_loader.rb" "./test/fizz_buzz_test.rb"
Started with run options --seed 47335
Progress: |==============================================================================|
Finished in 0.00781s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
Started with run options --seed 47825
Progress: |==============================================================================|
Finished in 0.00761s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
02:03:17 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 13 / 13 LOC (100.0%) covered.
Started with run options --guard --seed 17744
27/27: [===========================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00789s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
02:03:17 - INFO - Inspecting Ruby code style of all files
7/7 files |=========================== 100 ============================>| Time: 00:00:00
7 files inspected, no offenses detected
02:03:19 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
0/0 files |=========================== 100 ============================>| Time: 00:00:00
0 files inspected, no offenses detected
[1] guard(main)>
警告は消えたのでコミットしておきます。
$ git add .
$ git commit -m 'test: タイプ2の場合'
TODOリスト
-
タイプ1の場合 -
タイプ2の場合
-
数を文字列にして返す
1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には数を文字列にして返す
15を渡したら文字列"15"を返す
-
-
タイプ3の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す
- 15を渡したら文字列"FizzBuzz"を返す
-
タイプ3の場合
TODOリスト
-
タイプ1の場合 -
タイプ2の場合 -
タイプ3の場合
-
数を文字列にして返す
- 1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
- 3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
- 5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す
- 15を渡したら文字列"FizzBuzz"を返す
-
続いて、タイプ3の場合ですがやることは同じなので今回は一気にテストを書いてみましょう。
...
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3, 3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5, 3)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.generate(15, 3)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1, 3)
end
end
end
end
...
...
FAIL["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005642171ea5a0 @name="数を文字列にして返す::タイプ3の場合::その他の場合">, 0.003375133004738018]
test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ3の場合::その他の場合 (0.00s)
Expected: "1"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:123:in `test_1を渡したら文字列1を返す'
FAIL["test_5を渡したら文字列5を返す", #<Minitest::Reporters::Suite:0x000056421723af78 @name="数を文字列にして返す::タイプ3の場合::五の倍数の場合">, 0.003832244998193346]
test_5を渡したら文字列5を返す#数を文字列にして返す::タイプ3の場合::五の倍数の場合 (0.00s)
Expected: "5"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:111:in `test_5を渡したら文字列5を返す'
FAIL["test_3を渡したら文字列3を返す", #<Minitest::Reporters::Suite:0x0000564217297340 @name="数を文字列にして返す::タイプ3の場合::三の倍数の場合">, 0.0043466729985084385]
test_3を渡したら文字列3を返す#数を文字列にして返す::タイプ3の場合::三の倍数の場合 (0.00s)
Expected: "3"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:105:in `test_3を渡したら文字列3を返す'
FAIL["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005642174dec98 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.006096020006225444]
test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.01s)
Expected: "FizzBuzz"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:117:in `test_15を渡したら文字列FizzBuzzを返す'
31/31: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00650s
31 tests, 33 assertions, 4 failures, 0 errors, 0 skips
...
case式 に処理を追加します。
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
case type
when 1
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
end
end
...
...
Started with run options --seed 12137
Progress: |=============================================================================|
Finished in 0.01662s
31 tests, 33 assertions, 0 failures, 0 errors, 0 skips
05:06:44 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png lib/fizz_buzz.rb
lib/fizz_buzz.rb:6:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
def self.generate(number, type = 1) ...
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:6:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
def self.generate(number, type = 1) ...
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1/1 file |=========================== 100 ============================>| Time: 00:00:00
1 file inspected, 2 offenses detected
...
テストは通りましたが新しい警告が表示されるようになりました。とりあえずコミットしておきます。
$ git add .
$ git commit -m 'test: タイプ3の場合'
処理の追加により一部重複が発生しました。ここは、 ステートメントのスライド を適用して重複をなくしておきましょう。
ステートメントのスライド
旧:重複した条件記述の断片の統合
— リファクタリング(第2版)
重複した条件記述の断片の統合
条件式のすべて分岐に同じコードの断片がある。
それを式の外側に移動する。
— 新装版 リファクタリング
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
case type
when 1
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
end
end
...
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
end
end
...
警告は消えていませんがプログラムは壊れていないことが確認できたのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: ステートメントのスライド'
TODOリスト
-
タイプ1の場合 -
タイプ2の場合 -
タイプ3の場合
-
数を文字列にして返す
1を渡したら文字列"1"を返す
-
3 の倍数のときは数を文字列にして返す
3を渡したら文字列"3"を返す
-
5 の倍数のときは数を文字列にして返す
5を渡したら文字列"5"を返す
-
3 と 5 両方の倍数の場合には「FizzBuzz」と返す
15を渡したら文字列"FizzBuzz"を返す
-
それ以外のタイプの場合
追加仕様には対応しましたがタイプ1,2,3以外の値が 引数 として渡された場合はどうしましょうか? 現状では nil
を返しますがこのような例外ケースも考慮する必要があります。
TODOリスト
-
タイプ1の場合 -
タイプ2の場合 -
タイプ3の場合 -
それ以外のタイプの場合
例外処理 を追加します。まず、例外のテストですが以下の様に書きます。
例外とは記述したプログラムが想定していない値を受け取ったり、何らかの障害が発生した場合に処理を中断して、例外オブジェクトを生成して呼び出し元のメソッドに処理を戻す機構です。
— かんたんRuby
describe 'タイプ3の場合' do
...
end
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz
end
def test_例外を返す
e = assert_raises RuntimeError do
@fizzbuzz.generate(1, 4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
...
...
FAIL["test_例外を返す", #<Minitest::Reporters::Suite:0x0000558a26888e60 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.003033002998563461]
test_例外を返す#数を文字列にして返す::それ以外のタイプの場合 (0.00s)
RuntimeError expected but nothing was raised.
/workspace/tdd_rb/test/fizz_buzz_test.rb:134:in `test_例外を返す'
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00609s
32 tests, 34 assertions, 1 failures, 0 errors, 0 skips
...
case式 に該当しないタイプが指定された場合は 例外を発生させる ようにします。
例外を明示的に発生させるには「raise」を使います。raiseには発生させたい例外クラスを指定するのですが、何も指定しない場合はRuntimeErrorオブジェクトが生成されます。
— かんたんRuby
...
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
...
...
07:04:53 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 16 / 16 LOC (100.0%) covered.
Started with run options --guard --seed 32508
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00600s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
テストが通ったのでコミットしておきます。
$ git add .
$ git commit -m 'test: それ以外のタイプの場合'
TODOリスト
-
タイプ1の場合
-
タイプ2の場合
-
タイプ3の場合
-
それ以外のタイプの場合
TODOリスト
をすべて完了しました。追加仕様を満たすプログラムは出来ましたがまだ改善の余地がありそうですね。以降ではオブジェクト指向アプローチによるコードのリファクタリングを解説していきたいと思います。
オブジェクト指向
手続き型プログラム
オブジェクト指向 の解説の前に以下のコードを御覧ください。いわゆる 手続き型 で書かれたコードですが、これも追加仕様を満たしています。
MAX_NUMBER = 100
type = 1
list = []
MAX_NUMBER.times do |i|
r = ''
i += 1
case type
when 1
if i % 3 == 0 && i % 5 == 0
r = 'FizzBuzz'
elsif i % 3 == 0
r = 'Fizz'
elsif i % 5 == 0
r = 'Buzz'
else
r = i.to_s
end
when 2
r = i.to_s
when 3
if i % 3 == 0 && i % 5 == 0
r = 'FizzBuzz'
else
r = i.to_s
end
else
r = '該当するタイプは存在しません'
end
list.push(r)
end
puts list
処理の流れをフローチャートにしたものです、実態はコードに記述されている内容を記号に置き換えて人間が読めるようにしたものです。
オブジェクト指向プログラム
続いて、これまでに作ってきたコードがこちらになります。上記の 手続き型コード との大きな違いとして class
というキーワードでくくられている部分があります。
クラスとは、大まかに説明すると何らかの値と処理(メソッド)をひとかたまりにしたものです。
— かんたんRuby
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
def self.generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
(1..MAX_NUMBER).map { |n| generate(n) }
end
end
UML を使って上記のコードの構造をクラス図として表現しました。
更にシーケンス図を使って上記のコードの振る舞いを表現しました。
手続き型コード のフローチャートと比べてどう思われましたか?具体的な記述が少なくデータや処理の概要だけを表現しているけどFizzBuzzのルールを知っている人であれば何をやろうとしているかのイメージはつかみやすいのではないでしょうか?だから何?と思われるかもしれませんが現時点では オブジェクト指向 において 抽象化 がキーワードだという程度の認識で十分です。
オブジェクト指向の理解を深める取り掛かりにはこちらの記事を参照してください。
オブジェクト指向の詳細は控えるとして、ここでは カプセル化 ポリモフィズム 継承 というオブジェクト指向プログラムで原則とされる概念をリファクタリングを通して体験してもらい、オブジェクト指向プログラムの感覚を掴んでもらうことを目的に解説を進めていきたいと思います。
カプセル化
フィールドのカプセル化
まず、データとロジックを1つのクラスにまとめていくためのリファクタリングを実施していくとします。FizzBuzz
クラスにFizzBuzz配列を保持できるようして以下のように取得できるようにしたいと思います。
...
fizzbuzz.generate_list
@result = fizzbuzz.list
...
まず、 インスタンス変数 追加します。次に self
キーワードを外して クラスメソッド から インスタンスメソッド に変更します。
クラスメソッドはいくつか定義方法がありますが、どの方法を使ってもクラスメソッドとして定義されれば「クラス名.メソッド名」という形で呼び出せます。
— かんたんRuby
インスタンスメソッドはコンストラクタと同じようにクラス内でdefキーワードを使ってメソッドを定義するだけで作成できます。
— かんたんRuby
class FizzBuzz
MAX_NUMBER = 100
def self.generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
def self.generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
(1..MAX_NUMBER).map { |n| generate(n) }
end
end
class FizzBuzz
MAX_NUMBER = 100
def list
@list
end
def generate(number, type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
...
ERROR["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005613555ed120 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.0041351839900016785]
test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.00s)
Minitest::UnexpectedError: NoMethodError: undefined method `generate' for FizzBuzz:Class
/workspace/tdd_rb/test/fizz_buzz_test.rb:117:in `test_15を渡したら文字列FizzBuzzを返す'
...
FizzBuzz配列を インスタンス変数 @list
に 代入 して インスタンス変数経由で取得できるように変更しました。変更にあたり クラスメソッド FizzBuzz.generate
と FizzBuzz.generate_list
を インスタンスメソッド に変更しています。それに伴ってテストが失敗して NoMethodError: undefined method `generate'
と表示されるようになってしまいました。インスタンスメソッド が使えるようにするため new
メソッドを使ってFizzBuzzクラスの インスタンス を作りFizzBuzz配列を インスタンス変数 経由で取得するようにテストコードを変更します。
クラスとして定義された情報を元に具体的な値を伴ったオブジェクトを作成することをインスタンス化と呼び、生成されたオブジェクトのことをインスタンスと呼びます。
— かんたんRuby
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new
end
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new
end
...
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new
end
...
end
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz.new
end
...
end
end
...
...
07:17:36 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 5 / 17 LOC (29.41%) covered.
Started with run options --guard --seed 7701
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00616s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
テストが直りました。クラスメソッド インスタンスメソッド インスタンス変数 インスタンス などいろんな単語が出てきて戸惑ってしまったかもしれませんが、ピンとこないうちは クラス に値や状態を保持させるためには インスタンス化 する必要があってそのためには new
メソッドを使わないといけないのね程度の理解で十分です。大概のことは手を動かしているうちにピンと来るようになります。
インスタンス変数 に直接アクセスしているのでここは アクセッサメソッド を使って フィールドのカプセル化 を適用しておきます。
オブジェクト指向ではクラス内の値をカプセル化することが重要ですが、時には内部で保持しているインスタンス変数を参照や更新できる方が良い場合もあります。複雑な処理ではなく、単にインスタンス変数にアクセスするためのメソッドのことを、アクセッサメソッドと呼びます。
— かんたんRuby
フィールドのカプセル化
公開フィールドがある。
それを非公開にして、そのアクセサを用意する。
— 新装版 リファクタリング
自動実行の結果、以下のように書き換えられている部分を変更します。
class FizzBuz、
MAX_NUMBER = 100
attr_reader :list
...
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
...
テストが動作して既存のコードが壊れていないことが確認できたのでここでコミットします。
$ git add .
$ git commit -m 'refactor: フィールドのカプセル化'
引き続き、FizzBuzz配列は保持できるようになりましたがタイプごとに出力される配列のパターンは違います。FizzBuzzクラスにタイプを持たる必要があります。ここでは コンストラクタ を使って インスタンス化 する際に インスタンス変数 に 代入 するようにします。Rubyでは initialize というメソッドを使って初期化処理を実行します。
クラスをインスタンス化した時に初期化処理を行うシチュエーションはよくあります。このような初期化処理を行うメソッドをコンストラクタと呼び、Rubyではinitializeという特別なメソッドを用意することで実現できます。
— かんたんRuby
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
def initialize(type)
@type = type
end
...
...
ERROR["test_3を渡したら文字列3を返す", #<Minitest::Reporters::Suite:0x00005564e21e85b0 @name="数を文字列にして返す::タイプ3の場合::三の倍数の場合">, 0.004276092993677594]
test_3を渡したら文字列3を返す#数を文字列にして返す::タイプ3の場合::三の倍数の場合 (0.00s)
Minitest::UnexpectedError: ArgumentError: wrong number of arguments (given 0, expected 1)
/workspace/tdd_rb/lib/fizz_buzz.rb:7:in `initialize'
/workspace/tdd_rb/test/fizz_buzz_test.rb:101:in `new'
/workspace/tdd_rb/test/fizz_buzz_test.rb:101:in `setup'
...
テストが失敗して引数が違うというエラーが表示される用になりました。new
メソッドの 引数 にタイプを渡すようにテストを変更します。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new(1)
end
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(2)
end
...
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(3)
end
...
end
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz.new(4)
end
...
end
end
...
...
07:28:38 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 6 / 19 LOC (31.58%) covered.
Started with run options --guard --seed 46661
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00793s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
テストは直りましたがまだ インスタンス変数 のタイプが使われていないので使うようにプロダクトコードを変更します。
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
def initialize(type)
@type = type
end
def generate(number, _type = 1)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case @type
...
FizzBuzz.gnerate
メソッドの 引数 から type
を削除します。
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
def initialize(type)
@type = type
end
def generate(number)
...
...
ERROR["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x0000564e16c14200 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.01706391001062002]
test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.02s)
Minitest::UnexpectedError: ArgumentError: wrong number of arguments (given 2, expected 1)
/workspace/tdd_rb/lib/fizz_buzz.rb:11:in `generate'
/workspace/tdd_rb/test/fizz_buzz_test.rb:118:in `test_15を渡したら文字列FizzBuzzを返す'
...
続いて、FizzBuzz#generate
メソッドから不要になった 引数 type
を削除したところテストが壊れたのでテストコードを修正します。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
...
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(2)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.generate(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1)
end
end
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(3)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.generate(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1)
end
end
end
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz.new(4)
end
def test_例外を返す
e = assert_raises RuntimeError do
@fizzbuzz.generate(1)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
...
07:34:57 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 15 / 19 LOC (78.95%) covered.
Started with run options --guard --seed 59116
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00700s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
インスタンス変数 の @type
も アクセッサメソッド を使って フィールドのカプセル化 を適用しておきます。
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
def initialize(type)
@type = type
end
...
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
attr_accessor :type
def initialize(type)
@type = type
end
...
...
Started with run options --guard --seed 56315
32/32: [===========================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.01069s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
コミットしておきます。
$ git add .
$ git commit -m 'refactor: フィールドのカプセル化'
setterの削除
FizzBuzz配列を取得する アクセッサメソッド は現在このように定義されています。
class FizzBuzz
MAX_NUMBER = 100
attr_accessor :list
attr_accessor :type
...
以下のようにテストコードを変更したらどうなるでしょうか?
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
fizzbuzz.list = []
@result = fizzbuzz.list
end
...
...
FAIL["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x0000563c29a8a8c0 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.005137628992088139]
test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Expected: "Fizz"
Actual: nil
/workspace/tdd_rb/test/fizz_buzz_test.rb:58:in `test_配列の2番目は文字列のFizzを返す'
...
FizzBuzz配列が初期化されてしまいました。アクセッサメソッド に参照のための getter と 更新するための setter が許可されているため カプセル化 が破られてしまいました。ここは setterの削除 を適用して外部からの更新を出来ないようにしておきましょう。
getterを定義するには、「attr_reader」を使います。このメソッドにインスタンス変数の「@」を除いた名称をシンボル表現にしたものを列挙します。複数ある場合はカンマで区切って複数の値を指定することができます。
— かんたんRuby
setterを定義するには、「attr_writer」を使います。このメソッドもattr_readerと同じくインスタンス変数名の「@」を除いた名称をシンボル表現にしたものを列挙します。複数ある場合はカンマで区切って複数の値を指定することができます。
— かんたんRuby
getter/setterの両方を定義する場合、そのインスタンスは属しているクラス外から自由に参照や更新ができてしまいます。これはカプセル化の観点には反した挙動なので、できる限りattr_readerだけで済ませられないか検討しましょう。
— かんたんRuby
setterの削除
setterが用意されているということは、フィールドが変更される可能性があることを意味します。オブジェクトを生成した後でフィールドを変更したくないなら、setterは用意しません(加えて、フィールドを変更不可にします)。そうすることで、フィールドはコンストラクタでのみで設定され、変更させないという意図が明確になって、フィールドが変更される可能性を、たいていは排除できます。
— リファクタリング(第2版)
Rubyでは以下のようにして インスタンス変数 を読み取り専用にします。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_accessor :type
...
ERROR["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x000055b32efd75f0 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.008614362974185497]
test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError: NoMethodError: undefined method `list=' for #<FizzBuzz:0x000055b32ee8c678>
Did you mean? list
/workspace/tdd_rb/test/fizz_buzz_test.rb:45:in `setup'
更新メソッドは存在しませんというエラーに変わったことが確認できたのでテストを元にもどします。
同様に インスタンス変数 の @type
も読み取り専用にします。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
...
...
04:32:06 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 22 / 22 LOC (100.0%) covered.
Started with run options --guard --seed 20902
32/32: [===========================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00920s
...
テストが壊れていないことを確認したらコミットします。
$ git add .
$ git commit -m 'refactor: setterの削除'
ポリモーフィズム
ポリモーフィズムによる条件記述の置き換え 1
リファクタリングによりデータとロジックを1つのクラスにまとめて カプセル化 を進めることが出来ました。しかし、以下の警告メッセージが表示されたままです。ポリモーフィズム を使ったロジックのリファクタリングを実施していきましょう。
...
07:53:29 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb lib/fizz_buzz.rb
lib/fizz_buzz.rb:11:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
def generate(number) ...
^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:11:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
def generate(number) ...
^^^^^^^^^^^^^^^^^^^^
2/2 files |====================================== 100 =======================================>| Time: 00:00:00
2 files inspected, 2 offenses detected
...
循環的複雑度 が高く可読性が低く複雑なコードと警告されているようです。対象となっている FizzBuzz#generate
を確認してみましょう。
...
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case @type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
...
コードの不吉な臭いである スイッチ文 に該当するコードのようなのでここはリファクタリングカタログに従って ポリモーフィズムによる条件記述の置き換え を適用していきましょう。比較的大きなリファクタリングなのでいくつかのステップに分けて進めていきます。
スイッチ文
オブジェクト指向プログラミングのメリットして、スイッチ文が従来にくらべて少なくなるということがあります。スイッチ文は重複したコードを生み出す問題児です。コードのあちらこちらに同じようなスイッチ文が見られることがあります。これでは新たな分岐を追加したときに、すべてのスイッチ文を探して似たような変更をしていかなければなりません。オブジェクト指向ではポリモーフィズムを使い、この問題をエレガントに解決できます。
— 新装版 リファクタリング
重複したスイッチ文
最近はポリモーフィズムも一般的となり、15年前に比べるとswitch文が単純に赤信号というわけでもなくなりました。また、多くのプログラミング言語が、基本データ型以外をサポートする、より洗練されたswitch文を提供してきています。そこで、今後問題とするのは、重複したswitch文のみとします。switch/case文や、ネストしたif/else文の形で、コードのさまざまな箇所に同じ条件分岐ロジックが書かれていれば、それは「不吉な臭い」です。重複した条件分岐が問題なのは、新たな分岐を追加したら、すべての重複した条件分岐を探して更新指定かなけれけならないからです。ポリモーフィズムは、そうした単調な繰り返しに誘うダークフォースに対抗するための、洗練された武器です。コードベースをよりモダンにしていきましょう。
— リファクタリング(第2版)
ポリモーフィズムによる条件記述の置き換え
オブジェクトのタイプによって異なる振る舞いを選択する条件記述がある。
条件記述の各アクション部をサブクラスでオーバーライドするメソッドに移動する。元のメソッドはabstractにする。
— 新装版 リファクタリング
class FizzBuzz
...
end
class FizzBuzzType01; end
class FizzBuzzType02; end
class FizzBuzzType03; end
まず、タイプごとのクラスを定義します。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
end
def self.create(type)
case type
when 1
FizzBuzzType01.new
when 2
FizzBuzzType02.new
when 3
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
...
次に、タイプごとのクラスを インスタンス化 する ファクトリメソッド をFizzBuzzクラスに追加します。この時点では新しいクラスとメソッドの追加だけなのでテストは壊れていないはずです(警告は出ていますが・・・)。ここでコミットしておきますがリファクタリング作業としては 仕掛 なのでWIP(Work In Progress)をメッセージに追加してコミットします。
$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 2
続いて、各タイプクラスに インスタンスメソッド を実装します。ここでは case式 の各処理をコピー&ペーストしています。カット&ペーストするとプロダクトコードが壊れたままリファクタリングを進めることになるのでここは慎重に進めていきます。
class FizzBuzz
...
end
class FizzBuzzType01; end
class FizzBuzzType02; end
class FizzBuzzType03; end
...
class FizzBuzzType01
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
end
end
...
...
class FizzBuzzType02
def generate(number)
number.to_s
end
end
...
...
class FizzBuzzType03
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
end
end
警告は出ますがテストは壊れていないのでコミットします。
$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 3
これで準備は整いましたのでテストコードの setup
メソッドを ファクトリメソッド の呼び出しに変更します。以下の部分は変更してはいけません。理由はわかりますか?
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.create(1)
end
...
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.create(2)
end
...
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.create(3)
end
...
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz.create(4)
end
def test_例外を返す
e = assert_raises RuntimeError do
@fizzbuzz.generate(1)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
08:14:14 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 26 / 42 LOC (61.9%) covered.
Started with run options --guard --seed 37585
ERROR["test_例外を返す", #<Minitest::Reporters::Suite:0x000056317940fa28 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.0037079370085848495]
test_例外を返す#数を文字列にして返す::それ以外のタイプの場合 (0.00s)
Minitest::UnexpectedError: RuntimeError: 該当するタイプは存在しません
/workspace/tdd_rb/lib/fizz_buzz.rb:20:in `create'
/workspace/tdd_rb/test/fizz_buzz_test.rb:132:in `setup'
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00685s
32 tests, 33 assertions, 0 failures, 1 errors, 0 skips
...
失敗するテストがありますね、該当するコードを確認したところ例外が発生するタイミングが変わってしまったので以下のように変更します。
...
describe 'それ以外のタイプの場合' do
def setup
@fizzbuzz = FizzBuzz.create(4)
end
def test_例外を返す
e = assert_raises RuntimeError do
@fizzbuzz.generate(1)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
...
...
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzz.create(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
...
...
08:18:08 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 37 / 42 LOC (88.1%) covered.
Started with run options --guard --seed 40171
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00559s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...
コミットしておきましょう。
$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 4
タイプごとにFizzBuzzを生成するクラスを用意したのでFizzBuzzクラスから呼び出せるようにしましょう。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
end
...
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
まず、コンストラクタ から クラスメソッド の ファクトリメソッド を呼び出して インスタンス変数 の type
にタイプクラスの 参照 を 代入 します。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = FizzBuzz.create(type)
end
...
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x000055670a343110 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.006740843993611634]
test_配列の14番目は文字列のFizzBuzzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError: RuntimeError: 該当するタイプは存在しません
/workspace/tdd_rb/lib/fizz_buzz.rb:42:in `generate'
/workspace/tdd_rb/lib/fizz_buzz.rb:48:in `block in generate_list'
/workspace/tdd_rb/lib/fizz_buzz.rb:48:in `each'
/workspace/tdd_rb/lib/fizz_buzz.rb:48:in `map'
/workspace/tdd_rb/lib/fizz_buzz.rb:48:in `generate_list'
/workspace/tdd_rb/test/fizz_buzz_test.rb:44:in `setup'
テストが失敗して沢山エラーが表示するようになりましたが落ち着いてください。次に インスタンスメソッド FizzBuzz#generate_list
内の FizzBuzz#generate
メソッド呼び出しを インスタンス変数 type
が参照するタイプクラスのメソッド FizzBuzzTypeXX#generate
を呼び出すように変更します。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = FizzBuzz.create(type)
end
...
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
end
end
Started with run options --seed 13878
Progress: |=====================================================================================================|
Finished in 0.00960s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
05:54:49 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
lib/fizz_buzz.rb:24:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
def generate(number) ...
^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:24:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
def generate(number) ...
^^^^^^^^^^^^^^^^^^^^
1/1 file |======================================= 100 ========================================>| Time: 00:00:00
1 file inspected, 2 offenses detected
再びテストが通るようになりました。始めのうちはコードを少し変更しただけでなんで動くようになったの?と思うかもしれませんがこれが ポリモーフィズム の威力です。この概念を感覚としてつかんで使いこなせるようになることがオブジェクト指向プログラミングの第一歩です。感覚は意識して手を動かしていればそのうちつかめます(多分)。
ポリモーフィズムによる条件記述の置き換え が完了したのでWIPを外してコミットします。
$ git add .
$ git commit -m 'refactor ポリモーフィズムによる条件記述の置き換え'
State/Strategyによるタイプコードの置き換え
仕上げは State/Strategyによるタイプコードの置き換え を適用して、警告メッセージを消すとしましょう。
State/Strategyによるタイプコードの置き換え
クラスの振る舞いに影響するタイプコードがあるが、サブクラス化はできない。
状態オブジェクトでタイプコードを置き換える
— 新装版 リファクタリング
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = FizzBuzz.create(type)
end
def self.create(type)
case type
when 1
FizzBuzzType01.new
when 2
FizzBuzzType02.new
when 3
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
case @type
when 1
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
when 2
number.to_s
when 3
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
else
raise '該当するタイプは存在しません'
end
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
end
end
...
まず、FizzBuzz#generate
のメソッド呼び出しを インスタンス変数 type
が参照するタイプクラスのメソッド FizzBuzzTypeXX#generate
に 委譲 するように変更します。
...
def generate(number)
@type.generate(number)
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
end
end
...
...
Started with run options --seed 49543
Progress: |=====================================================================================================|
Finished in 0.00925s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
06:34:27 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |======================================= 100 ========================================>| Time: 00:00:00
1 file inspected, no offenses detected
06:34:29 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |======================================= 100 =======================================>| Time: 00:00:00
0 files inspected, no offenses detecte
...
警告が消えました。しかもテストは壊れていないようです。実は FizzBuzz#generate
メソッドはどこからも使われていないためテストも壊れることが無いのですがこれでは不要なメソッドになってしまうので 移譲の隠蔽 を実施して、ロジックを カプセル化 します。
委譲の隠蔽
オブジェクト指向について最初に教わる時、カプセル化とはフィールドを隠すことだと習うでしょう。しかし経験を積むにつれて、他にもカプセル化できるものがあることに気づきます。
— リファクタリング(第2版)
...
def generate(number)
@type.generate(number)
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
...
テストもFizzBuzzインスタンス経由で実行するように修正しておきます。これですべての呼び出しが new
メソッド経由となりテストコードに一貫性を取り戻すことが出来ました。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new(1)
end
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(2)
end
...
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(3)
end
...
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzz.new(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
...
08:32:17 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 32 / 32 LOC (100.0%) covered.
Started with run options --guard --seed 63863
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00564s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:32:18 - INFO - Inspecting Ruby code style of all files
7/7 files |====================================== 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
...
ポリモーフィズム の感覚がつかめないうちは FizzBuzz#generate
のコードが一行になったのに既存のテストも壊れず動いていることが不思議に思うかもしれません。しかしコードとしてはFizzBuzzクラスの generate
メソッドは任意のタイプクラスの generate
メソッドを呼び出しているだけで処理の詳細は理解しなくても振る舞いを理解できる 抽象化 された読みやすいコードになりました。静的コード解析も可読性が高くシンプルなコードとみなしてくれているようです。さて、警告メッセージもなくなり、テストも壊れていないのでコミットしておきましょう。
$ git add .
$ git commit -m 'refactor: State/Strategyによるタイプコードの置き換え'
継承
分割したタイプクラスのメソッドに重複する処理があるので 継承 を使ってリファクタリングしましょう。ここでは スーパークラスの抽出を適用します。
スーパークラスの抽出
似通った特性を持つ2つのクラスがある。
スーパークラスを作成して、共通の特性を移動する。
— 新装版 リファクタリング
スーパークラスの抽出
まずは、タイプクラスのスーパークラスとなる FizzBuzzType
クラスを作成して各タイプクラスに継承させます。
クラスベースのオブジェクト指向言語の多くはクラスの継承機能を有しています。クラスの継承とはあるクラスを元として、新しいクラスを定義することです。この時、継承元となるクラスを親クラスやスーパークラスと呼び、継承したクラスのことを子クラスやサブクラスと呼びます。
— かんたんRuby
Rubyの クラスの継承 は以下のように書きます。
class FizzBuzz
...
end
class FizzBuzzType; end
class FizzBuzzType01
...
...
class FizzBuzzType; end
class FizzBuzzType01 < FizzBuzzType
...
end
class FizzBuzzType02 < FizzBuzzType
...
end
class FizzBuzzType03 < FizzBuzzType
...
end
スーパークラス FizzBuzzType
を定義して各サブクラスに継承させます。
08:42:24 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 43548
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00860s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:42:25 - INFO - Inspecting Ruby code style of all files
7/7 files |====================================== 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
次に is_fizz
is_buzz
部分を共通メソッドとしてスーパークラスに定義して各タイプクラスで呼び出すように変更します。
...
class FizzBuzzType; end
class FizzBuzzType01 < FizzBuzzType
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
return 'Fizz' if is_fizz
return 'Buzz' if is_buzz
number.to_s
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
number.to_s
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
is_fizz = number.modulo(3).zero?
is_buzz = number.modulo(5).zero?
return 'FizzBuzz' if is_fizz && is_buzz
number.to_s
end
end
...
class FizzBuzzType
def is_fizz(number)
number.modulo(3).zero?
end
def is_buzz(number)
number.modulo(5).zero?
end
end
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
return 'Fizz' if is_fizz(number)
return 'Buzz' if is_buzz(number)
number.to_s
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
number.to_s
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
number.to_s
end
end
08:50:16 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 45685
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.01073s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:50:17 - INFO - Inspecting Ruby code style of all files
lib/fizz_buzz.rb:35:7: C: Naming/PredicateName: Rename is_fizz to fizz?.
def is_fizz(number)
^^^^^^^
lib/fizz_buzz.rb:39:7: C: Naming/PredicateName: Rename is_buzz to buzz?.
def is_buzz(number)
^^^^^^^
7/7 files |====================================== 100 =======================================>| Time: 00:00:00
7 files inspected, 2 offenses detected
テストが壊れていないことが確認できたのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: スーパークラスの抽出'
メソッド名の変更
スーパークラスの抽出 を実施したところまた警告メッセージが表示されるようになりました。
08:50:19 - INFO - Inspecting Ruby code styl
e: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png lib/fizz_buzz.rb
lib/fizz_buzz.rb:35:7: C: Naming/PredicateName: Rename is_fizz to fizz?.
def is_fizz(number)
^^^^^^^
lib/fizz_buzz.rb:39:7: C: Naming/PredicateName: Rename is_buzz to buzz?.
def is_buzz(number)
^^^^^^^
1/1 file |======================================= 100 =======================================>| Time: 00:00:00
1 file inspected, 2 offenses detected
Naming/PredicateName Rubyのネーミングとしてはよろしくないようなので指示に従って メソッド名の変更 を実施しましょう。
...
class FizzBuzzType
def is_fizz(number)
number.modulo(3).zero?
end
def is_buzz(number)
number.modulo(5).zero?
end
end
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
return 'Fizz' if is_fizz(number)
return 'Buzz' if is_buzz(number)
number.to_s
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
number.to_s
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
number.to_s
end
end
...
class FizzBuzzType
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if fizz?(number) && buzz?(number)
return 'Fizz' if fizz?(number)
return 'Buzz' if buzz?(number)
number.to_s
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
number.to_s
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if fizz?(number) && buzz?(number)
number.to_s
end
end
Progress: |====================================================================================================|
Finished in 0.01144s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:53:35 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |======================================= 100 =======================================>| Time: 00:00:00
1 file inspected, no offenses detected
作業としては難しくないのでミスタイプしないように(まあ、ミスタイプしてもテストが教えてくれますが・・・)変更してコミットしましょう。
$ git add .
$ git commit -m 'refactor: メソッド名の変更'
メソッドの移動
FizzBuzz
クラスの ファクトリメソッド ですが 特性の横恋慕 の臭いがするので メソッドの移動 を実施します。
特性の横恋慕
オブジェクト指向には、処理および処理に必要なデータを1つにまとめてしまうという重要な考え方があります。あるメソッドが、自分のクラスより他のクラスに興味を持つような場合には、古典的な誤りを犯しています。
— 新装版 リファクタリング
メソッドの移動
あるクラスでメソッドが定義されているが、現在または将来において、そのクラスの特性よりも他のクラスの特性の方が、そのメソッドを使ったり、そのメソッドから使われたりすることが多い。
同様の本体を持つ新たなメソッドを、それを最も多用するクラスに作成する。元のメソッドは、単純な委譲とするか、またはまるごと取り除く。
— 新装版 リファクタリング
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
def initialize(type)
@type = FizzBuzz.create(type)
end
def self.create(type)
case type
when 1
FizzBuzzType01.new
when 2
FizzBuzzType02.new
when 3
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
def generate(number)
@type.generate(number)
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
class FizzBuzzType
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
...
クラスメソッド FizzBuzz.create
をカット&ペーストして FizzBuzzType.create
に移動します。 FizzBuzz
の コンストラクタ で呼び出している クラスメソッド を FizzBuzzType.create
に変更します。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
def initialize(type)
@type = FizzBuzzType.create(type)
end
def generate(number)
@type.generate(number)
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
class FizzBuzzType
def self.create(type)
case type
when 1
FizzBuzzType01.new
when 2
FizzBuzzType02.new
when 3
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
...
08:59:27 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 19583
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00688s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:59:28 - INFO - Inspecting Ruby code style of all files
7/7 files |====================================== 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
テストが壊れていないことを確認したらコミットします。
$ git add .
$ git commit -m 'refactor: メソッドの移動'
値オブジェクト
オブジェクトによるプリミティブの置き換え
FizzBuzz
クラスを インスタンス化 するには以下のように書きます。
fizz_buzz = FizzBuzz.new(1)
クラスとして定義された情報を元に具体的な値を伴ったオブジェクトを作成することをインスタンス化と呼び、生成されたオブジェクトのことをインスタンスと呼びます。
— かんたんRuby
コンストラクタ の 引数 に渡される 1
は何を表しているのでしょうか?もちろんタイプですが初めてこのコードを見る人にはわからないでしょう。このような整数、浮動小数点、文字列などの基本データ(プリミティブ)型の使い方からは 基本データ型への執着の臭いがします。 オブジェクトによるプリミティブの置き換え を実施してコードの意図を明確にしましょう。
基本データ型への執着
オブジェクト指向のメリットとして、基本データ型とそれより大きなクラスとの境界を取り除くということがあります。プログラミング言語の組み込み(built-in)型と区別できないような小さなクラスを自分で定義することが容易です。
— 新装版 リファクタリング
基本データ型への執着
興味深いことに、多くのプログラマは、対象としているドメインに役立つ、貨幣、座標、範囲などの基本的な型を導入するのを嫌がる傾向があります。
— リファクタリング(第2版)
オブジェクトによるデータ値の置き換え
追加のデータや振る舞いが必要なデータ項目がある。
そのデータ項目をオブジェクトに変える。
— 新装版 リファクタリング
オブジェクトによるプリミティブの置き換え
旧:オブジェクトによるデータ値の置き換え
旧:クラスによるタイプコードの置き換え
— リファクタリング(第2版)
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = FizzBuzzType.create(type)
end
...
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
end
...
コンストラクタ で引き渡されるタイプは整数ではなくタイプクラスの インスタンス に変更します。
...
ERROR["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005654f32602c0 @name="数を文字列にして返す::タイプ3の場合::その他の場合">, 0.00241121300496161]
test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ3の場合::その他の場合 (0.00s)
Minitest::UnexpectedError: NoMethodError: undefined method `generate' for 3:Integer
/workspace/tdd_rb/lib/fizz_buzz.rb:12:in `generate'
/workspace/tdd_rb/test/fizz_buzz_test.rb:125:in `test_1を渡したら文字列1を返す'
...
テストが失敗しました。 コンストラクタ の引数を整数からタイプクラスの インスタンス に変更します。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new(1)
end
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(1)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(2)
end
...
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(3)
end
...
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzz.new(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
ここで注意するのは それ以外のタイプの場合
ですが例外を投げなくなります。静的に型付けされた言語なら型チェックエラーになるのですがRubyは動的に型付けされる言語のため FizzBuzz#generate
メソッド実行までエラーになりません。そこで例外を投げる FizzBuzzType#create
メソッドに変更しておきます。
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
end
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType02.new)
end
...
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType03.new)
end
...
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzzType.create(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
それ以外のタイプの場合は ファクトリメソッド 経由でないと 例外 を出さなくなるので注意してください。
09:09:40 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 30 / 33 LOC (90.91%) covered.
Started with run options --guard --seed 17452
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00687s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
初めてコードを見る人でもテストコードを見ればコードの意図が読み取れるようになりましたのでコミットします。
$ git add .
$ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'
マジックナンバーの置き換え
まだプリミティグ型を使っている部分があります。ここは マジックナンバーの置き換え を実施して可読性を上げておきましょう。
...
class FizzBuzzType
def self.create(type)
case type
when 1
FizzBuzzType01.new
when 2
FizzBuzzType02.new
when 3
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
...
...
class FizzBuzzType
TYPE_01 = 1
TYPE_02 = 2
TYPE_03 = 3
def self.create(type)
case type
when FizzBuzzType::TYPE_01
FizzBuzzType01.new
when FizzBuzzType::TYPE_02
FizzBuzzType02.new
when FizzBuzzType::TYPE_03
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
...
09:18:51 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 36 LOC (91.67%) covered.
Started with run options --guard --seed 41124
32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00909s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
テストは壊れていないのでコミットします。
$ git add .
$ git commit -m 'refactor: マジックナンバーの置き換え'
オブジェクトによるプリミティブの置き換え
次に 基本データ型への執着 の臭いがする箇所として FizzBuzz#generate
メソッドが返すFizzBuzzの値が文字型である点です。文字列の代わりに 値オブジェクト FizzBuzzValue
クラスを定義します。
値の種類ごとに専用の型を用意するとコードが安定し、コードの意図が明確になります。このように、値を扱うための専用クラスを作るやり方を値オブジェクト(ValueObject)と呼びます。
— 現場で役立つシステム設計の原則
...
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
@number = number
@value = value
end
def to_s
"#{@number}:#{@value}"
end
def ==(other)
@number == other.number && @value == other.value
end
alias eql? ==
end
各タイプクラスの generate
メソッドが文字列のプリミティブ型を返しているので 値オブジェクト FizzBuzzValue
を返すように変更します。
...
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if fizz?(number) && buzz?(number)
return 'Fizz' if fizz?(number)
return 'Buzz' if buzz?(number)
number.to_s
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
number.to_s
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return 'FizzBuzz' if fizz?(number) && buzz?(number)
number.to_s
end
end
...
...
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
class FizzBuzzType02 < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, number.to_s)
end
end
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
...
...
FAIL["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x000055feccc65ab8 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.012104410998290405]
test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
--- expected
+++ actual
@@ -1 +1 @@
-"Fizz"
+#<FizzBuzzValue:0xXXXXXX @number=3, @value="Fizz">
/workspace/tdd_rb/test/fizz_buzz_test.rb:57:in `test_配列の2番目は文字列のFizzを返す'
...
変更によりテストが失敗しました。エラー内容を見てみると文字列からオブジェクトを返しているためアサーションが失敗しているようです。ここは、値オブジェクト の アクセッサメソッド を経由して取得した値をアサーション対象に変更しましょう。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列Fizzを返す
assert_equal 'Fizz', @fizzbuzz.generate(3).value
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列Buzzを返す
assert_equal 'Buzz', @fizzbuzz.generate(5).value
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.generate(15).value
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1).value
end
end
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
def test_配列の初めは文字列の1を返す
assert_equal '1', @result.first.value
end
def test_配列の最後は文字列のBuzzを返す
assert_equal 'Buzz', @result.last.value
end
def test_配列の2番目は文字列のFizzを返す
assert_equal 'Fizz', @result[2].value
end
def test_配列の4番目は文字列のBuzzを返す
assert_equal 'Buzz', @result[4].value
end
def test_配列の14番目は文字列のFizzBuzzを返す
assert_equal 'FizzBuzz', @result[14].value
end
end
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType02.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3).value
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5).value
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.generate(15).value
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1).value
end
end
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType03.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.generate(3).value
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.generate(5).value
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.generate(15).value
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.generate(1).value
end
end
end
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzzType.create(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
08:49:28 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 41 / 46 LOC (89.13%) covered.
Started with run options --guard --seed 25972
32/32: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00619s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:49:29 - INFO - Inspecting Ruby code style of all files
7/7 files |======================================= 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
08:49:30 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |======================================= 100 =======================================>| Time: 00:00:00
0 files inspected, no offenses detected
テストコードをそれほど変更することなく 値オブジェクト を返すリファクタリングが出来ました。コミットしておきましょう。
$ git add .
$ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'
学習用テスト
値オブジェクト の理解を深めるために 学習用テスト を追加します。
...
describe 'FizzBuzzValue' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
end
def test_同じで値である
value1 = @fizzbuzz.generate(1)
value2 = @fizzbuzz.generate(1)
assert value1.eql?(value2)
end
def test_to_stringメソッド
value = @fizzbuzz.generate(3)
assert_equal '3:Fizz', value.to_s
end
end
end
$ git add .
$ git commit -m 'test: 学習用テスト'
ファーストクラスコレクション
コレクションのカプセル化
値オブジェクト を扱うFizzBuzzリストですが コレクションのカプセル化 を適用して ファーストクラスコレクション オブジェクトを追加しましょう。
コレクションのカプセル化
メソッドがコレクションを返している。
読み取り専用のビューを返して、追加と削除のメソッドを提供する。
— 新装版 リファクタリング
このように、コレクション型のデータとロジックを特別扱いにして、コレクションを1つだけ持つ専用クラスを作るやり方をコレクションオブジェクトあるいはファーストクラスコレクションと呼びます。
— 現場で役立つシステム設計の原則
まず、 ファーストクラスコレクション クラスを追加します。
...
class FizzBuzzList
attr_reader :value
def initialize(list)
@value = list
end
def to_s
@value.to_s
end
def add(value)
FizzBuzzList.new(@value + value)
end
end
FizzBuzz配列を ファーストクラスコレクション から取得するように変更します。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
end
...
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = (1..MAX_NUMBER).map { |n| generate(n) }
end
end
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
@list = FizzBuzzList.new([])
end
...
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = @list.add((1..MAX_NUMBER).map { |n| @type.generate(n) })
end
end
なんだか紛らわしい書き方になってしましました。配列を作るのに以前の配列を元に新しい配列を作るとか回りくどいことをしないで既存の配列を使い回せばいいじゃんと思うかもしれませんが 変更可能なデータ はバグの原因となる傾向があります。変更可能な ミュータブル な変数ではなく 永続的に変更されない イミュータブル な変数を使うように心がけましょう。
変更可能なデータ
データの変更はしばし予期せぬ結果結果や、厄介なバグを引き起こします。他で違う値を期待していることに気づかないままに、ソフトウェアのある箇所で値を変更してしまえば、それだけで動かなくなってしまいます。これは値が変わる条件がまれにしかない場合、特に見つけにくいバグとなります。そのため、ソフトウェア開発の一つの潮流である関数型プログラミングは、データは不変であるべきで、更新時は常に元にデータ構造のコピーを返すようにし、元データには手を触れないという思想に基づいています。
— リファクタリング(第2版)
値オブジェクトと同じようにコレクションオブジェクトも、できるだけ「不変」スタイルで設計します。そのほうがプログラムが安定します。
— 現場で役立つシステム設計の原則
...
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005561331b7940 @name="FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.011710233025951311]
test_配列の14番目は文字列のFizzBuzzを返す#FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError: NoMethodError: undefined method `[]' for #<FizzBuzzList:0x0000556133198ba8 @value=[]>
/workspace/tdd_rb/test/fizz_buzz_test.rb:66:in `test_配列の14番目は文字列のFizzBuzzを返す'
...
ファーストクラスコレクション 経由で取得するようになったので アクセッサメソッド を変更する必要があります。
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
end
...
class FizzBuzz
MAX_NUMBER = 100
attr_reader :list
attr_reader :type
def initialize(type)
@type = type
@list = FizzBuzzList.new([])
end
...
class FizzBuzz
MAX_NUMBER = 100
attr_reader :type
def list
@list.value
end
def initialize(type)
@type = type
@list = FizzBuzzList.new([])
end
....
09:12:46 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 53 / 56 LOC (94.64%) covered.
Started with run options --guard --seed 61051
34/34: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.01285s
34 tests, 37 assertions, 0 failures, 0 errors, 0 skips
09:12:47 - INFO - Inspecting Ruby code style of all files
7/7 files |======================================= 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
09:12:48 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |======================================= 100 =======================================>| Time: 00:00:00
0 files inspected, no offenses detected
テストが直ったのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: コレクションのカプセル化'
学習用テスト
ファーストクラスコレクション を理解するため 学習用テスト を追加しておきましょう。
...
describe 'FizzBuzzValueList' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
end
def test_新しいインスタンスが作られる
list1 = @fizzbuzz.generate_list
list2 = list1.add(list1.value)
assert_equal 100, list1.value.count
assert_equal 200, list2.value.count
end
end
end
$ git add .
$ git commit -m 'refactor: 学習用テスト'
オブジェクト指向設計
値オブジェクト 及び ファーストクラスコレクション の適用で 基本データ型への執着 の臭いはなくなりました。今度は設計の観点から全体を眺めてみましょう。ここで気になるのが FizzBuzz
クラスです。このクラスは他のクラスと比べてやることが多いようです。このようなクラスは 単一責任の原則 に違反している可能性があります。そこで デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え 適用してみようと思います。
SRP:
単一責任の原則かつて単一責任の原則(SRP)は、以下のように語られてきた。
モジュールを変更する理由はたったひとつだけであるべきである
ソフトウェアシステムに手を加えるのは、ユーザーやステークホルダーを満足させるためだ。この「ユーザーやステークホルダー」こそが、単一責任の原則(SRP)を指す「変更する理由」である。つまり、この原則は以下のように言い換えられる。
モジュールはたったひとりのユーザーやステークホルダーに対して責任を負うべきである。
残念ながら「たったひとりのユーザーやステークホルダー」という表現は適切ではない。複数のユーザーやステークホルダーがシステムを同じように変更したいと考えることもある。ここでは、変更を望む人たちをひとまとめにしたグループとして扱いたい。このグループのことをアクターと呼ぶことにしよう。
これを踏まえると、最終的な単一責任の原則(SRP)は以下のようになる。モジュールはたったひとつのアクターに対して責任を負うべきである。
さて、ここでいう「モジュール」とは何のことだろう?端的に言えば、モジュールとはソースファイルのことである。たいていの場合は、この定義で問題ないだろう。だが、ソースファイル以外のところにコードを格納する言語や開発環境も存在する。そのような場合の「モジュール」は、いくつかの関数やデータをまとめた凝集性のあるものだと考えよう。
「凝集性のある」という言葉が単一責任の原則(SRP)を匂わせる。凝集性が、ひとつのアクターに対する責務を負うコードをまとめるフォースとなる。
— Clean Architecture 達人に学ぶソフトウェアの構造と設計
Commandパターン
処理の呼び出しが、シンプルなメソッド呼び出しよりも複雑になってきたときはどうすればよいだろうか---処理のためのオブジェクトを作成し、それを起動するようにしよう。
— テスト駆動開発
メソッドオブジェクトによるメソッドの置き換え
長いメソッドで、「メソッドの抽出」を適用できないようなローカル変数の使い方をしている。
メソッド自身をオブジェクトとし、すべてのローカル変数をそのオブジェクトのフィールドとする。そうすれば、そのメソッドを同じオブジェクト中のメソッド群に分解できる。
— 新装版 リファクタリング
メソッドオブジェクトによるメソッドの置き換え
まず、値オブジェクト の FizzBuzzValue
を返す責務だけを持った メソッドオブジェクト を抽出します。Rubyのような動的言語では必要が無いのですが Commandパターン の説明のため インターフェイス にあたるスーパークラスを継承した メソッドオブジェクト を定義します。
...
class FizzBuzzCommand
def execute; end
end
class FizzBuzzValueCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
@type.generate(number).value
end
end
テストコードを FizzBuzzValueCommand
を呼び出すように変更します。
...
class FizzBuzzTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列Fizzを返す
assert_equal 'Fizz', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列Buzzを返す
assert_equal 'Buzz', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
def test_配列の初めは文字列の1を返す
assert_equal '1', @result.first.value
end
def test_配列の最後は文字列のBuzzを返す
assert_equal 'Buzz', @result.last.value
end
def test_配列の2番目は文字列のFizzを返す
assert_equal 'Fizz', @result[2].value
end
def test_配列の4番目は文字列のBuzzを返す
assert_equal 'Buzz', @result[4].value
end
def test_配列の14番目は文字列のFizzBuzzを返す
assert_equal 'FizzBuzz', @result[14].value
end
end
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzzType.create(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
...
09:56:19 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 60 / 63 LOC (95.24%) covered.
Started with run options --guard --seed 27353
35/35: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00692s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips
09:56:20 - INFO - Inspecting Ruby code style of all files
7/7 files |======================================= 100 =======================================>| Time: 00:00:00
7 files inspected, no offenses detected
09:56:21 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
0/0 files |======================================= 100 =======================================>| Time: 00:00:00
...
FizzBuzzValueCommand
の抽出ができたのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'
メソッドオブジェクトによるメソッドの置き換え
続いて、ファーストクラスコレクション を扱う FizzBuzzList
を返す責務だけを持った メソッドオブジェクト を抽出します。
...
class FizzBuzzListCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
end
end
テストコードを FizzBuzzListCommand 経由から実行するように変更します
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
fizzbuzz.generate_list
@result = fizzbuzz.list
end
...
...
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
@result = fizzbuzz.execute(100)
end
...
01:27:54 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 61 / 66 LOC (92.42%) covered.
Started with run options --guard --seed 62253
35/35: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00652s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips
テストが通ったのでコミットします。
$ git add .
$ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'
デッドコードの削除
FizzBuzz
クラスの責務は各 メソッドオブジェクト が実行するようになったので削除しましょう。
class FizzBuzz
MAX_NUMBER = 100
def initialize(type)
@type = type
@list = FizzBuzzList.new([])
end
def list
@list.value
end
def generate(number)
@type.generate(number)
end
def generate_list
# 1から最大値までのFizzBuzz配列を1発で作る
@list = @list.add((1..MAX_NUMBER).map { |n| @type.generate(n) })
end
end
class FizzBuzzType
...
class FizzBuzzType
...
...
ERROR["test_同じで値である", #<Minitest::Reporters::Suite:0x0000562fd34f7848 @name="FizzBuzzValue">, 0.008059715997660533]
test_同じで値である#FizzBuzzValue (0.01s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::FizzBuzz
/workspace/tdd_rb/test/fizz_buzz_test.rb:225:in `setup'
ERROR["test_to_stringメソッド", #<Minitest::Reporters::Suite:0x0000562fd37694a0 @name="FizzBuzzValue">, 0.01728590900893323]
test_to_stringメソッド#FizzBuzzValue (0.02s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::FizzBuzz
/workspace/tdd_rb/test/fizz_buzz_test.rb:225:in `setup'
ERROR["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000562fd39be070 @name="FizzBuzzValueList">, 0.028008958004647866]
test_新しいインスタンスが作られる#FizzBuzzValueList (0.03s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::FizzBuzz
/workspace/tdd_rb/test/fizz_buzz_test.rb:244:in `setup'
========================================|
Finished in 0.03539s
35 tests, 35 assertions, 0 failures, 3 errors, 0 skips
...
テストが失敗しました。これは 学習用テスト で FizzBuzz
クラスを使っている箇所があるからですね。 メソッドオブジェクト 呼び出しに変更しておきましょう。
describe 'FizzBuzzValue' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
end
def test_同じで値である
value1 = @fizzbuzz.generate(1)
value2 = @fizzbuzz.generate(1)
assert value1.eql?(value2)
end
def test_to_stringメソッド
value = @fizzbuzz.generate(3)
assert_equal '3:Fizz', value.to_s
end
end
describe 'FizzBuzzValueList' do
def setup
@fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
end
def test_新しいインスタンスが作られる
list1 = @fizzbuzz.generate_list
list2 = list1.add(list1.value)
assert_equal 100, list1.value.count
assert_equal 200, list2.value.count
end
end
end
...
describe 'FizzBuzzValue' do
def test_同じで値である
value1 = FizzBuzzValue.new(1, '1')
value2 = FizzBuzzValue.new(1, '1')
assert value1.eql?(value2)
end
def test_to_stringメソッド
value = FizzBuzzValue.new(3, 'Fizz')
assert_equal '3:Fizz', value.to_s
end
end
describe 'FizzBuzzValueList' do
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(100)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 100, list1.value.count
assert_equal 200, list2.value.count
end
end
end
...
01:35:22 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 50 / 56 LOC (89.29%) covered.
Started with run options --guard --seed 10411
35/35: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00704s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips
...
不要なコードを残しておくとメンテナンスの時に削除していいのかわからなくなり可読性を落とし原因となります。削除できる時に削除しておきましょう。後で必要になったとしてもバージョン管理システムを使えば問題ありません。ということでコミットします。
デッドコードの削除
コードが使用されなくなったら削除すべきです。そのコードが将来必要になるかもしれないなどという心配はしません。必要になったらいつでも、バージョン管理システムから再び掘り起こせるからです。
(中略)
デッドコードのコメントアウトは、かつては一般的な習慣でした。それは、バージョン管理システムが広く使用される以前の時代や、使いづらかった時代には有用でした。現在では、とても小さなコードベースでもバージョン管理システムに置けるため、もはや必要のない習慣です。
— リファクタリング(第2版)
$ git add .
$ git commit -m 'refactor: デッドコードの削除'
デザインパターン
メソッドオブジェクトによるメソッドの置き換え リファクタリングの結果として Commandパターン という デザインパターン を適用しました。実はこれまでにも オブジェクトによるプリミティブの置き換え では Value Objectパターン を ポリモーフィズムによる条件記述の置き換え では Factory Methodパターン をそして、 委譲の隠蔽 の実施による State/Strategyによるタイプコードの置き換え では Strategyパターン を適用しています。
Value Objectパターン
広く共有されるものの、同一インスタンスであることはさほど重要でないオブジェクトを設計するにはどうしたらよいだろうか----オブジェクト作成時に状態を設定したら、その後決して変えないようにする。オブジェクトへの操作は必ず新しいオブジェクトを返すようにしよう。
— テスト駆動開発
Factory Methodパターン
オブジェクト作成に柔軟性をもたせたいときは、どうすればよいだろうか---単にコンストラクタで作るのではなく、メソッドを使ってオブジェクトを作成しよう。
— テスト駆動開発
作成したコードはパターンと完全に一致しているわけではありませんし、Rubyのような動的言語ではもっと簡単な実現方法もありますがここでは先人の考えた設計パターンというものがありオブジェクト指向設計の イデオム として使えること。そしてテスト駆動開発では一般的な設計アプローチとは異なる形で導かれているということくらいを頭に残しておけば結構です。どのパターンをいつ適用するかはリファクタリングを繰り返しているうちに思いつくようになってきます(多分)。
ただ、書籍『デザインパターン』(通称Gof本)の大ヒットは、その反面、それらパターンを表現する方法の多様性を奪ってしまった。Gof本には、設計をフェーズとして扱うという暗黙の前提があるように見受けられる。つまり、リファクタリングを設計行為として捉えていない。TDDにおける設計は、デザインパターンを少しだけ違う側面から捉えなければならない。
— テスト駆動開発
あと、設計の観点から今回 単一責任の原則 に従って FizzBuzz
クラスを メソッドオブジェクト に分割して削除しました。
もし、新しい処理を追加する必要が発生した場合はどうしましょうか? FizzBuzzCommand
インターフェイスを実装した メソッドオブジェクト を追加しましょう。
もし、新しいタイプが必要になったらどうしましょうか? FizzBuzzType
クラスを継承した新しいタイプクラスを追加しましょう。
このように既存のコードを変更することなく振る舞いを変更できるので オープン・クローズドの原則 を満たした設計といえます。
OCP:オープン・クローズドの原則
「オープン・クローズドの原則(OCP)」は、1988年にBertrand Maeerが提唱した以下のような原則だ。
ソフトウェアの構成要素は拡張に対しては開いていて、修正に対しては閉じていなければならない。 『アジャイルソフトウェア開発の奥義 第2版』(SBクリエイティブ)より引用
言い換えれば、ソフトウェアの振る舞いは、既存の成果物を変更せず拡張できるようにすべきである、ということだ。
— Clean Architecture 達人に学ぶソフトウェアの構造と設計
例外
ここまでは、正常系をリファクタリングして設計を改善してきました。しかし、アプリケーションは例外系も考慮する必要があります。続いて、アサーションの導入 を適用した例外系のリファクタリングに取り組むとしましょう。
アサーションの導入
前提を明示するためのすぐれたテクニックとして、アサーションを記述する方法があります。
— リファクタリング(第2版)
アサーションの導入
まず、 メソッドオブジェクト の FizzBuzzValueCommand
にマイナスの値が渡された場合の振る舞いをどうするか考えます。ここでは正の値のみ許可する振る舞いにしたいので以下のテストコードを追加します。
class FizzBuzzTest < Minitest::Test
...
describe '例外ケース' do
def test_値は正の値のみ許可する
assert_raises Assertions::AssertionFailedError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
end
end
end
...
ERROR["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x00007fadf30c45d8 @name="例外ケース">, 0.006546000000525964]
test_値は正の値のみ許可する#例外ケース (0.01s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::Assertions
/Users/k2works/Projects/sandbox/tdd_rb/test/fizz_buzz_test.rb:249:in `test_値は正の値のみ許可する'
36/36: [=========================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.03159s
36 tests, 39 assertions, 0 failures, 1 errors, 0 skips
...
テストを通すためアサーションモジュールを追加します。Rubyでは モジュール を使います。
モジュールはクラスと非常によく似ていますが、以下の二点が異なります。
モジュールはインスタンス化できない
本章後半可能なのは include や extend が可能なのはモジュールだけ
それ以外のクラスメソッドや定数の定義などはクラスと同じように定義することができます。
— かんたんRuby
...
module Assertions
class AssertionFailedError < StandardError; end
def assert(&condition)
raise AssertionFailedError, 'Assertion Failed' unless condition.call
end
end
class FizzBuzzValue
...
アサーションモジュールを追加してエラーはなくなりましたがテストは失敗したままです。
...
FAIL["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x00007fdcfc0c2548 @name="例外ケース">, 0.005800000000817818]
test_値は正の値のみ許可する#例外ケース (0.01s)
Assertions::AssertionFailedError expected but nothing was raised.
/Users/k2works/Projects/sandbox/tdd_rb/test/fizz_buzz_test.rb:249:in `test_値は正の値のみ許可する'
============================================================================================================|
Finished in 0.00621s
36 tests, 40 assertions, 1 failures, 0 errors, 0 skips
...
追加したモジュールを FizzBuzzValue
クラスをに Mix-in します。そして、コンストラクタ 実行時に数値は0以上であるアサーションを追加します。
Rubyでの継承は一種類、単一継承しか実行できませんが、複数のクラスを継承する多重継承の代わりにMix-inというメソッドの共有方法を提供します。
— かんたんRuby
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
@number = number
@value = value
end
...
end
class FizzBuzzValue
include Assertions
attr_reader :number, :value
def initialize(number, value)
assert { number >= 0 }
@number = number
@value = value
end
...
end
...
Started with run options --seed 37354
Progress: |====================================================================================================|
Finished in 0.01433s
36 tests, 40 assertions, 0 failures, 0 errors, 0 skips
...
アサーションが機能するようになりました、コミットしておきます。
$ git add .
$ git commit -m 'refactor: アサーションの導入'
次は、メソッドオブジェクト の FizzBuzzListCommand
の実行時に100件以上指定された場合の振る舞いをどうするか考えます。ここでは100までを許可する振る舞いにします。
...
describe '例外ケース' do
def test_値は正の値のみ許可する
assert_raises Assertions::AssertionFailedError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
end
def test_100より多い数を許可しない
assert_raises Assertions::AssertionFailedError do
FizzBuzzListCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(101)
end
end
end
end
FizzBuzzList
にアサーションモジュールを Mix-in します。コンストラクタ 実行時に配列のサイズは100までというアサーションを追加します。
...
class FizzBuzzList
include Assertions
attr_reader :value
def initialize(list)
assert { list.count <= 100 }
@value = list
end
...
...
ERROR["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x00005558ca6e8e80 @name="FizzBuzzValueList">, 0.010412617004476488]
test_新しいインスタンスが作られる#FizzBuzzValueList (0.01s)
Minitest::UnexpectedError: Assertions::AssertionFailedError: Assertion Failed
/workspace/tdd_rb/lib/fizz_buzz.rb:58:in `assert'
/workspace/tdd_rb/lib/fizz_buzz.rb:88:in `initialize'
/workspace/tdd_rb/lib/fizz_buzz.rb:97:in `new'
/workspace/tdd_rb/lib/fizz_buzz.rb:97:in `add'
/workspace/tdd_rb/test/fizz_buzz_test.rb:259:in `test_新しいインスタンスが作られる'
====================================================================================================|
Finished in 0.01238s
36 tests, 38 assertions, 0 failures, 1 errors, 0 skips
...
追加したテストはパスするようになりましたが既存のテストコードでエラーが出るようになりました。該当するテストコードを見たところ100件より多い 学習用テスト で ファーストクラスコレクション を作ろうとしたため AssertionFailedError
を発生させたようです。テストコードを修正しておきましょう。
...
describe 'FizzBuzzValueList' do
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(100)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 100, list1.value.count
assert_equal 200, list2.value.count
end
end
...
最初は50件作るように変更します。
...
describe 'FizzBuzzValueList' do
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(50)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 100, list1.value.count
assert_equal 200, list2.value.count
end
end
...
アサーションエラーはなくなりましたが期待した値と違うと指摘されています。テストコードのアサーションを修正します。
FAIL["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000556b5137c780 @name="FizzBuzzValueList">, 0.003735148988198489]
test_新しいインスタンスが作られる#FizzBuzzValueList (0.00s)
Expected: 100
Actual: 50
/workspace/tdd_rb/test/fizz_buzz_test.rb:261:in `test_新しいインスタンスが作られる'
36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00837s
36 tests, 39 assertions, 1 failures, 0 errors, 0 skips
...
describe 'FizzBuzzValueList' do
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(50)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 50, list1.value.count
assert_equal 200, list2.value.count
end
end
...
2つ目のアサーションに引っかかってしまいました。こちらも修正します。
FAIL["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000563a0c4fc2b0 @name="FizzBuzzValueList">, 0.005684088013367727]
test_新しいインスタンスが作られる#FizzBuzzValueList (0.01s)
Expected: 200
Actual: 100
/workspace/tdd_rb/test/fizz_buzz_test.rb:262:in `test_新しいインスタンスが作られる'
36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00809s
36 tests, 40 assertions, 1 failures, 0 errors, 0 skips
...
describe 'FizzBuzzValueList' do
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(50)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 50, list1.value.count
assert_equal 100, list2.value.count
end
end
...
...
01:58:57 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 61 / 64 LOC (95.31%) covered.
Started with run options --guard --seed 44956
36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00717s
36 tests, 40 assertions, 0 failures, 0 errors, 0 skips
...
仕様変更による反映が出来たのでコミットしましょう。
$ git add .
$ git commit -m 'refactor: アサーションの導入'
アサーションの導入 とは別のアプローチとして 例外 を返す方法もあります。 例外によるエラーコードの置き換え を適用してアサーションモジュールを削除しましょう。
例外によるエラーコードの置き換え
エラーを示す特別なコードをメソッドがリターンしている。
代わりに例外を発生させる。
— 新装版 リファクタリング
例外によるエラーコードの置き換え
アサーションモジュールを削除してアサーション部分を 例外 に変更します。
...
module Assertions
class AssertionFailedError < StandardError; end
def assert(&condition)
raise AssertionFailedError, 'Assertion Failed' unless condition.call
end
end
class FizzBuzzValue
include Assertions
attr_reader :number, :value
def initialize(number, value)
assert { number >= 0 }
@number = number
@value = value
end
...
end
class FizzBuzzList
include Assertions
attr_reader :value
def initialize(list)
assert { list.count <= 100 }
@value = list
end
...
end
...
...
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
raise '正の値のみ有効です' if number < 0
@number = number
@value = value
end
...
end
class FizzBuzzList
attr_reader :value
def initialize(list)
raise '上限は100件までです' if list.count > 100
@value = list
end
...
end
...
ERROR["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x000055d30f0b8a50 @name="FizzBuzz::数を文字列にして返す::例外ケース">, 0.004186890990240499]
test_値は正の値のみ許可する#FizzBuzz::数を文字列にして返す::例外ケース (0.00s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::Assertions
/workspace/tdd_rb/test/fizz_buzz_test.rb:143:in `test_値は正の値のみ許可する'
ERROR["test_100より多い数を許可しない", #<Minitest::Reporters::Suite:0x000055d30f114210 @name="FizzBuzz::数を文字列にして返す::例外ケース">, 0.008254560001660138]
test_100より多い数を許可しない#FizzBuzz::数を文字列にして返す::例外ケース (0.01s)
Minitest::UnexpectedError: NameError: uninitialized constant FizzBuzzTest::Assertions
/workspace/tdd_rb/test/fizz_buzz_test.rb:151:in `test_100より多い数を許可しない'
37/37: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.01731s
37 tests, 39 assertions, 0 failures, 2 errors, 0 skips
...
アサーションモジュールを削除したのでエラーが発生しています。テストコードを修正しましょう。
...
describe '例外ケース' do
def test_値は正の値のみ許可する
assert_raises Assertions::AssertionFailedError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
end
def test_100より多い数を許可しない
assert_raises Assertions::AssertionFailedError do
FizzBuzzListCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(101)
end
end
end
end
...
describe '例外ケース' do
def test_値は正の値のみ許可する
e = assert_raises RuntimeError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
assert_equal '正の値のみ有効です', e.message
end
def test_100より多い数を許可しない
e = assert_raises RuntimeError do
FizzBuzzListCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(101)
end
assert_equal '上限は100件までです', e.message
end
end
end
...
02:13:46 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 55 / 58 LOC (94.83%) covered.
Started with run options --guard --seed 55179
37/37: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00738s
37 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...
再びテストが通るようになったのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: 例外によるエラーコードの置き換え'
アルゴリズムの置き換え
02:13:46 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb lib/fizz_buzz.rb
lib/fizz_buzz.rb:58:26: C: Style/NumericPredicate: Use number.negative? instead of number < 0.
raise '正の値のみ有効です' if number < 0
^^^^^^^^^^
2/2 files |====================================== 100 =======================================>| Time: 00:00:00
2 files inspected, 1 offense detected
テストは通りますが警告が表示されるようになりました。 Style/NumericPredicate: Use number.negative? instead of number < 0.
とのことなので アルゴリズムの置き換え を適用しておきましょう。
アルゴリズムの取り替え
アルゴリズムをよりわかりやすいものに置き換えたい
メソッドの本体を新たなアルゴリズムで置き換える。
— 新装版 リファクタリング
...
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
raise '正の値のみ有効です' if number < 0
...
...
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
raise '正の値のみ有効です' if number.negative?
...
02:18:31 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |======================================= 100 =======================================>| Time: 00:00:00
1 file inspected, no offenses detected
警告が消えたのでコミットします。
$ git add .
$ git commit -m 'refactor: アルゴリズムの置き換え'
マジックナンバーの置き換え
件数に リテラル を使っています。ここは マジックナンバーの置き換え を適用するべきですね。
シンボリック定数によるマジックナンバーの置き換え
特別な意味を持った数字のリテラルがある。
定数を作り、それにふさわしい名前をつけて、そのリテラルを置き換える。
— 新装版 リファクタリング
...
class FizzBuzzList
attr_reader :value
def initialize(list)
raise '上限は100件までです' if list.count > 100
@value = list
end
...
式展開 を使ってメッセージ内容も定数から参照するようにしましょう。
式展開
式展開とは、「#{}」の書式で文字列中に何らかの変数や式を埋め込むことが可能な機能です。これは、ダブルクオートを使用した場合のみの機能です。
— かんたんRuby
class FizzBuzzList
MAX_COUNT = 100
attr_reader :value
def initialize(list)
raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT
@value = list
end
...
テストは壊れていないようですが MAX_COUNT
を変更したらテストが失敗するか確認しておきましょう。
class FizzBuzzList
MAX_COUNT = 10
...
...
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x000055942ab5e230 @name="FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.008073228993453085]
test_配列の14番目は文字列のFizzBuzzを返す#FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError: RuntimeError: 上限は10件までです
/workspace/tdd_rb/lib/fizz_buzz.rb:80:in `initialize'
/workspace/tdd_rb/lib/fizz_buzz.rb:112:in `new'
/workspace/tdd_rb/lib/fizz_buzz.rb:112:in `execute'
/workspace/tdd_rb/test/fizz_buzz_test.rb:45:in `setup'
...
想定通りのエラーが発生したのでコードを元に戻してコミットしましょう。
class FizzBuzzList
MAX_COUNT = 100
...
...
Started with run options --seed 5525
Progress: |====================================================================================================|
Finished in 0.01262s
37 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...
$ git add .
$ git commit -m 'refactor: マジックナンバーの置き換え'
特殊ケースの導入
最後に ポリモーフィズム の応用としてタイプクラスが未定義の場合に 例外 ではなく未定義のタイプクラスを返す 特殊ケースの導入 を適用してみましょう。
ヌルオブジェクトの導入
null値のチェックが繰り返し現れる。
そのnull値をヌルオブジェクトで置き換える。
— 新装版 リファクタリング
特殊ケースの導入
旧:ヌルオブジェクトの導入
特殊ケースの処理を要する典型的な値がnullなので、このパターンをヌルオブジェクトパターンと呼ぶことがあります、しかし、通常の特殊ケースとアプローチは同じです。いわばヌルオブジェクトは「特殊ケース」の特殊ケースです。
— リファクタリング(第2版)
まず、それ以外のタイプの場合の振る舞いを変更します。
...
describe 'それ以外のタイプの場合' do
def test_例外を返す
e = assert_raises RuntimeError do
FizzBuzzType.create(4)
end
assert_equal '該当するタイプは存在しません', e.message
end
end
end
...
...
describe 'それ以外のタイプの場合' do
def test_未定義のタイプを返す
fizzbuzz = FizzBuzzType.create(4)
assert_equal '未定義', fizzbuzz.to_s
end
end
end
...
...
ERROR["test_未定義のタイプを返す", #<Minitest::Reporters::Suite:0x00005593e21297d0 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.0065623498521745205]
test_未定義のタイプを返す#数を文字列にして返す::それ以外のタイプの場合 (0.01s)
Minitest::UnexpectedError: RuntimeError: 該当するタイプは存在しません
/workspace/tdd_rb/lib/fizz_buzz.rb:17:in `create'
/workspace/tdd_rb/test/fizz_buzz_test.rb:131:in `test_未定義のタイプを返す'
37/37: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00780s
37 tests, 41 assertions, 0 failures, 1 errors, 0 skips
...
現時点では 例外 を投げるので未定義タイプ FizzBuzzTypeNotDefined
を作成して ファクトリメソッド を変更します。
class FizzBuzzType
TYPE_01 = 1
TYPE_02 = 2
TYPE_03 = 3
def self.create(type)
case type
when FizzBuzzType::TYPE_01
FizzBuzzType01.new
when FizzBuzzType::TYPE_02
FizzBuzzType02.new
when FizzBuzzType::TYPE_03
FizzBuzzType03.new
else
raise '該当するタイプは存在しません'
end
end
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
class FizzBuzzType01 < FizzBuzzType
...
class FizzBuzzType
TYPE_01 = 1
TYPE_02 = 2
TYPE_03 = 3
def self.create(type)
case type
when FizzBuzzType::TYPE_01
FizzBuzzType01.new
when FizzBuzzType::TYPE_02
FizzBuzzType02.new
when FizzBuzzType::TYPE_03
FizzBuzzType03.new
else
FizzBuzzTypeNotDefined.new
end
end
...
class FizzBuzzTypeNotDefined < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, '')
end
def to_s
'未定義'
end
end
class FizzBuzzValue
...
...
Started with run options --seed 33939
Progress: |=====================================================================================================|
Finished in 0.01193s
37 tests, 42 assertions, 0 failures, 0 errors, 0 skips
06:46:48 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
1/1 file |======================================= 100 ========================================>| Time: 00:00:00
1 file inspected, no offenses detected
06:46:49 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
0/0 files |======================================= 100 =======================================>| Time: 00:00:00
0 files inspected, no offenses detected
...
テストが通るようになりました。 メソッドオブジェクト から実行された場合の振る舞いも明記しておきましょう。
...
describe 'それ以外のタイプの場合' do
def test_未定義のタイプを返す
fizzbuzz = FizzBuzzType.create(4)
assert_equal '未定義', fizzbuzz.to_s
end
def test_空の文字列を返す
type = FizzBuzzType.create(4)
command = FizzBuzzValueCommand.new(type)
assert_equal '', command.execute(3)
end
end
end
...
...
06:48:54 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 62 / 65 LOC (95.38%) covered.
Started with run options --guard --seed 18202
38/38: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00747s
38 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...
FizzBuzzTypeNotDefined
オブジェクトは Null Objectパターン を適用したものです。
Null Objectパターン
特殊な状況をオブジェクトで表現するにはどうすればよいだろうか---その特殊な状況を表現するオブジェクトを作り、通常のオブジェクトと同じプロトコル(メソッド群)を実装しよう。
— テスト駆動開発
オープン・クローズドの原則 に従って未定義のタイプである Null Object を安全に追加することができたのでコミットしておきます。
$ git add .
$ git commit -m 'refactor: 特殊ケースの導入'
モジュール分割
クラスモジュールの抽出によってアプリケーションの構造が 抽象化 された結果、視覚的に把握できるようになりました。ここでアプリケーションを実行してみましょう。
$ ruby main.rb
Traceback (most recent call last):
main.rb:5:in `<main>': uninitialized constant FizzBuzz (NameError)
Did you mean? FizzBuzzType
エラーが出ています、これはアプリケーションの構成が変わったためです。クライアントプログラムをアプリケーションの変更に合わせて修正します。
# frozen_string_literal: true
require './lib/fizz_buzz.rb'
puts FizzBuzz.generate_list
# frozen_string_literal: true
require './lib/fizz_buzz.rb'
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
command.execute(100).each { |i| puts i.value }
$ ruby main.rb
1
2
Fizz
4
Buzz
...
Fizz
クライアントプログラムが直ったのでコミットしておきます。
$ git add .
$ git commit -m 'fix: プリントする'
ドメインモデル
fizz_buzz.rb
ファイル内のクラスモジュールをファイルとして分割していきます。まずは ドメインオブジェクト を抽出して ドメインモデル として整理しましょう。既存のテストを壊さないように1つづつコピー&ペーストしていきます。
関連する業務データと業務ロジックを1つにまとめたこのようなオブジェクトをドメインオブジェクトと呼びます。
「ドメイン」とは、対象領域とか問題領域という意味です。業務アプリケーションの場合、そのアプリケーションが対象となる業務活動全体がドメインです。業務活動という問題領域(ドメイン)で扱うデータと業務ロジックを、オブジェクトとして表現したものドメインオブジェクトです。ドメインオブジェクトは、業務データと業務ロジックを密接に関係づけます。
— 現場で役立つシステム設計の原則
このように業務アプリケーションの対象領域(ドメイン)をオブジェクトのモデルとして整理したものをドメインモデルと呼びます。
— 現場で役立つシステム設計の原則
/main.rb
|--lib/
|
-- fizz_buzz.rb
|--test/
|
-- fizz_buzz_test.rb
/main.rb
|--lib/
|
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz_type_not_defined.rb
-- fizz_buzz.rb
|--test/
|
-- fizz_buzz_test.rb
値オブジェクトクラス と タイプクラス を domain
フォルダ以下に配置します。
# frozen_string_literal: true
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
raise '正の値のみ有効です' if number.negative?
@number = number
@value = value
end
def to_s
"#{@number}:#{@value}"
end
def ==(other)
@number == other.number && @value == other.value
end
alias eql? ==
end
# frozen_string_literal: true
class FizzBuzzList
MAX_COUNT = 100
attr_reader :value
def initialize(list)
raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT
@value = list
end
def to_s
@value.to_s
end
def add(value)
FizzBuzzList.new(@value + value)
end
end
# frozen_string_literal: true
class FizzBuzzType
TYPE_01 = 1
TYPE_02 = 2
TYPE_03 = 3
def self.create(type)
case type
when FizzBuzzType::TYPE_01
FizzBuzzType01.new
when FizzBuzzType::TYPE_02
FizzBuzzType02.new
when FizzBuzzType::TYPE_03
FizzBuzzType03.new
else
FizzBuzzTypeNotDefined.new
end
end
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
# frozen_string_literal: true
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
# frozen_string_literal: true
class FizzBuzzType02 < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, number.to_s)
end
end
# frozen_string_literal: true
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
# frozen_string_literal: true
class FizzBuzzTypeNotDefined < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, '')
end
def to_s
'未定義'
end
end
...
07:29:03 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png lib/domain/type/fizz_buzz_type_not_defined.rb lib/domain/type/fizz_buzz_type_03.rb lib/domain/type/fizz_buzz_type_02.rb lib/domain/type/fizz_buzz_type_01.rb lib/domain/type/fizz_buzz_type.rb lib/domain/model/fizz_buzz_list.rb lib/domain/model/fizz_buzz_value.rb
lib/domain/type/fizz_buzz_type_not_defined.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzTypeNotDefined < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_03.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType03 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_02.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType02 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_01.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType01 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType
^^^^^
lib/domain/model/fizz_buzz_list.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzList
^^^^^
lib/domain/model/fizz_buzz_value.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzValue
^^^^^
7/7 files |======================== 100 =========================>| Time: 00:00:00
7 files inspected, 7 offenses detected
...
テストは壊れていないようですが警告が出るようになりました。まだ仕掛ですが一旦コミットしておきます。
$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'
アプリケーション
続いて アプリケーション層 の分割を行います。
データクラスと機能クラスを分ける手続き型の設計では、アプリケーション層のクラスに業務ロジックの詳細を記述します。
— 現場で役立つシステム設計の原則
/main.rb
|--lib/
|
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
-- fizz_buzz_test.rb
/main.rb
|--lib/
|
application/
|
-- fizz_buzz_command.rb
-- fizz_buzz_value_command.rb
-- fizz_buzz_list_command.rb
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
-- fizz_buzz_test.rb
ここでは ドメインオブジェクト を操作する メソッドオブジェクト を application
フォルダ以下に配置します。
# frozen_string_literal: true
class FizzBuzzCommand
def execute; end
end
# frozen_string_literal: true
class FizzBuzzValueCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
@type.generate(number).value
end
end
# frozen_string_literal: true
class FizzBuzzListCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
end
end
テストは壊れていないのでコミットしておきます。
$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'
テスト
アプリケーションのメイン部分は分割できました。続いてテストも分割しましょう。
/main.rb
|--lib/
|
application/
|
-- fizz_buzz_command.rb
-- fizz_buzz_value_command.rb
-- fizz_buzz_list_command.rb
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
-- fizz_buzz_test.rb
/main.rb
|--lib/
|
application/
|
-- fizz_buzz_command.rb
-- fizz_buzz_value_command.rb
-- fizz_buzz_list_command.rb
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
application/
|
-- fizz_buzz_value_command_test.rb
-- fizz_buzz_list_command_test.rb
domain/
|
model/
|
-- fizz_buzz_value_test.rb
-- fizz_buzz_list_test.rb
|
-- learning_test.rb
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzValueCommandTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列Fizzを返す
assert_equal 'Fizz', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列Buzzを返す
assert_equal 'Buzz', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'それ以外のタイプの場合' do
def test_未定義のタイプを返す
fizzbuzz = FizzBuzzType.create(4)
assert_equal '未定義', fizzbuzz.to_s
end
def test_空の文字列を返す
type = FizzBuzzType.create(4)
command = FizzBuzzValueCommand.new(type)
assert_equal '', command.execute(3)
end
end
end
describe '例外ケース' do
def test_値は正の値のみ許可する
e = assert_raises RuntimeError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
assert_equal '正の値のみ有効です', e.message
end
end
end
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzListCommandTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
@result = fizzbuzz.execute(100)
end
def test_配列の初めは文字列の1を返す
assert_equal '1', @result.first.value
end
def test_配列の最後は文字列のBuzzを返す
assert_equal 'Buzz', @result.last.value
end
def test_配列の2番目は文字列のFizzを返す
assert_equal 'Fizz', @result[2].value
end
def test_配列の4番目は文字列のBuzzを返す
assert_equal 'Buzz', @result[4].value
end
def test_配列の14番目は文字列のFizzBuzzを返す
assert_equal 'FizzBuzz', @result[14].value
end
end
end
end
describe '例外ケース' do
def test_100より多い数を許可しない
e = assert_raises RuntimeError do
FizzBuzzListCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(101)
end
assert_equal '上限は100件までです', e.message
end
end
end
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzValueTest < Minitest::Test
def test_同じで値である
value1 = FizzBuzzValue.new(1, '1')
value2 = FizzBuzzValue.new(1, '1')
assert value1.eql?(value2)
end
def test_to_stringメソッド
value = FizzBuzzValue.new(3, 'Fizz')
assert_equal '3:Fizz', value.to_s
end
end
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzListTest < Minitest::Test
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(50)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 50, list1.value.count
assert_equal 100, list2.value.count
end
end
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
class LearningTest < Minitest::Test
describe '配列や繰り返し処理を理解する' do
def test_繰り返し処理
$stdout = StringIO.new
[1, 2, 3].each { |i| p i * i }
output = $stdout.string
assert_equal "1\n" + "4\n" + "9\n", output
end
def test_selectメソッドで特定の条件を満たす要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].select(&:integer?)
assert_equal [2, 4], result
end
def test_find_allメソッドで特定の条件を満たす要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].find_all(&:integer?)
assert_equal [2, 4], result
end
def test_特定の条件を満たさない要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].reject(&:integer?)
assert_equal [1.1, 3.3], result
end
def test_mapメソッドで新しい要素の配列を返す
result = %w[apple orange pineapple strawberry].map(&:size)
assert_equal [5, 6, 9, 10], result
end
def test_collectメソッドで新しい要素の配列を返す
result = %w[apple orange pineapple strawberry].collect(&:size)
assert_equal [5, 6, 9, 10], result
end
def test_findメソッドで配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry].find(&:size)
assert_equal 'apple', result
end
def test_detectメソッドで配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry].detect(&:size)
assert_equal 'apple', result
end
def test_指定した評価式で並び変えた配列を返す
result1 = %w[2 4 13 3 1 10].sort
result2 = %w[2 4 13 3 1 10].sort { |a, b| a.to_i <=> b.to_i }
result3 = %w[2 4 13 3 1 10].sort { |b, a| a.to_i <=> b.to_i }
assert_equal %w[1 10 13 2 3 4], result1
assert_equal %w[1 2 3 4 10 13], result2
assert_equal %w[13 10 4 3 2 1], result3
end
def test_配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry apricot].grep(/^a/)
assert_equal %w[apple apricot], result
end
def test_ブロック内の条件式が真である間までの要素を返す
result = [1, 2, 3, 4, 5, 6, 7, 8, 9].take_while { |item| item < 6 }
assert_equal [1, 2, 3, 4, 5], result
end
def test_ブロック内の条件式が真である以降の要素を返す
result = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].drop_while { |item| item < 6 }
assert_equal [6, 7, 8, 9, 10], result
end
def test_injectメソッドで畳み込み演算を行う
result = [1, 2, 3, 4, 5].inject(0) { |total, n| total + n }
assert_equal 15, result
end
def test_reduceメソッドで畳み込み演算を行う
result = [1, 2, 3, 4, 5].reduce { |total, n| total + n }
assert_equal 15, result
end
end
end
ファイル分割でテストは壊れていないようですが警告がたくさん出てきました。
...
test/learning_test.rb:70:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_ブロック内の条件式が真である間までの要素を返す
^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:75:9: C: Naming/MethodName: Use snake_case for method names.
def test_ブロック内の条件式が真である以降の要素を返す
^^^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:75:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_ブロック内の条件式が真である以降の要素を返す
^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:80:9: C: Naming/MethodName: Use snake_case for method names.
def test_injectメソッドで畳み込み演算を行う
^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:80:20: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_injectメソッドで畳み込み演算を行う
^^^^^^^^^^^^^^
test/learning_test.rb:85:9: C: Naming/MethodName: Use snake_case for method names.
def test_reduceメソッドで畳み込み演算を行う
^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:85:20: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
def test_reduceメソッドで畳み込み演算を行う
^^^^^^^^^^^^^^
15/15 files |======================= 100 ========================>| Time: 00:00:00
15 files inspected, 87 offenses detected
...
これらはテストコードに関する警告がほとんどなので .rubocop.yml
を編集してチェック対象から外しておきましょう。
inherit_from: .rubocop_todo.yml
Naming/AsciiIdentifiers:
Exclude:
- 'test/**/*'
Naming/MethodName:
EnforcedStyle: snake_case
Exclude:
- 'test/**/*'
Metrics/BlockLength:
Max: 62
Exclude:
- 'test/**/*'
Documentation:
Enabled: false
...
08:21:55 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 144 / 215 LOC (66.98%) covered.
Started with run options --guard --seed 55977
70/70: [=====================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.01518s
70 tests, 79 assertions, 0 failures, 0 errors, 0 skips
08:21:56 - INFO - Inspecting Ruby code style of all files
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
22/22 files |======================= 100 ========================>| Time: 00:00:00
22 files inspected, no offenses detected
08:21:58 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
0/0 files |======================== 100 =========================>| Time: 00:00:00
0 files inspected, no offenses detected
...
警告は消えました、仕上げに fizz_buzz_test.rb
ファイルを削除します。
...
08:24:12 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 135 / 201 LOC (67.16%) covered.
Started with run options --guard --seed 40104
32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00601s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips
08:24:13 - INFO - Inspecting Ruby code style of all files
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
21/21 files |======================= 100 ========================>| Time: 00:00:00
21 files inspected, no offenses detected
08:24:14 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
0/0 files |======================== 100 =========================>| Time: 00:00:00
0 files inspected, no offenses detected
...
テストの分割も完了したのでコミットしておきます。
$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'
エントリーポイント
仕上げはクラスモジュールのエントリーポイント作成とテストヘルパーの追加です。
/main.rb
|--lib/
|
application/
|
-- fizz_buzz_command.rb
-- fizz_buzz_value_command.rb
-- fizz_buzz_list_command.rb
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
application/
|
-- fizz_buzz_value_command_test.rb
-- fizz_buzz_list_command._test.rb
domain/
|
model/
|
-- fizz_buzz_value_test.rb
-- fizz_buzz_list_test.rb
|
-- learning_test.rb
/main.rb
|--lib/
|
application/
|
-- fizz_buzz_command.rb
-- fizz_buzz_value_command.rb
-- fizz_buzz_list_command.rb
domain/
|
model/
|
-- fizz_buzz_value.rb
-- fizz_buzz_list.rb
type/
|
-- fizz_buzz_type.rb
-- fizz_buzz_type_01.rb
-- fizz_buzz_type_02.rb
-- fizz_buzz_type_03.rb
-- fizz_buzz.rb
|--test/
|
application/
|
-- fizz_buzz_value_command_test.rb
-- fizz_buzz_list_command._test.rb
domain/
|
model/
|
-- fizz_buzz_value_test.rb
-- fizz_buzz_list_test.rb
|
-- learning_test.rb
-- test_helper.rb
fizz_buzz.rb
ファイルの内容をクラスモジュール読み込みに変更します。
require './lib/application/fizz_buzz_command.rb'
require './lib/application/fizz_buzz_value_command.rb'
require './lib/application/fizz_buzz_list_command.rb'
require './lib/domain/model/fizz_buzz_value.rb'
require './lib/domain/model/fizz_buzz_list.rb'
require './lib/domain/type/fizz_buzz_type.rb'
require './lib/domain/type/fizz_buzz_type_01.rb'
require './lib/domain/type/fizz_buzz_type_02.rb'
require './lib/domain/type/fizz_buzz_type_03.rb'
require './lib/domain/type/fizz_buzz_type_not_defined.rb'
...
08:34:32 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 119 / 211 LOC (56.4%) covered.
Started with run options --guard --seed 18696
32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00561s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips
....
コードカバレッジがうまく機能していないようなので、test_helper.rb
を追加して共通部分を各テストファイルから読み込むように変更します。
# frozen_string_literal: true
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'
...
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
...
テストタスクを実行したところ動作しなくなりました。
$ rake test
テスト対象をテストディレクトリ内のすべてのテストコードに変更します。
...
Rake::TestTask.new do |test|
test.test_files = Dir['./test/fizz_buzz_test.rb']
test.verbose = true
end
...
...
Rake::TestTask.new do |test|
test.test_files = Dir['./test/**/*_test.rb']
test.verbose = true
end
...
$ rake test
Started with run options --seed 46929
32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.00800s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips
テストも壊れていないし警告も出ていません。モジュール分割完了です。
$ git add .
$ git commit -m 'refactor: モジュール分割'
ふりかえり
今回、 オブジェクト指向プログラム から オブジェクト指向設計 そして モジュール分割 を テスト駆動開発を通じて実践しました。各トピックを振り返ってみましょう。
オブジェクト指向プログラム
エピソード1で作成したプログラムの追加仕様を テスト駆動開発 で実装しました。 次に 手続き型コード との比較から オブジェクト指向プログラム を構成する カプセル化 ポリモフィズム 継承 という概念をコードベースの リファクタリング を通じて解説しました。
具体的には フィールドのカプセル から setterの削除 を適用することにより カプセル化 を実現しました。続いて、 ポリモーフィズムによる条件記述の置き換え から State/Strategyによるタイプコードの置き換え を適用することにより ポリモーフィズム の効果を体験しました。そして、 スーパークラスの抽出 から メソッド名の変更 メソッドの移動 の適用を通して 継承 の使い方を体験しました。さらに 値オブジェクト と ファーストクラス というオブジェクト指向プログラミングに必要なツールの使い方も学習しました。
オブジェクト指向設計
次に設計の観点から 単一責任の原則 に違反している FizzBuzz
クラスを デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え を適用してクラスの責務を分割しました。オブジェクト指向設計のイデオムである デザインパターン として Commandパターン 以外に Value Objectパターン Factory Methodパターン Strategyパターン を リファクタリング を適用する過程ですでに実現していたことを説明しました。そして、オープン・クローズドの原則 を満たすコードに リファクタリング されたことで既存のコードを変更することなく振る舞いを変更できるようになりました。
加えて、正常系の設計を改善した後 アサーションの導入 例外によるエラーコードの置き換え といった例外系の リファクタリング を適用しました。最後に ポリモーフィズム の応用として 特殊ケースの導入 の適用による Null Objectパターン を使った オープン・クローズドの原則 に従った安全なコードの追加方法を解説しました。
モジュールの分割
仕上げに、モノリシック なファイルから個別のクラスモジュールへの分割を ドメインオブジェクト の抽出を通して ドメインモデル へと整理することにより モジュール分割 を実現しました。最終的にプログラムからアプリケーションへと体裁を整えることが出来ました。以下が最終的なモジュール構造とコードです。
- Application
/main.rb.
# frozen_string_literal: true
require './lib/fizz_buzz.rb'
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
command.execute(100).each { |i| puts i.value }
/lib/application/fizz_buzz_command.rb.
# frozen_string_literal: true
class FizzBuzzCommand
def execute; end
end
/lib/application/fizz_buzz_value_command.rb.
# frozen_string_literal: true
class FizzBuzzValueCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
@type.generate(number).value
end
end
/lib/application/fizz_buzz_list_command.rb.
# frozen_string_literal: true
class FizzBuzzListCommand < FizzBuzzCommand
def initialize(type)
@type = type
end
def execute(number)
FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
end
end
- Domain
/lib/domain/model/fizz_buzz_value.rb.
# frozen_string_literal: true
class FizzBuzzValue
attr_reader :number, :value
def initialize(number, value)
raise '正の値のみ有効です' if number.negative?
@number = number
@value = value
end
def to_s
"#{@number}:#{@value}"
end
def ==(other)
@number == other.number && @value == other.value
end
alias eql? ==
end
/lib/domain/model/fizz_buzz_list.rb.
# frozen_string_literal: true
class FizzBuzzList
MAX_COUNT = 100
attr_reader :value
def initialize(list)
raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT
@value = list
end
def to_s
@value.to_s
end
def add(value)
FizzBuzzList.new(@value + value)
end
end
/lib/domain/type/fizz_buzz_type.rb.
# frozen_string_literal: true
class FizzBuzzType
TYPE_01 = 1
TYPE_02 = 2
TYPE_03 = 3
def self.create(type)
case type
when FizzBuzzType::TYPE_01
FizzBuzzType01.new
when FizzBuzzType::TYPE_02
FizzBuzzType02.new
when FizzBuzzType::TYPE_03
FizzBuzzType03.new
else
FizzBuzzTypeNotDefined.new
end
end
def fizz?(number)
number.modulo(3).zero?
end
def buzz?(number)
number.modulo(5).zero?
end
end
/lib/domain/type/fizz_buzz_type_01.rb.
# frozen_string_literal: true
class FizzBuzzType01 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
/lib/domain/type/fizz_buzz_type_02.rb.
# frozen_string_literal: true
class FizzBuzzType02 < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, number.to_s)
end
end
/lib/domain/type/fizz_buzz_type_03.rb.
# frozen_string_literal: true
class FizzBuzzType03 < FizzBuzzType
def generate(number)
return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
FizzBuzzValue.new(number, number.to_s)
end
end
/lib/domain/type/fizz_buzz_type_not_defined.b.
# frozen_string_literal: true
class FizzBuzzTypeNotDefined < FizzBuzzType
def generate(number)
FizzBuzzValue.new(number, '')
end
def to_s
'未定義'
end
end
- Test
/test/application/fizz_buzz_value_command_test.rb.
# frozen_string_literal: true
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzValueCommandTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列Fizzを返す
assert_equal 'Fizz', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列Buzzを返す
assert_equal 'Buzz', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'タイプ2の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列15を返す
assert_equal '15', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'タイプ3の場合' do
def setup
@fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
end
describe '三の倍数の場合' do
def test_3を渡したら文字列3を返す
assert_equal '3', @fizzbuzz.execute(3)
end
end
describe '五の倍数の場合' do
def test_5を渡したら文字列5を返す
assert_equal '5', @fizzbuzz.execute(5)
end
end
describe '三と五の倍数の場合' do
def test_15を渡したら文字列FizzBuzzを返す
assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
end
end
describe 'その他の場合' do
def test_1を渡したら文字列1を返す
assert_equal '1', @fizzbuzz.execute(1)
end
end
end
describe 'それ以外のタイプの場合' do
def test_未定義のタイプを返す
fizzbuzz = FizzBuzzType.create(4)
assert_equal '未定義', fizzbuzz.to_s
end
def test_空の文字列を返す
type = FizzBuzzType.create(4)
command = FizzBuzzValueCommand.new(type)
assert_equal '', command.execute(3)
end
end
end
describe '例外ケース' do
def test_値は正の値のみ許可する
e = assert_raises RuntimeError do
FizzBuzzValueCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(-1)
end
assert_equal '正の値のみ有効です', e.message
end
end
end
/test/application/fizz_buzz_list_command_test.rb.
# frozen_string_literal: true
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzListCommandTest < Minitest::Test
describe '数を文字列にして返す' do
describe 'タイプ1の場合' do
describe '1から100までのFizzBuzzの配列を返す' do
def setup
fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
@result = fizzbuzz.execute(100)
end
def test_配列の初めは文字列の1を返す
assert_equal '1', @result.first.value
end
def test_配列の最後は文字列のBuzzを返す
assert_equal 'Buzz', @result.last.value
end
def test_配列の2番目は文字列のFizzを返す
assert_equal 'Fizz', @result[2].value
end
def test_配列の4番目は文字列のBuzzを返す
assert_equal 'Buzz', @result[4].value
end
def test_配列の14番目は文字列のFizzBuzzを返す
assert_equal 'FizzBuzz', @result[14].value
end
end
end
end
describe '例外ケース' do
def test_100より多い数を許可しない
e = assert_raises RuntimeError do
FizzBuzzListCommand.new(
FizzBuzzType.create(FizzBuzzType::TYPE_01)
).execute(101)
end
assert_equal '上限は100件までです', e.message
end
end
end
/test/domain/model/fizz_buzz_value_test.rb.
# frozen_string_literal: true
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzValueTest < Minitest::Test
def test_同じで値である
value1 = FizzBuzzValue.new(1, '1')
value2 = FizzBuzzValue.new(1, '1')
assert value1.eql?(value2)
end
def test_to_stringメソッド
value = FizzBuzzValue.new(3, 'Fizz')
assert_equal '3:Fizz', value.to_s
end
end
/test/domain/model/fizz_buzz_list_test.rb.
# frozen_string_literal: true
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
class FizzBuzzListTest < Minitest::Test
def test_新しいインスタンスが作られる
command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
array = command.execute(50)
list1 = FizzBuzzList.new(array)
list2 = list1.add(array)
assert_equal 50, list1.value.count
assert_equal 100, list2.value.count
end
end
/test/learning_test.rb.
# frozen_string_literal: true
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'
class LearningTest < Minitest::Test
describe '配列や繰り返し処理を理解する' do
def test_繰り返し処理
$stdout = StringIO.new
[1, 2, 3].each { |i| p i * i }
output = $stdout.string
assert_equal "1\n" + "4\n" + "9\n", output
end
def test_selectメソッドで特定の条件を満たす要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].select(&:integer?)
assert_equal [2, 4], result
end
def test_find_allメソッドで特定の条件を満たす要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].find_all(&:integer?)
assert_equal [2, 4], result
end
def test_特定の条件を満たさない要素だけを配列に入れて返す
result = [1.1, 2, 3.3, 4].reject(&:integer?)
assert_equal [1.1, 3.3], result
end
def test_mapメソッドで新しい要素の配列を返す
result = %w[apple orange pineapple strawberry].map(&:size)
assert_equal [5, 6, 9, 10], result
end
def test_collectメソッドで新しい要素の配列を返す
result = %w[apple orange pineapple strawberry].collect(&:size)
assert_equal [5, 6, 9, 10], result
end
def test_findメソッドで配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry].find(&:size)
assert_equal 'apple', result
end
def test_detectメソッドで配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry].detect(&:size)
assert_equal 'apple', result
end
def test_指定した評価式で並び変えた配列を返す
result1 = %w[2 4 13 3 1 10].sort
result2 = %w[2 4 13 3 1 10].sort { |a, b| a.to_i <=> b.to_i }
result3 = %w[2 4 13 3 1 10].sort { |b, a| a.to_i <=> b.to_i }
assert_equal %w[1 10 13 2 3 4], result1
assert_equal %w[1 2 3 4 10 13], result2
assert_equal %w[13 10 4 3 2 1], result3
end
def test_配列の中から条件に一致する要素を取得する
result = %w[apple orange pineapple strawberry apricot].grep(/^a/)
assert_equal %w[apple apricot], result
end
def test_ブロック内の条件式が真である間までの要素を返す
result = [1, 2, 3, 4, 5, 6, 7, 8, 9].take_while { |item| item < 6 }
assert_equal [1, 2, 3, 4, 5], result
end
def test_ブロック内の条件式が真である以降の要素を返す
result = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].drop_while { |item| item < 6 }
assert_equal [6, 7, 8, 9, 10], result
end
def test_injectメソッドで畳み込み演算を行う
result = [1, 2, 3, 4, 5].inject(0) { |total, n| total + n }
assert_equal 15, result
end
def test_reduceメソッドで畳み込み演算を行う
result = [1, 2, 3, 4, 5].reduce { |total, n| total + n }
assert_equal 15, result
end
end
end
良い設計
エピソード1では 良いコード について考えました。
TDDは「より良いコードを書けば、よりうまくいく」という素朴で奇妙な仮設によって成り立っている
— テスト駆動開発
「動作するきれいなコード」。RonJeffriesのこの簡潔な言葉が、テスト駆動開発(TDD)のゴールだ。動作するきれいなコードはあらゆる意味で価値がある。
— テスト駆動開発
良いコードかどうかは、変更がどれだけ容易なのかで決まる。
— リファクタリング(第2版)
コードは理解しやすくなければいけない。
— リーダブルコード
本エピソードでは テスト駆動開発 による オブジェクト指向プログラミング の リファクタリング を経てコードベースを改善してきました。そして オブジェクト指向設計 により 良いコード のプログラムを 良い設計 のアプリケーションへと進化させることができました。
どこに何が書いてあるかをわかりやすくし、変更の影響を狭い範囲に閉じ込め、安定して動作する部品を柔軟に組み合わせながらソフトウェアを構築する技法がオブジェクト指向設計です。
— 現場で役立つシステム設計の原則
設計の良し悪しは、ソフトウェアを変更するときにはっきりします。
構造が入り組んだわかりづらいプログラムは内容の理解に時間がかかります。重複したコードをあちこちで修正する作業が増え、変更の副作用に悩まされます。
一方、うまく設計されたプログラムは変更が楽で安全です。変更すべき箇所がかんたんにわかり、変更するコード量が少なく、変更の影響を狭い範囲に限定できます。
プログラムの修正に3日かかるか、それとも半日で済むか。その違いを生むのが「設計」なのです。
— 現場で役立つシステム設計の原則
では、いつ設計をしていたのでしょうか? わかりますよね、このエピソードの始まりから終わりまで常に設計をしていたのです。
TDDは分析技法であり、設計技法であり、実際には開発のすべてのアクティビティを構造化する技法なのだ。
— テスト駆動開発
参考サイト
参考図書
-
テスト駆動開発 Kent Beck (著), 和田 卓人 (翻訳): オーム社; 新訳版 (2017/10/14)
-
新装版 リファクタリング―既存のコードを安全に改善する― (OBJECT TECHNOLOGY SERIES) Martin
Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 新装版
(2014/7/26) -
リファクタリング(第2版): 既存のコードを安全に改善する (OBJECT TECHNOLOGY SERIES) Martin
Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 第2版
(2019/12/1) -
リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)
Dustin Boswell (著), Trevor Foucher (著), 須藤 功平 (解説), 角 征典 (翻訳):
オライリージャパン; 初版八刷版 (2012/6/23) -
Clean Code アジャイルソフトウェア達人の技 (アスキードワンゴ) Robert C.Martin (著), 花井
志生 (著) ドワンゴ (2017/12/28) -
現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法 増田 亨 (著) 技術評論社
(2017/7/5) -
かんたん Ruby (プログラミングの教科書) すがわらまさのり (著) 技術評論社 (2018/6/21)