Open in Gitpod
初めに この記事は テスト駆動開発から始める 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 で開発環境の自動化をしているので以下のコマンドを実行するだけで開発を始めることができます。
guard
が起動するとコンソールが使えなくなるのでもう一つコンソールを開いておきましょう。もしくは .
を使うことで guard
内でコンソールのコマンドを呼び出すことができます。
1 2 3 4 5 6 [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 の場合 テストファースト アサートファースト で最初に失敗するテストから始めます。テストを追加しましょう。
ここでは既存の FizzBuzz.generate
メソッドにタイプを 引数 として追加することで対応できるように変更してみたいと思います。まず、 fizz_buzz_test.rb
ファイルに以下のテストコードを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 ... end describe 'タイプごとに出力を切り替えることができる' do describe 'タイプ1の場合' do def test_1 を渡したら文字列1を返す assert_equal '1' , FizzBuzz.generate(1 , 1 ) end end end describe '配列や繰り返し処理を理解する' do ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ... 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を返す" , test_1を渡したら文字列1を返す 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 ... 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
と入力したのですがコードフォーマットによって以下のように自動修正されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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
に変更しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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 ...
テストは無事通りました。ここでコミットしておきます。
1 2 $ git add . $ git commit -m 'test: タイプ1の場合'
追加仕様の取っ掛かりができました。既存のテストを流用したいので先程作成したテストを削除して以下のように新しいグループ内に既存テストコードを移動しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 ... 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 ...
テストコードが壊れていないことを確認したらコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: メソッドのインライン化'
TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
タイプ 2 の場合 TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
続いて、タイプ 2 の場合に取り掛かりましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 ... FAIL["test_1を渡したら文字列1を返す" , test_1を渡したら文字列1を返す 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 を渡した場合の処理を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ... 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 ...
1 2 3 4 5 6 7 8 9 ... Started with run options --seed 19625 Progress: |=============================================================================| Finished in 0.00894s 24 tests, 26 assertions, 0 failures, 0 errors, 0 skips ...
テストが通ったのでテストケースを追加します。ここはタイプ 1 の場合をコピーして編集すれば良いでしょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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
に以下コードを追加してチェック対象から外しておきます。
1 2 3 4 5 6 7 8 --- Metrics/BlockLength: Max: 62 Exclude: - "test/fizz_buzz_test.rb"
ちなみに guard(main)>
にカーソルを合わせてエンターキーを押すと自動化タスクが実行されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 [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)>
警告は消えたのでコミットしておきます。
1 2 $ git add . $ git commit -m 'test: タイプ2の場合'
TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
タイプ 3 の場合 TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
続いて、タイプ 3 の場合ですがやることは同じなので今回は一気にテストを書いてみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ... FAIL["test_1を渡したら文字列1を返す" , test_1を渡したら文字列1を返す 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を返す" , test_3を渡したら文字列3を返す 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 式 に処理を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... 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 ...
テストは通りましたが新しい警告が表示されるようになりました。とりあえずコミットしておきます。
1 2 $ git add . $ git commit -m 'test: タイプ3の場合'
処理の追加により一部重複が発生しました。ここは、 ステートメントのスライド を適用して重複をなくしておきましょう。
ステートメントのスライド
旧:重複した条件記述の断片の統合
— リファクタリング(第 2 版)
重複した条件記述の断片の統合
条件式のすべて分岐に同じコードの断片がある。
それを式の外側に移動する。
— 新装版 リファクタリング
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ... 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 ...
警告は消えていませんがプログラムは壊れていないことが確認できたのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: ステートメントのスライド'
TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
それ以外のタイプの場合 追加仕様には対応しましたがタイプ 1,2,3 以外の値が 引数 として渡された場合はどうしましょうか? 現状では nil
を返しますがこのような例外ケースも考慮する必要があります。
TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
それ以外のタイプの場合
例外処理 を追加します。まず、例外のテストですが以下の様に書きます。
例外とは記述したプログラムが想定していない値を受け取ったり、何らかの障害が発生した場合に処理を中断して、例外オブジェクトを生成して呼び出し元のメソッドに処理を戻す機構です。
— かんたん Ruby
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 ...
1 2 3 4 5 6 7 8 9 10 11 ... FAIL["test_例外を返す" , test_例外を返す 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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 ...
テストが通ったのでコミットしておきます。
1 2 $ git add . $ git commit -m 'test: それ以外のタイプの場合'
TODO リスト
タイプ 1 の場合
タイプ 2 の場合
タイプ 3 の場合
それ以外のタイプの場合
TODO リスト をすべて完了しました。追加仕様を満たすプログラムは出来ましたがまだ改善の余地がありそうですね。以降ではオブジェクト指向アプローチによるコードのリファクタリングを解説していきたいと思います。
オブジェクト指向 手続き型プログラム オブジェクト指向 の解説の前に以下のコードを御覧ください。いわゆる 手続き型 で書かれたコードですが、これも追加仕様を満たしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 ..MAX_NUMBER).map { |n| generate(n) } end end
UML を使って上記のコードの構造をクラス図として表現しました。
更にシーケンス図を使って上記のコードの振る舞いを表現しました。
手続き型コード のフローチャートと比べてどう思われましたか?具体的な記述が少なくデータや処理の概要だけを表現しているけど FizzBuzz のルールを知っている人であれば何をやろうとしているかのイメージはつかみやすいのではないでしょうか?だから何?と思われるかもしれませんが現時点では オブジェクト指向 において 抽象化 がキーワードだという程度の認識で十分です。
オブジェクト指向の理解を深める取り掛かりにはこちらの記事を参照してください。
オブジェクト指向の詳細は控えるとして、ここでは カプセル化 ポリモフィズム 継承 というオブジェクト指向プログラムで原則とされる概念をリファクタリングを通して体験してもらい、オブジェクト指向プログラムの感覚を掴んでもらうことを目的に解説を進めていきたいと思います。
カプセル化 フィールドのカプセル化
まず、データとロジックを1つのクラスにまとめていくためのリファクタリングを実施していくとします。FizzBuzz
クラスに FizzBuzz 配列を保持できるようして以下のように取得できるようにしたいと思います。
1 2 3 4 ... fizzbuzz.generate_list @result = fizzbuzz.list ...
まず、 インスタンス変数 追加します。次に self
キーワードを外して クラスメソッド から インスタンスメソッド に変更します。
クラスメソッドはいくつか定義方法がありますが、どの方法を使ってもクラスメソッドとして定義されれば「クラス名.メソッド名」という形で呼び出せます。
— かんたん Ruby
インスタンスメソッドはコンストラクタと同じようにクラス内で def キーワードを使ってメソッドを定義するだけで作成できます。
— かんたん Ruby
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 ..MAX_NUMBER).map { |n| generate(n) } end end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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 @list = (1 ..MAX_NUMBER).map { |n| generate(n) } end end
1 2 3 4 5 6 7 ... ERROR["test_15を渡したら文字列FizzBuzzを返す" , test_15を渡したら文字列FizzBuzzを返す 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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
フィールドのカプセル化
公開フィールドがある。
それを非公開にして、そのアクセサを用意する。
— 新装版 リファクタリング
自動実行の結果、以下のように書き換えられている部分を変更します。
1 2 3 4 class FizzBuz 、 MAX_NUMBER = 100 attr_reader :list ...
1 2 3 4 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list ...
テストが動作して既存のコードが壊れていないことが確認できたのでここでコミットします。
1 2 $ git add . $ git commit -m 'refactor: フィールドのカプセル化'
引き続き、FizzBuzz 配列は保持できるようになりましたがタイプごとに出力される配列のパターンは違います。FizzBuzz クラスにタイプを持たる必要があります。ここでは コンストラクタ を使って インスタンス化 する際に インスタンス変数 に 代入 するようにします。Ruby では initialize というメソッドを使って初期化処理を実行します。
クラスをインスタンス化した時に初期化処理を行うシチュエーションはよくあります。このような初期化処理を行うメソッドをコンストラクタと呼び、Ruby では initialize という特別なメソッドを用意することで実現できます。
— かんたん Ruby
1 2 3 4 5 6 7 8 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list def initialize (type) @type = type end ...
1 2 3 4 5 6 7 8 ... ERROR["test_3を渡したら文字列3を返す" , test_3を渡したら文字列3を返す 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
メソッドの 引数 にタイプを渡すようにテストを変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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 ...
テストは直りましたがまだ インスタンス変数 のタイプが使われていないので使うようにプロダクトコードを変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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
を削除します。
1 2 3 4 5 6 7 8 9 10 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list def initialize (type) @type = type end def generate (number) ...
1 2 3 4 5 6 7 ... ERROR["test_15を渡したら文字列FizzBuzzを返す" , test_15を渡したら文字列FizzBuzzを返す 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
を削除したところテストが壊れたのでテストコードを修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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
も アクセッサメソッド を使って フィールドのカプセル化 を適用しておきます。
1 2 3 4 5 6 7 8 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list def initialize (type) @type = type end ...
1 2 3 4 5 6 7 8 9 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list attr_accessor :type def initialize (type) @type = type end ...
1 2 3 4 5 6 7 8 ... 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 ...
コミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: フィールドのカプセル化'
setter の削除 FizzBuzz 配列を取得する アクセッサメソッド は現在このように定義されています。
1 2 3 4 5 class FizzBuzz MAX_NUMBER = 100 attr_accessor :list attr_accessor :type ...
以下のようにテストコードを変更したらどうなるでしょうか?
1 2 3 4 5 6 7 8 ... describe '1から100までのFizzBuzzの配列を返す' do def setup fizzbuzz = FizzBuzz.new(1 ) fizzbuzz.generate_list @result = fizzbuzz.list end ...
1 2 3 4 5 6 7 8 9 ... describe '1から100までのFizzBuzzの配列を返す' do def setup fizzbuzz = FizzBuzz.new(1 ) fizzbuzz.generate_list fizzbuzz.list = [] @result = fizzbuzz.list end ...
1 2 3 4 5 6 7 ... FAIL["test_配列の2番目は文字列のFizzを返す" , test_配列の2番目は文字列のFizzを返す 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 では以下のようにして インスタンス変数 を読み取り専用にします。
1 2 3 4 5 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_accessor :type ...
1 2 3 4 5 ERROR["test_配列の2番目は文字列のFizzを返す" , test_配列の2番目は文字列のFizzを返す 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
も読み取り専用にします。
1 2 3 4 5 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type ...
1 2 3 4 5 6 7 8 9 ... 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 ...
テストが壊れていないことを確認したらコミットします。
1 2 $ git add . $ git commit -m 'refactor: setterの削除'
ポリモーフィズム ポリモーフィズムによる条件記述の置き換え 1
リファクタリングによりデータとロジックを1つのクラスにまとめて カプセル化 を進めることが出来ました。しかし、以下の警告メッセージが表示されたままです。ポリモーフィズム を使ったロジックのリファクタリングを実施していきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 ... 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
を確認してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ... 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 にする。
— 新装版 リファクタリング
1 2 3 4 5 6 7 class FizzBuzz ... end class FizzBuzzType01 ; end class FizzBuzzType02 ; end class FizzBuzzType03 ; end
まず、タイプごとのクラスを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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)をメッセージに追加してコミットします。
1 2 $ git add . $ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 2 続いて、各タイプクラスに インスタンスメソッド を実装します。ここでは case 式 の各処理をコピー&ペーストしています。カット&ペーストするとプロダクトコードが壊れたままリファクタリングを進めることになるのでここは慎重に進めていきます。
1 2 3 4 5 6 7 class FizzBuzz ... end class FizzBuzzType01 ; end class FizzBuzzType02 ; end class FizzBuzzType03 ; end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ... 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 ...
1 2 3 4 5 6 7 ... class FizzBuzzType02 def generate (number) number.to_s end end ...
1 2 3 4 5 6 7 8 9 10 11 ... 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
警告は出ますがテストは壊れていないのでコミットします。
1 2 $ git add . $ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 3 これで準備は整いましたのでテストコードの setup
メソッドを ファクトリメソッド の呼び出しに変更します。以下の部分は変更してはいけません。理由はわかりますか?
1 2 3 4 5 6 7 8 ... describe '1から100までのFizzBuzzの配列を返す' do def setup fizzbuzz = FizzBuzz.new(1 ) fizzbuzz.generate_list @result = fizzbuzz.list end ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ... 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_例外を返す" , test_例外を返す 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 ...
失敗するテストがありますね、該当するコードを確認したところ例外が発生するタイミングが変わってしまったので以下のように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 ... describe 'それ以外のタイプの場合' do def test_ 例外を返す e = assert_raises RuntimeError do FizzBuzz.create(4 ) end assert_equal '該当するタイプは存在しません' , e.message end end ...
1 2 3 4 5 6 7 8 9 10 ... 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 ...
コミットしておきましょう。
1 2 $ git add . $ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'
ポリモーフィズムによる条件記述の置き換え 4 タイプごとに FizzBuzz を生成するクラスを用意したので FizzBuzz クラスから呼び出せるようにしましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type end ... def generate_list @list = (1 ..MAX_NUMBER).map { |n| generate(n) } end end
まず、コンストラクタ から クラスメソッド の ファクトリメソッド を呼び出して インスタンス変数 の type
にタイプクラスの 参照 を 代入 します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = FizzBuzz.create(type) end ... def generate_list @list = (1 ..MAX_NUMBER).map { |n| generate(n) } end end
1 2 3 4 5 6 7 8 9 ERROR["test_配列の14番目は文字列のFizzBuzzを返す" , test_配列の14番目は文字列のFizzBuzzを返す 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
を呼び出すように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = FizzBuzz.create(type) end ... def generate_list @list = (1 ..MAX_NUMBER).map { |n| @type.generate(n) } end end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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 を外してコミットします。
1 2 $ git add . $ git commit -m 'refactor ポリモーフィズムによる条件記述の置き換え'
State/Strategy によるタイプコードの置き換え 仕上げは State/Strategy によるタイプコードの置き換え を適用して、警告メッセージを消すとしましょう。
State/Strategy によるタイプコードの置き換え
クラスの振る舞いに影響するタイプコードがあるが、サブクラス化はできない。
状態オブジェクトでタイプコードを置き換える
— 新装版 リファクタリング
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 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 @list = (1 ..MAX_NUMBER).map { |n| @type.generate(n) } end end ...
まず、FizzBuzz#generate
のメソッド呼び出しを インスタンス変数 type
が参照するタイプクラスのメソッド FizzBuzzTypeXX#generate
に 委譲 するように変更します。
1 2 3 4 5 6 7 8 9 10 11 ... def generate (number) @type.generate(number) end def generate_list @list = (1 ..MAX_NUMBER).map { |n| @type.generate(n) } end end ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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 版)
1 2 3 4 5 6 7 8 9 10 11 ... def generate (number) @type.generate(number) end def generate_list @list = (1 ..MAX_NUMBER).map { |n| generate(n) } end end ...
テストも FizzBuzz インスタンス経由で実行するように修正しておきます。これですべての呼び出しが new
メソッド経由となりテストコードに一貫性を取り戻すことが出来ました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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
メソッドを呼び出しているだけで処理の詳細は理解しなくても振る舞いを理解できる 抽象化 された読みやすいコードになりました。静的コード解析も可読性が高くシンプルなコードとみなしてくれているようです。さて、警告メッセージもなくなり、テストも壊れていないのでコミットしておきましょう。
1 2 $ git add . $ git commit -m 'refactor: State/Strategyによるタイプコードの置き換え'
継承 分割したタイプクラスのメソッドに重複する処理があるので 継承 を使ってリファクタリングしましょう。ここでは スーパークラスの抽出 を適用します。
スーパークラスの抽出
似通った特性を持つ2つのクラスがある。
スーパークラスを作成して、共通の特性を移動する。
— 新装版 リファクタリング
スーパークラスの抽出
まずは、タイプクラスのスーパークラスとなる FizzBuzzType
クラスを作成して各タイプクラスに継承させます。
クラスベースのオブジェクト指向言語の多くはクラスの継承機能を有しています。クラスの継承とはあるクラスを元として、新しいクラスを定義することです。この時、継承元となるクラスを親クラスやスーパークラスと呼び、継承したクラスのことを子クラスやサブクラスと呼びます。
— かんたん Ruby
Ruby の クラスの継承 は以下のように書きます。
1 2 3 4 5 6 7 8 class FizzBuzz ... end class FizzBuzzType ; end class FizzBuzzType01 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ... class FizzBuzzType ; end class FizzBuzzType01 < FizzBuzzType... end class FizzBuzzType02 < FizzBuzzType... end class FizzBuzzType03 < FizzBuzzType... end
スーパークラス FizzBuzzType
を定義して各サブクラスに継承させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 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
部分を共通メソッドとしてスーパークラスに定義して各タイプクラスで呼び出すように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
テストが壊れていないことが確認できたのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: スーパークラスの抽出'
メソッド名の変更 スーパークラスの抽出 を実施したところまた警告メッセージが表示されるようになりました。
1 2 3 4 5 6 7 8 9 10 11 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 の ネーミングとしてはよろしくないようなので指示に従って メソッド名の変更 を実施しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ... 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
1 2 3 4 5 6 7 8 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
作業としては難しくないのでミスタイプしないように(まあ、ミスタイプしてもテストが教えてくれますが・・・)変更してコミットしましょう。
1 2 $ git add . $ git commit -m 'refactor: メソッド名の変更'
メソッドの移動 FizzBuzz
クラスの ファクトリメソッド ですが 特性の横恋慕 の臭いがするので メソッドの移動 を実施します。
特性の横恋慕
オブジェクト指向には、処理および処理に必要なデータを1つにまとめてしまうという重要な考え方があります。あるメソッドが、自分のクラスより他のクラスに興味を持つような場合には、古典的な誤りを犯しています。
— 新装版 リファクタリング
メソッドの移動
あるクラスでメソッドが定義されているが、現在または将来において、そのクラスの特性よりも他のクラスの特性の方が、そのメソッドを使ったり、そのメソッドから使われたりすることが多い。
同様の本体を持つ新たなメソッドを、それを最も多用するクラスに作成する。元のメソッドは、単純な委譲とするか、またはまるごと取り除く。
— 新装版 リファクタリング
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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 @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
に変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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 @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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 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
テストが壊れていないことを確認したらコミットします。
1 2 $ git add . $ git commit -m 'refactor: メソッドの移動'
値オブジェクト
オブジェクトによるプリミティブの置き換え FizzBuzz
クラスを インスタンス化 するには以下のように書きます。
1 fizz_buzz = FizzBuzz.new(1 )
クラスとして定義された情報を元に具体的な値を伴ったオブジェクトを作成することをインスタンス化と呼び、生成されたオブジェクトのことをインスタンスと呼びます。
— かんたん Ruby
コンストラクタ の 引数 に渡される 1
は何を表しているのでしょうか?もちろんタイプですが初めてこのコードを見る人にはわからないでしょう。このような整数、浮動小数点、文字列などの基本データ(プリミティブ)型の使い方からは 基本データ型への執着 の臭いがします。 オブジェクトによるプリミティブの置き換え を実施してコードの意図を明確にしましょう。
基本データ型への執着
オブジェクト指向のメリットとして、基本データ型とそれより大きなクラスとの境界を取り除くということがあります。プログラミング言語の組み込み(built-in)型と区別できないような小さなクラスを自分で定義することが容易です。
— 新装版 リファクタリング
基本データ型への執着
興味深いことに、多くのプログラマは、対象としているドメインに役立つ、貨幣、座標、範囲などの基本的な型を導入するのを嫌がる傾向があります。
— リファクタリング(第 2 版)
オブジェクトによるデータ値の置き換え
追加のデータや振る舞いが必要なデータ項目がある。
そのデータ項目をオブジェクトに変える。
— 新装版 リファクタリング
オブジェクトによるプリミティブの置き換え
旧:オブジェクトによるデータ値の置き換え
旧:クラスによるタイプコードの置き換え
— リファクタリング(第 2 版)
1 2 3 4 5 6 7 8 9 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = FizzBuzzType.create(type) end ...
1 2 3 4 5 6 7 8 9 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type end ...
コンストラクタ で引き渡されるタイプは整数ではなくタイプクラスの インスタンス に変更します。
1 2 3 4 5 6 7 8 ... ERROR["test_1を渡したら文字列1を返す" , test_1を渡したら文字列1を返す 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を返す' ...
テストが失敗しました。 コンストラクタ の引数を整数からタイプクラスの インスタンス に変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 ... 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
メソッドに変更しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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
それ以外のタイプの場合は ファクトリメソッド 経由でないと 例外 を出さなくなるので注意してください。
1 2 3 4 5 6 7 8 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
初めてコードを見る人でもテストコードを見ればコードの意図が読み取れるようになりましたのでコミットします。
1 2 $ git add . $ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'
マジックナンバーの置き換え まだプリミティグ型を使っている部分があります。ここは マジックナンバーの置き換え を実施して可読性を上げておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... class FizzBuzzType def self .create (type) case type when 1 FizzBuzzType01.new when 2 FizzBuzzType02.new when 3 FizzBuzzType03.new else raise '該当するタイプは存在しません' end end ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... 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 ...
1 2 3 4 5 6 7 8 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
テストは壊れていないのでコミットします。
1 2 $ git add . $ git commit -m 'refactor: マジックナンバーの置き換え'
オブジェクトによるプリミティブの置き換え 次に 基本データ型への執着 の臭いがする箇所として FizzBuzz#generate
メソッドが返す FizzBuzz の値が文字型である点です。文字列の代わりに 値オブジェクト FizzBuzzValue
クラスを定義します。
値の種類ごとに専用の型を用意するとコードが安定し、コードの意図が明確になります。このように、値を扱うための専用クラスを作るやり方を値オブジェクト(ValueObject)と呼びます。
— 現場で役立つシステム設計の原則
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... 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
を返すように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... FAIL["test_配列の2番目は文字列のFizzを返す" , test_配列の2番目は文字列のFizzを返す --- expected +++ actual @@ -1 +1 @@ -"Fizz" + /workspace/tdd_rb/test /fizz_buzz_test.rb:57:in `test_配列の2番目は文字列のFizzを返す' ...
変更によりテストが失敗しました。エラー内容を見てみると文字列からオブジェクトを返しているためアサーションが失敗しているようです。ここは、値オブジェクト の アクセッサメソッド を経由して取得した値をアサーション対象に変更しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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
テストコードをそれほど変更することなく 値オブジェクト を返すリファクタリングが出来ました。コミットしておきましょう。
1 2 $ git add . $ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'
学習用テスト 値オブジェクト の理解を深めるために 学習用テスト を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ... 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
1 2 $ git add . $ git commit -m 'test: 学習用テスト'
ファーストクラスコレクション
コレクションのカプセル化 値オブジェクト を扱う FizzBuzz リストですが コレクションのカプセル化 を適用して ファーストクラスコレクション オブジェクトを追加しましょう。
コレクションのカプセル化
メソッドがコレクションを返している。
読み取り専用のビューを返して、追加と削除のメソッドを提供する。
— 新装版 リファクタリング
このように、コレクション型のデータとロジックを特別扱いにして、コレクションを1つだけ持つ専用クラスを作るやり方をコレクションオブジェクトあるいはファーストクラスコレクションと呼びます。
— 現場で役立つシステム設計の原則
まず、 ファーストクラスコレクション クラスを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ... 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 配列を ファーストクラスコレクション から取得するように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type end ... def generate_list @list = (1 ..MAX_NUMBER).map { |n| generate(n) } end end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type @list = FizzBuzzList.new([]) end ... def generate_list @list = @list.add((1 ..MAX_NUMBER).map { |n| @type.generate(n) }) end end
なんだか紛らわしい書き方になってしましました。配列を作るのに以前の配列を元に新しい配列を作るとか回りくどいことをしないで既存の配列を使い回せばいいじゃんと思うかもしれませんが 変更可能なデータ はバグの原因となる傾向があります。変更可能な ミュータブル な変数ではなく 永続的に変更されない イミュータブル な変数を使うように心がけましょう。
変更可能なデータ
データの変更はしばし予期せぬ結果結果や、厄介なバグを引き起こします。他で違う値を期待していることに気づかないままに、ソフトウェアのある箇所で値を変更してしまえば、それだけで動かなくなってしまいます。これは値が変わる条件がまれにしかない場合、特に見つけにくいバグとなります。そのため、ソフトウェア開発の一つの潮流である関数型プログラミングは、データは不変であるべきで、更新時は常に元にデータ構造のコピーを返すようにし、元データには手を触れないという思想に基づいています。
— リファクタリング(第 2 版)
値オブジェクトと同じようにコレクションオブジェクトも、できるだけ「不変」スタイルで設計します。そのほうがプログラムが安定します。
— 現場で役立つシステム設計の原則
1 2 3 4 5 6 ... ERROR["test_配列の14番目は文字列のFizzBuzzを返す" , test_配列の14番目は文字列のFizzBuzzを返す Minitest::UnexpectedError: NoMethodError: undefined method `[]' for #<FizzBuzzList:0x0000556133198ba8 @value=[]> /workspace/tdd_rb/test/fizz_buzz_test.rb:66:in `test_配列の14番目は文字列のFizzBuzzを返す' ...
ファーストクラスコレクション 経由で取得するようになったので アクセッサメソッド を変更する必要があります。
1 2 3 4 5 6 7 8 9 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type end ...
1 2 3 4 5 6 7 8 9 10 class FizzBuzz MAX_NUMBER = 100 attr_reader :list attr_reader :type def initialize (type) @type = type @list = FizzBuzzList.new([]) end ...
1 2 3 4 5 6 7 8 9 10 11 12 13 class FizzBuzz MAX_NUMBER = 100 attr_reader :type def list @list.value end def initialize (type) @type = type @list = FizzBuzzList.new([]) end ....
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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
テストが直ったのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: コレクションのカプセル化'
学習用テスト ファーストクラスコレクション を理解するため 学習用テスト を追加しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... 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
1 2 $ git add . $ git commit -m 'refactor: 学習用テスト'
オブジェクト指向設計
値オブジェクト 及び ファーストクラスコレクション の適用で 基本データ型への執着 の臭いはなくなりました。今度は設計の観点から全体を眺めてみましょう。ここで気になるのが FizzBuzz
クラスです。このクラスは他のクラスと比べてやることが多いようです。このようなクラスは 単一責任の原則 に違反している可能性があります。そこで デザインパターン の1つである Command パターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え 適用してみようと思います。
SRP: 単一責任の原則
かつて単一責任の原則(SRP)は、以下のように語られてきた。
モジュールを変更する理由はたったひとつだけであるべきである
ソフトウェアシステムに手を加えるのは、ユーザーやステークホルダーを満足させるためだ。この「ユーザーやステークホルダー」こそが、単一責任の原則(SRP)を指す「変更する理由」である。つまり、この原則は以下のように言い換えられる。
モジュールはたったひとりのユーザーやステークホルダーに対して責任を負うべきである。
残念ながら「たったひとりのユーザーやステークホルダー」という表現は適切ではない。複数のユーザーやステークホルダーがシステムを同じように変更したいと考えることもある。ここでは、変更を望む人たちをひとまとめにしたグループとして扱いたい。このグループのことをアクターと呼ぶことにしよう。 これを踏まえると、最終的な単一責任の原則(SRP)は以下のようになる。
モジュールはたったひとつのアクターに対して責任を負うべきである。
さて、ここでいう「モジュール」とは何のことだろう?端的に言えば、モジュールとはソースファイルのことである。たいていの場合は、この定義で問題ないだろう。だが、ソースファイル以外のところにコードを格納する言語や開発環境も存在する。そのような場合の「モジュール」は、いくつかの関数やデータをまとめた凝集性のあるものだと考えよう。
「凝集性のある」という言葉が単一責任の原則(SRP)を匂わせる。凝集性が、ひとつのアクターに対する責務を負うコードをまとめるフォースとなる。
— Clean Architecture 達人に学ぶソフトウェアの構造と設計
Command パターン
処理の呼び出しが、シンプルなメソッド呼び出しよりも複雑になってきたときはどうすればよいだろうか—処理のためのオブジェクトを作成し、それを起動するようにしよう。
— テスト駆動開発
メソッドオブジェクトによるメソッドの置き換え
長いメソッドで、「メソッドの抽出」を適用できないようなローカル変数の使い方をしている。
メソッド自身をオブジェクトとし、すべてのローカル変数をそのオブジェクトのフィールドとする。そうすれば、そのメソッドを同じオブジェクト中のメソッド群に分解できる。
— 新装版 リファクタリング
メソッドオブジェクトによるメソッドの置き換え まず、値オブジェクト の FizzBuzzValue
を返す責務だけを持った メソッドオブジェクト を抽出します。Ruby のような動的言語では必要が無いのですが Command パターン の説明のため インターフェイス にあたるスーパークラスを継承した メソッドオブジェクト を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ... 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
を呼び出すように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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
の抽出ができたのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'
メソッドオブジェクトによるメソッドの置き換え 続いて、ファーストクラスコレクション を扱う FizzBuzzList
を返す責務だけを持った メソッドオブジェクト を抽出します。
1 2 3 4 5 6 7 8 9 10 ... 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 経由から実行するように変更します
1 2 3 4 5 6 7 8 ... describe '1から100までのFizzBuzzの配列を返す' do def setup fizzbuzz = FizzBuzz.new(FizzBuzzType01.new) fizzbuzz.generate_list @result = fizzbuzz.list end ...
1 2 3 4 5 6 7 ... describe '1から100までのFizzBuzzの配列を返す' do def setup fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new) @result = fizzbuzz.execute(100 ) end ...
1 2 3 4 5 6 7 8 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
テストが通ったのでコミットします。
1 2 $ git add . $ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'
デッドコードの削除 FizzBuzz
クラスの責務は各 メソッドオブジェクト が実行するようになったので削除しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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 @list = @list.add((1 ..MAX_NUMBER).map { |n| @type.generate(n) }) end end class FizzBuzzType ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ... ERROR["test_同じで値である" , test_同じで値である 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_新しいインスタンスが作られる" , test_新しいインスタンスが作られる 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
クラスを使っている箇所があるからですね。 メソッドオブジェクト 呼び出しに変更しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ... 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
1 2 3 4 5 6 7 8 9 10 ... 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 版)
1 2 $ git add . $ git commit -m 'refactor: デッドコードの削除'
デザインパターン メソッドオブジェクトによるメソッドの置き換え リファクタリングの結果として Command パターン という デザインパターン を適用しました。実はこれまでにも オブジェクトによるプリミティブの置き換え では Value Object パターン を ポリモーフィズムによる条件記述の置き換え では Factory Method パターン をそして、 委譲の隠蔽 の実施による State/Strategy によるタイプコードの置き換え では Strategy パターン を適用しています。
Command パターン
Value Object パターン
広く共有されるものの、同一インスタンスであることはさほど重要でないオブジェクトを設計するにはどうしたらよいだろうか—-オブジェクト作成時に状態を設定したら、その後決して変えないようにする。オブジェクトへの操作は必ず新しいオブジェクトを返すようにしよう。
— テスト駆動開発
Factory Method パターン
オブジェクト作成に柔軟性をもたせたいときは、どうすればよいだろうか—単にコンストラクタで作るのではなく、メソッドを使ってオブジェクトを作成しよう。
— テスト駆動開発
Strategy パターン
作成したコードはパターンと完全に一致しているわけではありませんし、Ruby のような動的言語ではもっと簡単な実現方法もありますがここでは先人の考えた設計パターンというものがありオブジェクト指向設計の イデオム として使えること。そしてテスト駆動開発では一般的な設計アプローチとは異なる形で導かれているということくらいを頭に残しておけば結構です。どのパターンをいつ適用するかはリファクタリングを繰り返しているうちに思いつくようになってきます(多分)。
ただ、書籍『デザインパターン』(通称 Gof 本)の 大ヒットは、その反面、それらパターンを表現する方法の多様性を奪ってしまった。Gof 本には、設計をフェーズとして扱うという暗黙の前提があるように見受けられる。つまり、リファクタリングを設計行為として捉えていない。TDD における設計は、デザインパターンを少しだけ違う側面から捉えなければならない。
— テスト駆動開発
あと、設計の観点から今回 単一責任の原則 に従って FizzBuzz
クラスを メソッドオブジェクト に分割して削除しました。
もし、新しい処理を追加する必要が発生した場合はどうしましょうか? FizzBuzzCommand
インターフェイスを実装した メソッドオブジェクト を追加しましょう。
もし、新しいタイプが必要になったらどうしましょうか? FizzBuzzType
クラスを継承した新しいタイプクラスを追加しましょう。
このように既存のコードを変更することなく振る舞いを変更できるので オープン・クローズドの原則 を満たした設計といえます。
OCP:オープン・クローズドの原則
「オープン・クローズドの原則(OCP)」は、1988 年に Bertrand Maeer が提唱した以下のような原則だ。
ソフトウェアの構成要素は拡張に対しては開いていて、修正に対しては閉じていなければならない。
『アジャイルソフトウェア開発の奥義 第2版』(SBクリエイティブ)より引用
言い換えれば、ソフトウェアの振る舞いは、既存の成果物を変更せず拡張できるようにすべきである、ということだ。
— Clean Architecture 達人に学ぶソフトウェアの構造と設計
例外
ここまでは、正常系をリファクタリングして設計を改善してきました。しかし、アプリケーションは例外系も考慮する必要があります。続いて、アサーションの導入 を適用した例外系のリファクタリングに取り組むとしましょう。
アサーションの導入
前提を明示するためのすぐれたテクニックとして、アサーションを記述する方法があります。
— リファクタリング(第 2 版)
アサーションの導入 まず、 メソッドオブジェクト の FizzBuzzValueCommand
にマイナスの値が渡された場合の振る舞いをどうするか考えます。ここでは正の値のみ許可する振る舞いにしたいので以下のテストコードを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 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
1 2 3 4 5 6 7 8 9 10 11 ... ERROR["test_値は正の値のみ許可する" , test_値は正の値のみ許可する 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 では モジュール を使います。
モジュールはクラスと非常によく似ていますが、以下の二点が異なります。
それ以外のクラスメソッドや定数の定義などはクラスと同じように定義することができます。
— かんたん Ruby
1 2 3 4 5 6 7 8 9 10 11 ... module Assertions class AssertionFailedError < StandardError; end def assert (&condition) raise AssertionFailedError, 'Assertion Failed' unless condition.call end end class FizzBuzzValue ...
アサーションモジュールを追加してエラーはなくなりましたがテストは失敗したままです。
1 2 3 4 5 6 7 8 9 10 11 ... FAIL["test_値は正の値のみ許可する" , test_値は正の値のみ許可する 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
1 2 3 4 5 6 7 8 9 class FizzBuzzValue attr_reader :number , :value def initialize (number, value) @number = number @value = value end ... end
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzValue include Assertions attr_reader :number , :value def initialize (number, value) assert { number >= 0 } @number = number @value = value end ... end
1 2 3 4 5 6 7 8 9 ... Started with run options --seed 37354 Progress: |====================================================================================================| Finished in 0.01433s 36 tests, 40 assertions, 0 failures, 0 errors, 0 skips ...
アサーションが機能するようになりました、コミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: アサーションの導入'
次は、メソッドオブジェクト の FizzBuzzListCommand
の実行時に 100 件以上指定された場合の振る舞いをどうするか考えます。ここでは 100 までを許可する振る舞いにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... 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 までというアサーションを追加します。
1 2 3 4 5 6 7 8 9 10 ... class FizzBuzzList include Assertions attr_reader :value def initialize (list) assert { list.count <= 100 } @value = list end ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... ERROR["test_新しいインスタンスが作られる" , test_新しいインスタンスが作られる 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
を発生させたようです。テストコードを修正しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 ... 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 件作るように変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 ... 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 ...
アサーションエラーはなくなりましたが期待した値と違うと指摘されています。テストコードのアサーションを修正します。
1 2 3 4 5 6 7 8 9 10 FAIL["test_新しいインスタンスが作られる" , test_新しいインスタンスが作られる 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
1 2 3 4 5 6 7 8 9 10 11 12 13 ... 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つ目のアサーションに引っかかってしまいました。こちらも修正します。
1 2 3 4 5 6 7 8 9 10 FAIL["test_新しいインスタンスが作られる" , test_新しいインスタンスが作られる 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
1 2 3 4 5 6 7 8 9 10 11 12 13 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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 ...
仕様変更による反映 が出来たのでコミットしましょう。
1 2 $ git add . $ git commit -m 'refactor: アサーションの導入'
アサーションの導入 とは別のアプローチとして 例外 を返す方法もあります。 例外によるエラーコードの置き換え を適用してアサーションモジュールを削除しましょう。
例外によるエラーコードの置き換え
エラーを示す特別なコードをメソッドがリターンしている。
代わりに例外を発生させる。
— 新装版 リファクタリング
例外によるエラーコードの置き換え アサーションモジュールを削除してアサーション部分を 例外 に変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 ... 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ... ERROR["test_値は正の値のみ許可する" , test_値は正の値のみ許可する 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 ...
アサーションモジュールを削除したのでエラーが発生しています。テストコードを修正しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ... 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ... 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
1 2 3 4 5 6 7 8 9 10 ... 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 ...
再びテストが通るようになったのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: 例外によるエラーコードの置き換え'
アルゴリズムの置き換え 1 2 3 4 5 6 7 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.
とのことなので アルゴリズムの置き換え を適用しておきましょう。
アルゴリズムの取り替え
アルゴリズムをよりわかりやすいものに置き換えたい
メソッドの本体を新たなアルゴリズムで置き換える。
— 新装版 リファクタリング
1 2 3 4 5 6 7 ... class FizzBuzzValue attr_reader :number , :value def initialize (number, value) raise '正の値のみ有効です' if number < 0 ...
1 2 3 4 5 6 7 8 ... class FizzBuzzValue attr_reader :number , :value def initialize (number, value) raise '正の値のみ有効です' if number.negative? ...
1 2 3 4 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
警告が消えたのでコミットします。
1 2 $ git add . $ git commit -m 'refactor: アルゴリズムの置き換え'
マジックナンバーの置き換え 件数に リテラル を使っています。ここは マジックナンバーの置き換え を適用するべきですね。
シンボリック定数によるマジックナンバーの置き換え
特別な意味を持った数字のリテラルがある。
定数を作り、それにふさわしい名前をつけて、そのリテラルを置き換える。
— 新装版 リファクタリング
1 2 3 4 5 6 7 8 9 10 ... class FizzBuzzList attr_reader :value def initialize (list) raise '上限は100件までです' if list.count > 100 @value = list end ...
式展開 を使ってメッセージ内容も定数から参照するようにしましょう。
式展開
式展開とは、「#{}」の書式で文字列中に何らかの変数や式を埋め込むことが可能な機能です。これは、ダブルクオートを使用した場合のみの機能です。
— かんたん Ruby
1 2 3 4 5 6 7 8 9 10 class FizzBuzzList MAX_COUNT = 100 attr_reader :value def initialize (list) raise "上限は#{MAX_COUNT} 件までです" if list.count > MAX_COUNT @value = list end ...
テストは壊れていないようですが MAX_COUNT
を変更したらテストが失敗するか確認しておきましょう。
1 2 3 class FizzBuzzList MAX_COUNT = 10 ...
1 2 3 4 5 6 7 8 9 ... ERROR["test_配列の14番目は文字列のFizzBuzzを返す" , test_配列の14番目は文字列のFizzBuzzを返す 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' ...
想定通りのエラーが発生したのでコードを元に戻してコミットしましょう。
1 2 3 class FizzBuzzList MAX_COUNT = 100 ...
1 2 3 4 5 6 7 8 9 ... Started with run options --seed 5525 Progress: |====================================================================================================| Finished in 0.01262s 37 tests, 43 assertions, 0 failures, 0 errors, 0 skips ...
1 2 $ git add . $ git commit -m 'refactor: マジックナンバーの置き換え'
特殊ケースの導入 最後に ポリモーフィズム の応用としてタイプクラスが未定義の場合に 例外 ではなく未定義のタイプクラスを返す 特殊ケースの導入 を適用してみましょう。
ヌルオブジェクトの導入
null 値のチェックが繰り返し現れる。
その null 値をヌルオブジェクトで置き換える。
— 新装版 リファクタリング
特殊ケースの導入
旧:ヌルオブジェクトの導入
特殊ケースの処理を要する典型的な値が null なので、このパターンをヌルオブジェクトパターンと呼ぶことがあります、しかし、通常の特殊ケースとアプローチは同じです。いわばヌルオブジェクトは「特殊ケース」の特殊ケースです。
— リファクタリング(第 2 版)
まず、それ以外のタイプの場合の振る舞いを変更します。
1 2 3 4 5 6 7 8 9 10 11 12 ... describe 'それ以外のタイプの場合' do def test_ 例外を返す e = assert_raises RuntimeError do FizzBuzzType.create(4 ) end assert_equal '該当するタイプは存在しません' , e.message end end end ...
1 2 3 4 5 6 7 8 9 10 ... describe 'それ以外のタイプの場合' do def test_ 未定義のタイプを返す fizzbuzz = FizzBuzzType.create(4 ) assert_equal '未定義' , fizzbuzz.to_s end end end ...
1 2 3 4 5 6 7 8 9 10 11 12 ... ERROR["test_未定義のタイプを返す" , test_未定義のタイプを返す 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
を作成して ファクトリメソッド を変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 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 ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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 ...
テストが通るようになりました。 メソッドオブジェクト から実行された場合の振る舞いも明記しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... 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 ...
1 2 3 4 5 6 7 8 9 10 ... 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 を安全に追加することができたのでコミットしておきます。
1 2 $ git add . $ git commit -m 'refactor: 特殊ケースの導入'
モジュール分割
クラスモジュールの抽出によってアプリケーションの構造が 抽象化 された結果、視覚的に把握できるようになりました。ここでアプリケーションを実行してみましょう。
1 2 3 4 $ ruby main.rb Traceback (most recent call last): main.rb:5:in `<main>': uninitialized constant FizzBuzz (NameError) Did you mean? FizzBuzzType
エラーが出ています、これはアプリケーションの構成が変わったためです。クライアントプログラムをアプリケーションの変更に合わせて修正します。
1 2 3 4 5 require './lib/fizz_buzz.rb' puts FizzBuzz.generate_list
1 2 3 4 5 6 require './lib/fizz_buzz.rb' command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))command.execute(100).each { |i| puts i.value }
1 2 3 4 5 6 7 8 $ ruby main.rb 1 2 Fizz 4 Buzz ... Fizz
クライアントプログラムが直ったのでコミットしておきます。
1 2 $ 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
フォルダ以下に配置します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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
1 2 3 4 5 6 7 8 9 10 11 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
1 2 3 4 5 6 7 class FizzBuzzType02 < FizzBuzzType def generate (number) FizzBuzzValue.new(number, number.to_s) end end
1 2 3 4 5 6 7 8 9 class FizzBuzzType03 < FizzBuzzType def generate (number) return FizzBuzzValue.new(number, 'FizzBuzz' ) if fizz?(number) && buzz?(number) FizzBuzzValue.new(number, number.to_s) end end
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzTypeNotDefined < FizzBuzzType def generate (number) FizzBuzzValue.new(number, '' ) end def to_s '未定義' end end
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ... 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 ...
テストは壊れていないようですが警告が出るようになりました。まだ仕掛ですが一旦コミットしておきます。
1 2 $ 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
フォルダ以下に配置します。
1 2 3 4 5 class FizzBuzzCommand def execute ; end end
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzValueCommand < FizzBuzzCommand def initialize (type) @type = type end def execute (number) @type.generate(number).value end end
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzListCommand < FizzBuzzCommand def initialize (type) @type = type end def execute (number) FizzBuzzList.new((1 ..number).map { |i| @type.generate(i) }).value end end
テストは壊れていないのでコミットしておきます。
1 2 $ 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 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
ファイル分割でテストは壊れていないようですが警告がたくさん出てきました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 ... 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
を編集してチェック対象から外しておきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ... 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
ファイルを削除します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ... 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 ...
テストの分割も完了したのでコミットしておきます。
1 2 $ 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
ファイルの内容をクラスモジュール読み込みに変更します。
1 2 3 4 5 6 7 8 9 10 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'
1 2 3 4 5 6 7 8 9 10 ... 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
を追加して共通部分を各テストファイルから読み込むように変更します。
1 2 3 4 5 6 require 'simplecov' SimpleCov.start require 'minitest/reporters' Minitest::Reporters.use!
1 2 3 4 5 6 7 8 require 'simplecov' SimpleCov.start require 'minitest/reporters' Minitest::Reporters.use! require 'minitest/autorun' require './lib/fizz_buzz' ...
1 2 3 4 5 require './test/test_helper' require 'minitest/autorun' require './lib/fizz_buzz' ...
テストタスクを実行したところ動作しなくなりました。
テスト対象をテストディレクトリ内のすべてのテストコードに変更します。
1 2 3 4 5 6 ... Rake::TestTask.new do |test| test.test_files = Dir['./test/fizz_buzz_test.rb' ] test.verbose = true end ...
1 2 3 4 5 6 ... Rake::TestTask.new do |test| test.test_files = Dir['./test/**/*_test.rb' ] test.verbose = true end ...
1 2 3 4 5 6 7 $ 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
テストも壊れていないし警告も出ていません。モジュール分割完了です。
1 2 $ git add . $ git commit -m 'refactor: モジュール分割'
ふりかえり 今回、 オブジェクト指向プログラム から オブジェクト指向設計 そして モジュール分割 を テスト駆動開発 を通じて実践しました。各トピックを振り返ってみましょう。
オブジェクト指向プログラム エピソード 1 で作成したプログラムの追加仕様を テスト駆動開発 で実装しました。 次に 手続き型コード との比較から オブジェクト指向プログラム を構成する カプセル化 ポリモフィズム 継承 という概念をコードベースの リファクタリング を通じて解説しました。
具体的には フィールドのカプセル から setter の削除 を適用することにより カプセル化 を実現しました。続いて、 ポリモーフィズムによる条件記述の置き換え から State/Strategy によるタイプコードの置き換え を適用することにより ポリモーフィズム の効果を体験しました。そして、 スーパークラスの抽出 から メソッド名の変更 メソッドの移動 の適用を通して 継承 の使い方を体験しました。さらに 値オブジェクト と ファーストクラス というオブジェクト指向プログラミングに必要なツールの使い方も学習しました。
オブジェクト指向設計 次に設計の観点から 単一責任の原則 に違反している FizzBuzz
クラスを デザインパターン の 1 つである Command パターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え を適用してクラスの責務を分割しました。オブジェクト指向設計のイデオムである デザインパターン として Command パターン 以外に Value Object パターン Factory Method パターン Strategy パターン を リファクタリング を適用する過程ですでに実現していたことを説明しました。そして、オープン・クローズドの原則 を満たすコードに リファクタリング されたことで既存のコードを変更することなく振る舞いを変更できるようになりました。
加えて、正常系の設計を改善した後 アサーションの導入 例外によるエラーコードの置き換え といった例外系の リファクタリング を適用しました。最後に ポリモーフィズム の応用として 特殊ケースの導入 の適用による Null Object パターン を使った オープン・クローズドの原則 に従った安全なコードの追加方法を解説しました。
モジュールの分割 仕上げに、モノリシック なファイルから個別のクラスモジュールへの分割を ドメインオブジェクト の抽出を通して ドメインモデル へと整理することにより モジュール分割 を実現しました。最終的にプログラムからアプリケーションへと体裁を整えることが出来ました。以下が最終的なモジュール構造とコードです。
/main.rb.
1 2 3 4 5 6 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.
1 2 3 4 5 class FizzBuzzCommand def execute ; end end
/lib/application/fizz_buzz_value_command.rb.
1 2 3 4 5 6 7 8 9 10 11 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.
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzListCommand < FizzBuzzCommand def initialize (type) @type = type end def execute (number) FizzBuzzList.new((1 ..number).map { |i| @type.generate(i) }).value end end
/lib/domain/model/fizz_buzz_value.rb.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 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.
1 2 3 4 5 6 7 8 9 10 11 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.
1 2 3 4 5 6 7 class FizzBuzzType02 < FizzBuzzType def generate (number) FizzBuzzValue.new(number, number.to_s) end end
/lib/domain/type/fizz_buzz_type_03.rb.
1 2 3 4 5 6 7 8 9 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.
1 2 3 4 5 6 7 8 9 10 11 class FizzBuzzTypeNotDefined < FizzBuzzType def generate (number) FizzBuzzValue.new(number, '' ) end def to_s '未定義' end end
/test/application/fizz_buzz_value_command_test.rb.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 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)