1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テスト駆動開発から始めるRuby入門 ~6時間でオブジェクト指向のエッセンスを体験する~

Last updated at Posted at 2020-02-10

エピソード3

Open in Gitpod

初めに

この記事は テスト駆動開発から始めるRuby入門 ~2時間でTDDとリファクタリングのエッセンスを体験する~ の続編です。

前提として エピソード1を完了して、テスト駆動開発から始めるRuby入門 ~ソフトウェア開発の三種の神器を準備する~ で開発環境を構築したところから始まります。 別途、セットアップ済み環境 を用意していますのでこちらからだとすぐに始めることが出来ます。

本記事は一応オブジェクト指向プログラム入門者向けとなっていますが、入門者の方は用語についてはわからなくても結構です、コードを繰り返し写経することで感覚を掴んでもらえば自ずと書いてあることはわかるようになってきますので。あと、概要はオブジェクト指向プログラム経験者に向けて書いたのものなので読み飛ばしてもらって結構です(ネタバレ内容です)、経験者の方からのツッコミお待ちしております。

概要

本記事では、 オブジェクト指向プログラム から オブジェクト指向設計 そして モジュール分割テスト駆動開発 を通じて実践していきます。

オブジェクト指向プログラム

エピソード1で作成したプログラムの追加仕様を テスト駆動開発 で実装します。 次に 手続き型コード との比較から オブジェクト指向プログラム を構成する カプセル化 ポリモフィズム 継承 という概念をコードベースの リファクタリング を通じて解説します。

具体的には フィールドのカプセル から setterの削除 を適用することにより カプセル化 を実現します。続いて、 ポリモーフィズムによる条件記述の置き換え から State/Strategyによるタイプコードの置き換え を適用することにより ポリモーフィズム の効果を体験します。そして、 スーパークラスの抽出 から メソッド名の変更 メソッドの移動 の適用を通して 継承 の使い方を体験します。さらに 値オブジェクトファーストクラス というオブジェクト指向プログラミングに必要なツールの使い方も学習します。

オブジェクト指向設計

次に設計の観点から 単一責任の原則 に違反している FizzBuzz クラスを デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え を適用してクラスの責務を分割します。オブジェクト指向設計のイデオムである デザインパターン として Commandパターン 以外に Value Objectパターン Factory Methodパターン Strategyパターンリファクタリング を適用する過程ですでに実現していたことを説明します。そして、オープン・クローズドの原則 を満たすコードに リファクタリング されたことで既存のコードを変更することなく振る舞いを変更できるようになることを解説します。

加えて、正常系の設計を改善した後 アサーションの導入 例外によるエラーコードの置き換え といった例外系の リファクタリング を適用します。最後に ポリモーフィズム の応用として 特殊ケースの導入 の適用による Null Objectパターン を使った オープン・クローズドの原則 に従った安全なコードの追加方法を解説します。

モジュールの分割

仕上げは、モノリシック なファイルから個別のクラスモジュールへの分割を ドメインオブジェクト の抽出を通して ドメインモデル へと整理することにより モジュール分割 を実現することを体験してもらいます。最後に 良いコード良い設計 について考えます。

Before

diag-c63943e73aed75ba31adf85779eaf481.png

After

diag-84a49e2f281dfc169055d0bfc4b4aeb6.png

オブジェクト指向から始めるテスト駆動開発

テスト駆動開発

エピソード1ので作成したプログラムに以下の仕様を追加します。

仕様

1 から 100 までの数をプリントするプログラムを書け。
ただし 3 の倍数のときは数の代わりに「Fizz」と、5 の倍数のときは「Buzz」とプリントし、
3 と 5 両方の倍数の場合には「FizzBuzz」とプリントすること。
タイプごとに出力を切り替えることができる。
タイプ1は通常、タイプ2は数字のみ、タイプ3は FizzBuzz の場合のみをプリントする。

早速開発に取り掛かりましょう。エピソード2で開発環境の自動化をしているので以下のコマンドを実行するだけで開発を始めることができます。

$ rake

guard が起動するとコンソールが使えなくなるのでもう一つコンソールを開いておきましょう。もしくは . を使うことで guard 内でコンソールのコマンドを呼び出すことができます。

[1] guard(main)> . ls
coverage  Gemfile.lock  lib      provisioning  README.md  tmp
Gemfile   Guardfile     main.rb  Rakefile      test       Vagrantfile
[2] guard(main)> . pwd
/workspace/tdd_rb
[3] guard(main)> . git status

TODOリスト作成

まずは追加仕様を TODOリスト に落とし込んでいきます。

TODOリスト

  • タイプ1の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す

タイプ1の場合

テストファースト アサートファースト で最初に失敗するテストから始めます。テストを追加しましょう。

ここでは既存の FizzBuzz.generate メソッドにタイプを 引数 として追加することで対応できるように変更してみたいと思います。まず、 fizz_buzz_test.rb ファイルに以下のテストコードを追加します。

...
  end

  describe 'タイプごとに出力を切り替えることができる' do
    describe 'タイプ1の場合' do
      def test_1を渡したら文字列1を返す
        assert_equal '1', FizzBuzz.generate(1, 1)
      end
    end
  end

  describe '配列や繰り返し処理を理解する' do
...
...
05:32:51 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 11 LOC (36.36%) covered.
Started with run options --guard --seed 37049

ERROR["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005623e6a24260 @name="タイプごとに出力を切り替えることができる::タイプ1の場合">, 0.0019176720088580623]
 test_1を渡したら文字列1を返す#タイプごとに出力を切り替えることができる::タイプ1の場合 (0.00s)
Minitest::UnexpectedError:         ArgumentError: wrong number of arguments (given 2, expected 1)
            /workspace/tdd_rb/lib/fizz_buzz.rb:6:in `generate'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:74:in `test_1を渡したら文字列1を返す'

  25/25: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00796s
25 tests, 26 assertions, 0 failures, 1 errors, 0 skips
...

ArgumentError: wrong number of arguments (given 2, expected 1) 引数 が違うと指摘されていますね。 FizzBuzz.generate メソッドの引数の変更したいのですが既存のテストを壊したくないのでここは デフォルト引数 使ってみましょう。

メソッドの引数にはデフォルト値を指定する定義方法があります。これは、メソッドの引数を省略した場合に割り当てられる値です。

— かんたんRuby

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz
    return 'Fizz' if is_fizz
    return 'Buzz' if is_buzz

    number.to_s
  end
...
...
05:32:52 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb Guardfile
 2/2 files |====================================== 100 =======================================>| Time: 00:00:00

2 files inspected, no offenses detected
05:32:54 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |====================================== 100 =======================================>| Time: 00:00:00

0 files inspected, no offenses detected
05:37:29 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
lib/fizz_buzz.rb:6:29: W: [Corrected] Lint/UnusedMethodArgument: Unused method argument - type. If it's necessary, use _ or _type as an argument name to indicate that it won't be used.
  def self.generate(number, type = 1)
                            ^^^^
 1/1 file |======================================= 100 =======================================>| Time: 00:00:00

1 file inspected, 1 offense detected, 1 offense corrected
05:37:31 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |======================================= 100 =======================================>| Time: 00:00:00

1 file inspected, no offenses detected
[1] guard(main)>
05:39:37 - INFO - Run all
05:39:37 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 11 LOC (36.36%) covered.
Started with run options --guard --seed 8607

  25/25: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00723s
25 tests, 27 assertions, 0 failures, 0 errors, 0 skips
...

ちなみにここでは 引数に type=1 と入力したのですがコードフォーマットによって以下のように自動修正されます。

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, _type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz
    return 'Fizz' if is_fizz
    return 'Buzz' if is_buzz

    number.to_s
  end
...

case式 を使って 引数 を判定できるように変更しましょう。ちなみに _type をメソッド内で変数として使うと警告されるので type に変更しています。

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    case type
    when 1
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    end
  end
...
...
Started with run options --seed 51330


Progress: |=============================================================|

Finished in 0.00828s
25 tests, 27 assertions, 0 failures, 0 errors, 0 skips
04:27:12 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |=================== 100 ====================>| Time: 00:00:00

1 file inspected, no offenses detected
04:27:13 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |=================== 100 ===================>| Time: 00:00:00

0 files inspected, no offenses detected
...

テストは無事通りました。ここでコミットしておきます。

$ git add .
$ git commit -m 'test: タイプ1の場合'

追加仕様の取っ掛かりができました。既存のテストを流用したいので先程作成したテストを削除して以下のように新しいグループ内に既存テストコードを移動しましょう。

...

class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列Fizzを返す
          assert_equal 'Fizz', @fizzbuzz.generate(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列Buzzを返す
          assert_equal 'Buzz', @fizzbuzz.generate(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.generate(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1)
        end
      end

      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          @result = FizzBuzz.generate_list
        end

        def test_配列の初めは文字列の1を返す
          assert_equal '1', @result.first
        end

        def test_配列の最後は文字列のBuzzを返す
          assert_equal 'Buzz', @result.last
        end

        def test_配列の2番目は文字列のFizzを返す
          assert_equal 'Fizz', @result[2]
        end

        def test_配列の4番目は文字列のBuzzを返す
          assert_equal 'Buzz', @result[4]
        end

        def test_配列の14番目は文字列のFizzBuzzを返す
          assert_equal 'FizzBuzz', @result[14]
        end
      end
    end
  end
...

テストコードが壊れていないことを確認したらコミットしておきます。

$ git add .
$ git commit -m 'refactor: メソッドのインライン化'

TODOリスト

  • タイプ1の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数の代わりに「Fizz」と返す_

      • 3を渡したら文字列"Fizz"を返す
    • 5 の倍数のときは「Buzz」と返す_

      • 5を渡したら文字列"Buzz"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す_

      • 15を渡したら文字列"FizzBuzz"を返す
  • タイプ2の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には数を文字列にして返す

      • 15を渡したら文字列"15"を返す
  • タイプ3の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す

      • 15を渡したら文字列"FizzBuzz"を返す

タイプ2の場合

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には数を文字列にして返す

      • 15を渡したら文字列"15"を返す
  • タイプ3の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す

      • 15を渡したら文字列"FizzBuzz"を返す

続いて、タイプ2の場合に取り掛かりましょう。

...
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1, 2)
        end
      end
    end
...
...
FAIL["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005555ec747100 @name="数を文字列にして返す::タイプ2の場合::その他の場合">, 0.002283181995153427]
 test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ2の場合::その他の場合 (0.00s)
        Expected: "1"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:75:in `test_1を渡したら文字列1を返す'

  24/24: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00437s
24 tests, 26 assertions, 1 failures, 0 errors, 0 skips
...

まだ 引数 に2を渡した場合は何もしないので case式 に2を渡した場合の処理を追加します。

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    case type
    when 1
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    end
  end
...
...
Started with run options --seed 19625


Progress: |=============================================================================|

Finished in 0.00894s
24 tests, 26 assertions, 0 failures, 0 errors, 0 skips
...

テストが通ったのでテストケースを追加します。ここはタイプ1の場合をコピーして編集すれば良いでしょう。

...
   end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3, 2)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5, 2)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.generate(15, 2)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1, 2)
        end
      end
    end
  end
...
...
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 4 / 13 LOC (30.77%) covered.
Started with run options --guard --seed 898

  27/27: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00900s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips

06:27:40 - INFO - Inspecting Ruby code style of all files
test/fizz_buzz_test.rb:11:3: C: Metrics/BlockLength: Block has too many lines. [70/62]
  describe '数を文字列にして返す' do ...
  ^^^^^^^^^^^^^^^^^^^^^^^^
 7/7 files |====================================== 100 =======================================>| Time: 00:00:00

7 files inspected, 1 offense detected
...

テストは通りましたが何やら警告が表示されるようになりました。 
Metrics/BlockLength:Block has too many lines. これは 数を文字列にして返す テストケースのコードブロックが長いという警告のようですがテストコードはチェックの対象から外しておきたいので .rubocop_todo.yml に以下コードを追加してチェック対象から外しておきます。

...
# Offense count: 2
# Configuration parameters: CountComments, ExcludedMethods.
# ExcludedMethods: refine
Metrics/BlockLength:
  Max: 62
  Exclude:
    - 'test/fizz_buzz_test.rb'
...

ちなみに guard(main)> にカーソルを合わせてエンターキーを押すと自動化タスクが実行されます。

[1] guard(main)>
02:03:15 - INFO - Run all
/home/gitpod/.rvm/rubies/ruby-2.6.3/bin/ruby -w -I"lib" -I"/workspace/.rvm/gems/rake-13.0.1/lib" "/workspace/.rvm/gems/rake-13.0.1/lib/rake/rake_test_loader.rb" "./test/fizz_buzz_test.rb"
/home/gitpod/.rvm/rubies/ruby-2.6.3/bin/ruby -w -I"lib" -I"/workspace/.rvm/gems/rake-13.0.1/lib" "/workspace/.rvm/gems/rake-13.0.1/lib/rake/rake_test_loader.rb" "./test/fizz_buzz_test.rb"
Started with run options --seed 47335


Progress: |==============================================================================|

Finished in 0.00781s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
Started with run options --seed 47825


Progress: |==============================================================================|

Finished in 0.00761s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips
02:03:17 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 13 / 13 LOC (100.0%) covered.
Started with run options --guard --seed 17744

  27/27: [===========================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00789s
27 tests, 29 assertions, 0 failures, 0 errors, 0 skips

02:03:17 - INFO - Inspecting Ruby code style of all files
 7/7 files |=========================== 100 ============================>| Time: 00:00:00

7 files inspected, no offenses detected
02:03:19 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
 0/0 files |=========================== 100 ============================>| Time: 00:00:00

0 files inspected, no offenses detected
[1] guard(main)>

警告は消えたのでコミットしておきます。

$ git add .
$ git commit -m 'test: タイプ2の場合'

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には数を文字列にして返す

      • 15を渡したら文字列"15"を返す
  • タイプ3の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す

      • 15を渡したら文字列"FizzBuzz"を返す

タイプ3の場合

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

  • タイプ3の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す

      • 15を渡したら文字列"FizzBuzz"を返す

続いて、タイプ3の場合ですがやることは同じなので今回は一気にテストを書いてみましょう。

...
    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3, 3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5, 3)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.generate(15, 3)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1, 3)
        end
      end
    end
  end
...
...
 FAIL["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005642171ea5a0 @name="数を文字列にして返す::タイプ3の場合::その他の場合">, 0.003375133004738018]
 test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ3の場合::その他の場合 (0.00s)
        Expected: "1"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:123:in `test_1を渡したら文字列1を返す'

 FAIL["test_5を渡したら文字列5を返す", #<Minitest::Reporters::Suite:0x000056421723af78 @name="数を文字列にして返す::タイプ3の場合::五の倍数の場合">, 0.003832244998193346]
 test_5を渡したら文字列5を返す#数を文字列にして返す::タイプ3の場合::五の倍数の場合 (0.00s)
        Expected: "5"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:111:in `test_5を渡したら文字列5を返す'

 FAIL["test_3を渡したら文字列3を返す", #<Minitest::Reporters::Suite:0x0000564217297340 @name="数を文字列にして返す::タイプ3の場合::三の倍数の場合">, 0.0043466729985084385]
 test_3を渡したら文字列3を返す#数を文字列にして返す::タイプ3の場合::三の倍数の場合 (0.00s)
        Expected: "3"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:105:in `test_3を渡したら文字列3を返す'

 FAIL["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005642174dec98 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.006096020006225444]
 test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.01s)
        Expected: "FizzBuzz"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:117:in `test_15を渡したら文字列FizzBuzzを返す'

  31/31: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00650s
31 tests, 33 assertions, 4 failures, 0 errors, 0 skips
...

case式 に処理を追加します。

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    case type
    when 1
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    end
  end
...
...
Started with run options --seed 12137


Progress: |=============================================================================|

Finished in 0.01662s
31 tests, 33 assertions, 0 failures, 0 errors, 0 skips
05:06:44 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png lib/fizz_buzz.rb
lib/fizz_buzz.rb:6:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
  def self.generate(number, type = 1) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:6:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
  def self.generate(number, type = 1) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 1/1 file |=========================== 100 ============================>| Time: 00:00:00

1 file inspected, 2 offenses detected
...

テストは通りましたが新しい警告が表示されるようになりました。とりあえずコミットしておきます。

$ git add .
$ git commit -m 'test: タイプ3の場合'

処理の追加により一部重複が発生しました。ここは、 ステートメントのスライド を適用して重複をなくしておきましょう。

ステートメントのスライド

旧:重複した条件記述の断片の統合

— リファクタリング(第2版)

重複した条件記述の断片の統合

条件式のすべて分岐に同じコードの断片がある。

それを式の外側に移動する。

— 新装版 リファクタリング

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    case type
    when 1
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      is_fizz = number.modulo(3).zero?
      is_buzz = number.modulo(5).zero?

      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    end
  end
...
...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    end
  end
...

警告は消えていませんがプログラムは壊れていないことが確認できたのでコミットしておきます。

$ git add .
$ git commit -m 'refactor: ステートメントのスライド'

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

  • タイプ3の場合

    • 数を文字列にして返す

      • 1を渡したら文字列"1"を返す
    • 3 の倍数のときは数を文字列にして返す

      • 3を渡したら文字列"3"を返す
    • 5 の倍数のときは数を文字列にして返す

      • 5を渡したら文字列"5"を返す
    • 3 と 5 両方の倍数の場合には「FizzBuzz」と返す

      • 15を渡したら文字列"FizzBuzz"を返す

それ以外のタイプの場合

追加仕様には対応しましたがタイプ1,2,3以外の値が 引数 として渡された場合はどうしましょうか? 現状では nil を返しますがこのような例外ケースも考慮する必要があります。

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

  • タイプ3の場合

  • それ以外のタイプの場合

例外処理 を追加します。まず、例外のテストですが以下の様に書きます。

例外とは記述したプログラムが想定していない値を受け取ったり、何らかの障害が発生した場合に処理を中断して、例外オブジェクトを生成して呼び出し元のメソッドに処理を戻す機構です。

— かんたんRuby

    describe 'タイプ3の場合' do
...
    end

    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz
      end

      def test_例外を返す
        e = assert_raises RuntimeError do
          @fizzbuzz.generate(1, 4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
...
...
 FAIL["test_例外を返す", #<Minitest::Reporters::Suite:0x0000558a26888e60 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.003033002998563461]
 test_例外を返す#数を文字列にして返す::それ以外のタイプの場合 (0.00s)
        RuntimeError expected but nothing was raised.
        /workspace/tdd_rb/test/fizz_buzz_test.rb:134:in `test_例外を返す'

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00609s
32 tests, 34 assertions, 1 failures, 0 errors, 0 skips
...

case式 に該当しないタイプが指定された場合は 例外を発生させる ようにします。

例外を明示的に発生させるには「raise」を使います。raiseには発生させたい例外クラスを指定するのですが、何も指定しない場合はRuntimeErrorオブジェクトが生成されます。

— かんたんRuby

...
class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end
...
...
07:04:53 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 16 / 16 LOC (100.0%) covered.
Started with run options --guard --seed 32508

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00600s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

テストが通ったのでコミットしておきます。

$ git add .
$ git commit -m 'test: それ以外のタイプの場合'

TODOリスト

  • タイプ1の場合

  • タイプ2の場合

  • タイプ3の場合

  • それ以外のタイプの場合

TODOリスト
をすべて完了しました。追加仕様を満たすプログラムは出来ましたがまだ改善の余地がありそうですね。以降ではオブジェクト指向アプローチによるコードのリファクタリングを解説していきたいと思います。

オブジェクト指向

手続き型プログラム

オブジェクト指向 の解説の前に以下のコードを御覧ください。いわゆる 手続き型 で書かれたコードですが、これも追加仕様を満たしています。

MAX_NUMBER = 100
type = 1
list = []

MAX_NUMBER.times do |i|
  r = ''
  i += 1
  case type
  when 1
    if i % 3 == 0 && i % 5 == 0
      r = 'FizzBuzz'
    elsif i % 3 == 0
      r = 'Fizz'
    elsif i % 5 == 0
      r = 'Buzz'
    else
      r = i.to_s
    end
  when 2
    r = i.to_s
  when 3
    if i % 3 == 0 && i % 5 == 0
      r = 'FizzBuzz'
    else
      r = i.to_s
    end
  else
    r = '該当するタイプは存在しません'
  end

  list.push(r)
end

puts list

処理の流れをフローチャートにしたものです、実態はコードに記述されている内容を記号に置き換えて人間が読めるようにしたものです。

diag-c465147b957dba9fdfaa1a1196d29378.png

オブジェクト指向プログラム

続いて、これまでに作ってきたコードがこちらになります。上記の 手続き型コード との大きな違いとして class というキーワードでくくられている部分があります。

クラスとは、大まかに説明すると何らかの値と処理(メソッド)をひとかたまりにしたものです。

— かんたんRuby

class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end

  def self.generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    (1..MAX_NUMBER).map { |n| generate(n) }
  end
end

UML を使って上記のコードの構造をクラス図として表現しました。

diag-c63943e73aed75ba31adf85779eaf481.png

更にシーケンス図を使って上記のコードの振る舞いを表現しました。

diag-4cda3860e38bd75023756d182d6db0b7.png

手続き型コード のフローチャートと比べてどう思われましたか?具体的な記述が少なくデータや処理の概要だけを表現しているけどFizzBuzzのルールを知っている人であれば何をやろうとしているかのイメージはつかみやすいのではないでしょうか?だから何?と思われるかもしれませんが現時点では オブジェクト指向 において 抽象化 がキーワードだという程度の認識で十分です。

オブジェクト指向の理解を深める取り掛かりにはこちらの記事を参照してください。

オブジェクト指向の詳細は控えるとして、ここでは カプセル化 ポリモフィズム 継承 というオブジェクト指向プログラムで原則とされる概念をリファクタリングを通して体験してもらい、オブジェクト指向プログラムの感覚を掴んでもらうことを目的に解説を進めていきたいと思います。

カプセル化

フィールドのカプセル化

diag-c63943e73aed75ba31adf85779eaf481.png

まず、データとロジックを1つのクラスにまとめていくためのリファクタリングを実施していくとします。FizzBuzz クラスにFizzBuzz配列を保持できるようして以下のように取得できるようにしたいと思います。

...
          fizzbuzz.generate_list
          @result = fizzbuzz.list
...

まず、 インスタンス変数 追加します。次に self キーワードを外して クラスメソッド から インスタンスメソッド に変更します。

クラスメソッドはいくつか定義方法がありますが、どの方法を使ってもクラスメソッドとして定義されれば「クラス名.メソッド名」という形で呼び出せます。

— かんたんRuby

インスタンスメソッドはコンストラクタと同じようにクラス内でdefキーワードを使ってメソッドを定義するだけで作成できます。

— かんたんRuby

class FizzBuzz
  MAX_NUMBER = 100

  def self.generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end

  def self.generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    (1..MAX_NUMBER).map { |n| generate(n) }
  end
end
class FizzBuzz
  MAX_NUMBER = 100

  def list
    @list
  end

  def generate(number, type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end
...

ERROR["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005613555ed120 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.0041351839900016785]
 test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.00s)
Minitest::UnexpectedError:         NoMethodError: undefined method `generate' for FizzBuzz:Class
            /workspace/tdd_rb/test/fizz_buzz_test.rb:117:in `test_15を渡したら文字列FizzBuzzを返す'
...

FizzBuzz配列を インスタンス変数 @list代入 して インスタンス変数経由で取得できるように変更しました。変更にあたり クラスメソッド FizzBuzz.generateFizzBuzz.generate_listインスタンスメソッド に変更しています。それに伴ってテストが失敗して NoMethodError: undefined method `generate' と表示されるようになってしまいました。インスタンスメソッド が使えるようにするため new メソッドを使ってFizzBuzzクラスの インスタンス を作りFizzBuzz配列を インスタンス変数 経由で取得するようにテストコードを変更します。

クラスとして定義された情報を元に具体的な値を伴ったオブジェクトを作成することをインスタンス化と呼び、生成されたオブジェクトのことをインスタンスと呼びます。

— かんたんRuby

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new
      end
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new
      end
...
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new
      end
...
    end

    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz.new
      end
...
    end
  end
...
...
07:17:36 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 5 / 17 LOC (29.41%) covered.
Started with run options --guard --seed 7701

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00616s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

テストが直りました。クラスメソッド インスタンスメソッド インスタンス変数 インスタンス などいろんな単語が出てきて戸惑ってしまったかもしれませんが、ピンとこないうちは クラス に値や状態を保持させるためには インスタンス化 する必要があってそのためには new メソッドを使わないといけないのね程度の理解で十分です。大概のことは手を動かしているうちにピンと来るようになります。

インスタンス変数 に直接アクセスしているのでここは アクセッサメソッド を使って フィールドのカプセル化 を適用しておきます。

オブジェクト指向ではクラス内の値をカプセル化することが重要ですが、時には内部で保持しているインスタンス変数を参照や更新できる方が良い場合もあります。複雑な処理ではなく、単にインスタンス変数にアクセスするためのメソッドのことを、アクセッサメソッドと呼びます。

— かんたんRuby

フィールドのカプセル化

公開フィールドがある。

それを非公開にして、そのアクセサを用意する。

— 新装版 リファクタリング

自動実行の結果、以下のように書き換えられている部分を変更します。

class FizzBuz
  MAX_NUMBER = 100
 attr_reader :list
...
class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list
...

テストが動作して既存のコードが壊れていないことが確認できたのでここでコミットします。

$ git add .
$ git commit -m 'refactor: フィールドのカプセル化'

diag-102c44abf73ca5bda6fd8b87cc722ac9.png

引き続き、FizzBuzz配列は保持できるようになりましたがタイプごとに出力される配列のパターンは違います。FizzBuzzクラスにタイプを持たる必要があります。ここでは コンストラクタ を使って インスタンス化 する際に インスタンス変数代入 するようにします。Rubyでは initialize というメソッドを使って初期化処理を実行します。

クラスをインスタンス化した時に初期化処理を行うシチュエーションはよくあります。このような初期化処理を行うメソッドをコンストラクタと呼び、Rubyではinitializeという特別なメソッドを用意することで実現できます。

— かんたんRuby

class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list

  def initialize(type)
    @type = type
  end
...
...
ERROR["test_3を渡したら文字列3を返す", #<Minitest::Reporters::Suite:0x00005564e21e85b0 @name="数を文字列にして返す::タイプ3の場合::三の倍数の場合">, 0.004276092993677594]
 test_3を渡したら文字列3を返す#数を文字列にして返す::タイプ3の場合::三の倍数の場合 (0.00s)
Minitest::UnexpectedError:         ArgumentError: wrong number of arguments (given 0, expected 1)
            /workspace/tdd_rb/lib/fizz_buzz.rb:7:in `initialize'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:101:in `new'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:101:in `setup'
...

テストが失敗して引数が違うというエラーが表示される用になりました。new メソッドの 引数 にタイプを渡すようにテストを変更します。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(1)
      end
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(2)
      end
...
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(3)
      end
...
    end

    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(4)
      end
...
    end
  end
...
...
07:28:38 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 6 / 19 LOC (31.58%) covered.
Started with run options --guard --seed 46661

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00793s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

テストは直りましたがまだ インスタンス変数 のタイプが使われていないので使うようにプロダクトコードを変更します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list

  def initialize(type)
    @type = type
  end

  def generate(number, _type = 1)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case @type
...

FizzBuzz.gnerate メソッドの 引数 から type を削除します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list

  def initialize(type)
    @type = type
  end

  def generate(number)
...
...
ERROR["test_15を渡したら文字列FizzBuzzを返す", #<Minitest::Reporters::Suite:0x0000564e16c14200 @name="数を文字列にして返す::タイプ3の場合::三と五の倍数の場合">, 0.01706391001062002]
 test_15を渡したら文字列FizzBuzzを返す#数を文字列にして返す::タイプ3の場合::三と五の倍数の場合 (0.02s)
Minitest::UnexpectedError:         ArgumentError: wrong number of arguments (given 2, expected 1)
            /workspace/tdd_rb/lib/fizz_buzz.rb:11:in `generate'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:118:in `test_15を渡したら文字列FizzBuzzを返す'
...

続いて、FizzBuzz#generate メソッドから不要になった 引数 type
を削除したところテストが壊れたのでテストコードを修正します。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
  ...
    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(2)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.generate(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1)
        end
      end
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(3)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.generate(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1)
        end
      end
    end

    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(4)
      end

      def test_例外を返す
        e = assert_raises RuntimeError do
          @fizzbuzz.generate(1)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
...
07:34:57 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 15 / 19 LOC (78.95%) covered.
Started with run options --guard --seed 59116

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00700s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

インスタンス変数@typeアクセッサメソッド を使って フィールドのカプセル化 を適用しておきます。

class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list

  def initialize(type)
    @type = type
  end
...
class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list
  attr_accessor :type

  def initialize(type)
    @type = type
  end
...
...
Started with run options --guard --seed 56315

  32/32: [===========================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.01069s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

コミットしておきます。

$ git add .
$ git commit -m 'refactor: フィールドのカプセル化'

setterの削除

FizzBuzz配列を取得する アクセッサメソッド は現在このように定義されています。

class FizzBuzz
  MAX_NUMBER = 100
  attr_accessor :list
  attr_accessor :type
...

以下のようにテストコードを変更したらどうなるでしょうか?

...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          fizzbuzz.list = []
          @result = fizzbuzz.list
        end
...
...
 FAIL["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x0000563c29a8a8c0 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.005137628992088139]
 test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
        Expected: "Fizz"
          Actual: nil
        /workspace/tdd_rb/test/fizz_buzz_test.rb:58:in `test_配列の2番目は文字列のFizzを返す'
...

FizzBuzz配列が初期化されてしまいました。アクセッサメソッド に参照のための getter と 更新するための setter が許可されているため カプセル化 が破られてしまいました。ここは setterの削除 を適用して外部からの更新を出来ないようにしておきましょう。

getterを定義するには、「attr_reader」を使います。このメソッドにインスタンス変数の「@」を除いた名称をシンボル表現にしたものを列挙します。複数ある場合はカンマで区切って複数の値を指定することができます。

— かんたんRuby

setterを定義するには、「attr_writer」を使います。このメソッドもattr_readerと同じくインスタンス変数名の「@」を除いた名称をシンボル表現にしたものを列挙します。複数ある場合はカンマで区切って複数の値を指定することができます。

— かんたんRuby

getter/setterの両方を定義する場合、そのインスタンスは属しているクラス外から自由に参照や更新ができてしまいます。これはカプセル化の観点には反した挙動なので、できる限りattr_readerだけで済ませられないか検討しましょう。

— かんたんRuby

setterの削除

setterが用意されているということは、フィールドが変更される可能性があることを意味します。オブジェクトを生成した後でフィールドを変更したくないなら、setterは用意しません(加えて、フィールドを変更不可にします)。そうすることで、フィールドはコンストラクタでのみで設定され、変更させないという意図が明確になって、フィールドが変更される可能性を、たいていは排除できます。

— リファクタリング(第2版)

Rubyでは以下のようにして インスタンス変数 を読み取り専用にします。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_accessor :type
...
ERROR["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x000055b32efd75f0 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.008614362974185497]
 test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError:         NoMethodError: undefined method `list=' for #<FizzBuzz:0x000055b32ee8c678>
        Did you mean?  list
            /workspace/tdd_rb/test/fizz_buzz_test.rb:45:in `setup'

更新メソッドは存在しませんというエラーに変わったことが確認できたのでテストを元にもどします。

同様に インスタンス変数@type も読み取り専用にします。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type
...
...
04:32:06 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 22 / 22 LOC (100.0%) covered.
Started with run options --guard --seed 20902

  32/32: [===========================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00920s
...

テストが壊れていないことを確認したらコミットします。

$ git add .
$ git commit -m 'refactor: setterの削除'

diag-a8b45c800de2a31873f9eba1feac2184.png

ポリモーフィズム

ポリモーフィズムによる条件記述の置き換え 1

diag-a8b45c800de2a31873f9eba1feac2184.png

リファクタリングによりデータとロジックを1つのクラスにまとめて カプセル化 を進めることが出来ました。しかし、以下の警告メッセージが表示されたままです。ポリモーフィズム を使ったロジックのリファクタリングを実施していきましょう。

...
07:53:29 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb lib/fizz_buzz.rb
lib/fizz_buzz.rb:11:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
  def generate(number) ...
  ^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:11:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
  def generate(number) ...
  ^^^^^^^^^^^^^^^^^^^^
 2/2 files |====================================== 100 =======================================>| Time: 00:00:00

2 files inspected, 2 offenses detected
...

循環的複雑度 が高く可読性が低く複雑なコードと警告されているようです。対象となっている FizzBuzz#generate を確認してみましょう。

...
  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case @type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end
...

コードの不吉な臭いである スイッチ文 に該当するコードのようなのでここはリファクタリングカタログに従って ポリモーフィズムによる条件記述の置き換え を適用していきましょう。比較的大きなリファクタリングなのでいくつかのステップに分けて進めていきます。

スイッチ文

オブジェクト指向プログラミングのメリットして、スイッチ文が従来にくらべて少なくなるということがあります。スイッチ文は重複したコードを生み出す問題児です。コードのあちらこちらに同じようなスイッチ文が見られることがあります。これでは新たな分岐を追加したときに、すべてのスイッチ文を探して似たような変更をしていかなければなりません。オブジェクト指向ではポリモーフィズムを使い、この問題をエレガントに解決できます。

— 新装版 リファクタリング

重複したスイッチ文

最近はポリモーフィズムも一般的となり、15年前に比べるとswitch文が単純に赤信号というわけでもなくなりました。また、多くのプログラミング言語が、基本データ型以外をサポートする、より洗練されたswitch文を提供してきています。そこで、今後問題とするのは、重複したswitch文のみとします。switch/case文や、ネストしたif/else文の形で、コードのさまざまな箇所に同じ条件分岐ロジックが書かれていれば、それは「不吉な臭い」です。重複した条件分岐が問題なのは、新たな分岐を追加したら、すべての重複した条件分岐を探して更新指定かなけれけならないからです。ポリモーフィズムは、そうした単調な繰り返しに誘うダークフォースに対抗するための、洗練された武器です。コードベースをよりモダンにしていきましょう。

— リファクタリング(第2版)

ポリモーフィズムによる条件記述の置き換え

オブジェクトのタイプによって異なる振る舞いを選択する条件記述がある。

条件記述の各アクション部をサブクラスでオーバーライドするメソッドに移動する。元のメソッドはabstractにする。

— 新装版 リファクタリング

class FizzBuzz
...
end

class FizzBuzzType01; end
class FizzBuzzType02; end
class FizzBuzzType03; end

まず、タイプごとのクラスを定義します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
  end

  def self.create(type)
    case type
    when 1
      FizzBuzzType01.new
    when 2
      FizzBuzzType02.new
    when 3
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end

...

次に、タイプごとのクラスを インスタンス化 する ファクトリメソッド をFizzBuzzクラスに追加します。この時点では新しいクラスとメソッドの追加だけなのでテストは壊れていないはずです(警告は出ていますが・・・)。ここでコミットしておきますがリファクタリング作業としては 仕掛 なのでWIP(Work In Progress)をメッセージに追加してコミットします。

$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'

diag-c83e15398192d4cb68c948dfda55870b.png

ポリモーフィズムによる条件記述の置き換え 2

続いて、各タイプクラスに インスタンスメソッド を実装します。ここでは case式 の各処理をコピー&ペーストしています。カット&ペーストするとプロダクトコードが壊れたままリファクタリングを進めることになるのでここは慎重に進めていきます。

class FizzBuzz
...
end

class FizzBuzzType01; end
class FizzBuzzType02; end
class FizzBuzzType03; end
...
class FizzBuzzType01
  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz
    return 'Fizz' if is_fizz
    return 'Buzz' if is_buzz

    number.to_s
  end
end
...
...
class FizzBuzzType02
  def generate(number)
    number.to_s
  end
end
...
...
class FizzBuzzType03
  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz

    number.to_s
  end
end

警告は出ますがテストは壊れていないのでコミットします。

$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'

diag-c9ffad9b803420dabdb72a8eaf15cb72.png

ポリモーフィズムによる条件記述の置き換え 3

これで準備は整いましたのでテストコードの setup メソッドを ファクトリメソッド の呼び出しに変更します。以下の部分は変更してはいけません。理由はわかりますか?

...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.create(1)
      end
...
    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.create(2)
      end
...
    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.create(3)
      end
...
    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz.create(4)
      end

      def test_例外を返す
        e = assert_raises RuntimeError do
          @fizzbuzz.generate(1)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
08:14:14 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 26 / 42 LOC (61.9%) covered.
Started with run options --guard --seed 37585

ERROR["test_例外を返す", #<Minitest::Reporters::Suite:0x000056317940fa28 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.0037079370085848495]
 test_例外を返す#数を文字列にして返す::それ以外のタイプの場合 (0.00s)
Minitest::UnexpectedError:         RuntimeError: 該当するタイプは存在しません
            /workspace/tdd_rb/lib/fizz_buzz.rb:20:in `create'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:132:in `setup'

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00685s
32 tests, 33 assertions, 0 failures, 1 errors, 0 skips
...

失敗するテストがありますね、該当するコードを確認したところ例外が発生するタイミングが変わってしまったので以下のように変更します。

...
    describe 'それ以外のタイプの場合' do
      def setup
        @fizzbuzz = FizzBuzz.create(4)
      end

      def test_例外を返す
        e = assert_raises RuntimeError do
          @fizzbuzz.generate(1)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
...
...
    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzz.create(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
...
...
08:18:08 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 37 / 42 LOC (88.1%) covered.
Started with run options --guard --seed 40171

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00559s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
...

コミットしておきましょう。

$ git add .
$ git commit -m 'refactor(WIP): ポリモーフィズムによる条件記述の置き換え'

diag-852794d6dd3e17ad001905a500b520e3.png

ポリモーフィズムによる条件記述の置き換え 4

タイプごとにFizzBuzzを生成するクラスを用意したのでFizzBuzzクラスから呼び出せるようにしましょう。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
  end
...
  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end

まず、コンストラクタ から クラスメソッドファクトリメソッド を呼び出して インスタンス変数type にタイプクラスの 参照代入 します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = FizzBuzz.create(type)
  end
...
  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x000055670a343110 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.006740843993611634]
 test_配列の14番目は文字列のFizzBuzzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError:         RuntimeError: 該当するタイプは存在しません
            /workspace/tdd_rb/lib/fizz_buzz.rb:42:in `generate'
            /workspace/tdd_rb/lib/fizz_buzz.rb:48:in `block in generate_list'
            /workspace/tdd_rb/lib/fizz_buzz.rb:48:in `each'
            /workspace/tdd_rb/lib/fizz_buzz.rb:48:in `map'
            /workspace/tdd_rb/lib/fizz_buzz.rb:48:in `generate_list'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:44:in `setup'

テストが失敗して沢山エラーが表示するようになりましたが落ち着いてください。次に インスタンスメソッド FizzBuzz#generate_list 内の FizzBuzz#generate メソッド呼び出しを インスタンス変数 type が参照するタイプクラスのメソッド FizzBuzzTypeXX#generate を呼び出すように変更します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = FizzBuzz.create(type)
  end
...
  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
  end
end
Started with run options --seed 13878


Progress: |=====================================================================================================|

Finished in 0.00960s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
05:54:49 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
lib/fizz_buzz.rb:24:3: C: Metrics/CyclomaticComplexity: Cyclomatic complexity for generate is too high. [10/8]
  def generate(number) ...
  ^^^^^^^^^^^^^^^^^^^^
lib/fizz_buzz.rb:24:3: C: Metrics/PerceivedComplexity: Perceived complexity for generate is too high. [8/7]
  def generate(number) ...
  ^^^^^^^^^^^^^^^^^^^^
 1/1 file |======================================= 100 ========================================>| Time: 00:00:00

1 file inspected, 2 offenses detected

再びテストが通るようになりました。始めのうちはコードを少し変更しただけでなんで動くようになったの?と思うかもしれませんがこれが ポリモーフィズム の威力です。この概念を感覚としてつかんで使いこなせるようになることがオブジェクト指向プログラミングの第一歩です。感覚は意識して手を動かしていればそのうちつかめます(多分)。

ポリモーフィズムによる条件記述の置き換え が完了したのでWIPを外してコミットします。

$ git add .
$ git commit -m 'refactor ポリモーフィズムによる条件記述の置き換え'

State/Strategyによるタイプコードの置き換え

仕上げは State/Strategyによるタイプコードの置き換え を適用して、警告メッセージを消すとしましょう。

State/Strategyによるタイプコードの置き換え

クラスの振る舞いに影響するタイプコードがあるが、サブクラス化はできない。

状態オブジェクトでタイプコードを置き換える

— 新装版 リファクタリング

diag-852794d6dd3e17ad001905a500b520e3.png

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = FizzBuzz.create(type)
  end

  def self.create(type)
    case type
    when 1
      FizzBuzzType01.new
    when 2
      FizzBuzzType02.new
    when 3
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
  end

  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    case @type
    when 1
      return 'FizzBuzz' if is_fizz && is_buzz
      return 'Fizz' if is_fizz
      return 'Buzz' if is_buzz

      number.to_s
    when 2
      number.to_s
    when 3
      return 'FizzBuzz' if is_fizz && is_buzz

      number.to_s
    else
      raise '該当するタイプは存在しません'
    end
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
  end
end
...

まず、FizzBuzz#generate のメソッド呼び出しを インスタンス変数 type が参照するタイプクラスのメソッド FizzBuzzTypeXX#generate委譲 するように変更します。

...
  def generate(number)
    @type.generate(number)
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| @type.generate(n) }
  end
end
...
...
Started with run options --seed 49543


Progress: |=====================================================================================================|

Finished in 0.00925s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
06:34:27 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |======================================= 100 ========================================>| Time: 00:00:00

1 file inspected, no offenses detected
06:34:29 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |======================================= 100 =======================================>| Time: 00:00:00

0 files inspected, no offenses detecte
...

警告が消えました。しかもテストは壊れていないようです。実は FizzBuzz#generate メソッドはどこからも使われていないためテストも壊れることが無いのですがこれでは不要なメソッドになってしまうので 移譲の隠蔽 を実施して、ロジックを カプセル化 します。

委譲の隠蔽

オブジェクト指向について最初に教わる時、カプセル化とはフィールドを隠すことだと習うでしょう。しかし経験を積むにつれて、他にもカプセル化できるものがあることに気づきます。

— リファクタリング(第2版)

...
  def generate(number)
    @type.generate(number)
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end
...

テストもFizzBuzzインスタンス経由で実行するように修正しておきます。これですべての呼び出しが new メソッド経由となりテストコードに一貫性を取り戻すことが出来ました。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(1)
      end
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(2)
      end
...
    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(3)
      end
...
    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzz.new(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
...
08:32:17 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 32 / 32 LOC (100.0%) covered.
Started with run options --guard --seed 63863

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00564s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

08:32:18 - INFO - Inspecting Ruby code style of all files
 7/7 files |====================================== 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected
...

ポリモーフィズム の感覚がつかめないうちは FizzBuzz#generate のコードが一行になったのに既存のテストも壊れず動いていることが不思議に思うかもしれません。しかしコードとしてはFizzBuzzクラスの generate メソッドは任意のタイプクラスの generate メソッドを呼び出しているだけで処理の詳細は理解しなくても振る舞いを理解できる 抽象化 された読みやすいコードになりました。静的コード解析も可読性が高くシンプルなコードとみなしてくれているようです。さて、警告メッセージもなくなり、テストも壊れていないのでコミットしておきましょう。

$ git add .
$ git commit -m 'refactor: State/Strategyによるタイプコードの置き換え'

diag-852794d6dd3e17ad001905a500b520e3.png

継承

分割したタイプクラスのメソッドに重複する処理があるので 継承 を使ってリファクタリングしましょう。ここでは スーパークラスの抽出を適用します。

スーパークラスの抽出

似通った特性を持つ2つのクラスがある。

スーパークラスを作成して、共通の特性を移動する。

— 新装版 リファクタリング

スーパークラスの抽出

diag-852794d6dd3e17ad001905a500b520e3.png

まずは、タイプクラスのスーパークラスとなる FizzBuzzType クラスを作成して各タイプクラスに継承させます。

クラスベースのオブジェクト指向言語の多くはクラスの継承機能を有しています。クラスの継承とはあるクラスを元として、新しいクラスを定義することです。この時、継承元となるクラスを親クラスやスーパークラスと呼び、継承したクラスのことを子クラスやサブクラスと呼びます。

— かんたんRuby

Rubyの クラスの継承 は以下のように書きます。

class FizzBuzz
...
end

class FizzBuzzType; end

class FizzBuzzType01
...
...
class FizzBuzzType; end

class FizzBuzzType01 < FizzBuzzType
...
end

class FizzBuzzType02 < FizzBuzzType
...
end

class FizzBuzzType03 < FizzBuzzType
...
end

スーパークラス FizzBuzzType を定義して各サブクラスに継承させます。

08:42:24 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 43548

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00860s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

08:42:25 - INFO - Inspecting Ruby code style of all files
 7/7 files |====================================== 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected

diag-df749de89e01204bc0eab92419515a4c.png

次に is_fizz is_buzz 部分を共通メソッドとしてスーパークラスに定義して各タイプクラスで呼び出すように変更します。

...
class FizzBuzzType; end

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz
    return 'Fizz' if is_fizz
    return 'Buzz' if is_buzz

    number.to_s
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    number.to_s
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    is_fizz = number.modulo(3).zero?
    is_buzz = number.modulo(5).zero?

    return 'FizzBuzz' if is_fizz && is_buzz

    number.to_s
  end
end
...
class FizzBuzzType
  def is_fizz(number)
    number.modulo(3).zero?
  end

  def is_buzz(number)
    number.modulo(5).zero?
  end
end

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
    return 'Fizz' if is_fizz(number)
    return 'Buzz' if is_buzz(number)

    number.to_s
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    number.to_s
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if is_fizz(number) && is_buzz(number)

    number.to_s
  end
end
08:50:16 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 45685

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.01073s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

08:50:17 - INFO - Inspecting Ruby code style of all files
lib/fizz_buzz.rb:35:7: C: Naming/PredicateName: Rename is_fizz to fizz?.
  def is_fizz(number)
      ^^^^^^^
lib/fizz_buzz.rb:39:7: C: Naming/PredicateName: Rename is_buzz to buzz?.
  def is_buzz(number)
      ^^^^^^^
 7/7 files |====================================== 100 =======================================>| Time: 00:00:00

7 files inspected, 2 offenses detected

テストが壊れていないことが確認できたのでコミットしておきます。

$ git add .
$ git commit -m 'refactor: スーパークラスの抽出'

diag-119864cf3ef287a3cb00d3a5ae7f7768.png

メソッド名の変更

スーパークラスの抽出 を実施したところまた警告メッセージが表示されるようになりました。

08:50:19 - INFO - Inspecting Ruby code styl
e: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png lib/fizz_buzz.rb
lib/fizz_buzz.rb:35:7: C: Naming/PredicateName: Rename is_fizz to fizz?.
  def is_fizz(number)
      ^^^^^^^
lib/fizz_buzz.rb:39:7: C: Naming/PredicateName: Rename is_buzz to buzz?.
  def is_buzz(number)
      ^^^^^^^
 1/1 file |======================================= 100 =======================================>| Time: 00:00:00

1 file inspected, 2 offenses detected

Naming/PredicateName Rubyのネーミングとしてはよろしくないようなので指示に従って メソッド名の変更 を実施しましょう。

...
class FizzBuzzType
  def is_fizz(number)
    number.modulo(3).zero?
  end

  def is_buzz(number)
    number.modulo(5).zero?
  end
end

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if is_fizz(number) && is_buzz(number)
    return 'Fizz' if is_fizz(number)
    return 'Buzz' if is_buzz(number)

    number.to_s
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    number.to_s
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if is_fizz(number) && is_buzz(number)

    number.to_s
  end
end
...
class FizzBuzzType
  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if fizz?(number) && buzz?(number)
    return 'Fizz' if fizz?(number)
    return 'Buzz' if buzz?(number)

    number.to_s
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    number.to_s
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if fizz?(number) && buzz?(number)

    number.to_s
  end
end
Progress: |====================================================================================================|

Finished in 0.01144s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips
08:53:35 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |======================================= 100 =======================================>| Time: 00:00:00

1 file inspected, no offenses detected

作業としては難しくないのでミスタイプしないように(まあ、ミスタイプしてもテストが教えてくれますが・・・)変更してコミットしましょう。

$ git add .
$ git commit -m 'refactor: メソッド名の変更'

diag-f06a12d01484b03fa6f2f85b062a3cf0.png

メソッドの移動

FizzBuzz クラスの ファクトリメソッド ですが 特性の横恋慕 の臭いがするので メソッドの移動 を実施します。

特性の横恋慕

オブジェクト指向には、処理および処理に必要なデータを1つにまとめてしまうという重要な考え方があります。あるメソッドが、自分のクラスより他のクラスに興味を持つような場合には、古典的な誤りを犯しています。

— 新装版 リファクタリング

メソッドの移動

あるクラスでメソッドが定義されているが、現在または将来において、そのクラスの特性よりも他のクラスの特性の方が、そのメソッドを使ったり、そのメソッドから使われたりすることが多い。

同様の本体を持つ新たなメソッドを、それを最も多用するクラスに作成する。元のメソッドは、単純な委譲とするか、またはまるごと取り除く。

— 新装版 リファクタリング

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list

  def initialize(type)
    @type = FizzBuzz.create(type)
  end

  def self.create(type)
    case type
    when 1
      FizzBuzzType01.new
    when 2
      FizzBuzzType02.new
    when 3
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
  end

  def generate(number)
    @type.generate(number)
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end

class FizzBuzzType
  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end
...

クラスメソッド FizzBuzz.create をカット&ペーストして FizzBuzzType.create に移動します。 FizzBuzzコンストラクタ で呼び出している クラスメソッドFizzBuzzType.create に変更します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list

  def initialize(type)
    @type = FizzBuzzType.create(type)
  end

  def generate(number)
    @type.generate(number)
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end

class FizzBuzzType
  def self.create(type)
    case type
    when 1
      FizzBuzzType01.new
    when 2
      FizzBuzzType02.new
    when 3
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
  end

  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end
...
08:59:27 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 33 LOC (100.0%) covered.
Started with run options --guard --seed 19583

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00688s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

08:59:28 - INFO - Inspecting Ruby code style of all files
 7/7 files |====================================== 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected

テストが壊れていないことを確認したらコミットします。

$ git add .
$ git commit -m 'refactor: メソッドの移動'

diag-c24dbdafdc4766204e9b3fba9938dbba.png

値オブジェクト

diag-c24dbdafdc4766204e9b3fba9938dbba.png

オブジェクトによるプリミティブの置き換え

FizzBuzz クラスを インスタンス化 するには以下のように書きます。

fizz_buzz = FizzBuzz.new(1)

クラスとして定義された情報を元に具体的な値を伴ったオブジェクトを作成することをインスタンス化と呼び、生成されたオブジェクトのことをインスタンスと呼びます。

— かんたんRuby

コンストラクタ引数 に渡される 1 は何を表しているのでしょうか?もちろんタイプですが初めてこのコードを見る人にはわからないでしょう。このような整数、浮動小数点、文字列などの基本データ(プリミティブ)型の使い方からは 基本データ型への執着の臭いがします。 オブジェクトによるプリミティブの置き換え を実施してコードの意図を明確にしましょう。

基本データ型への執着

オブジェクト指向のメリットとして、基本データ型とそれより大きなクラスとの境界を取り除くということがあります。プログラミング言語の組み込み(built-in)型と区別できないような小さなクラスを自分で定義することが容易です。

— 新装版 リファクタリング

基本データ型への執着

興味深いことに、多くのプログラマは、対象としているドメインに役立つ、貨幣、座標、範囲などの基本的な型を導入するのを嫌がる傾向があります。

— リファクタリング(第2版)

オブジェクトによるデータ値の置き換え

追加のデータや振る舞いが必要なデータ項目がある。

そのデータ項目をオブジェクトに変える。

— 新装版 リファクタリング

オブジェクトによるプリミティブの置き換え

旧:オブジェクトによるデータ値の置き換え

旧:クラスによるタイプコードの置き換え

— リファクタリング(第2版)

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = FizzBuzzType.create(type)
  end
...
class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
  end
...

コンストラクタ で引き渡されるタイプは整数ではなくタイプクラスの インスタンス に変更します。

...

ERROR["test_1を渡したら文字列1を返す", #<Minitest::Reporters::Suite:0x00005654f32602c0 @name="数を文字列にして返す::タイプ3の場合::その他の場合">, 0.00241121300496161]
 test_1を渡したら文字列1を返す#数を文字列にして返す::タイプ3の場合::その他の場合 (0.00s)
Minitest::UnexpectedError:         NoMethodError: undefined method `generate' for 3:Integer
            /workspace/tdd_rb/lib/fizz_buzz.rb:12:in `generate'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:125:in `test_1を渡したら文字列1を返す'
...

テストが失敗しました。 コンストラクタ の引数を整数からタイプクラスの インスタンス に変更します。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(1)
      end
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(1)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(2)
      end
...
    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(3)
      end
...
    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzz.new(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end

ここで注意するのは それ以外のタイプの場合 ですが例外を投げなくなります。静的に型付けされた言語なら型チェックエラーになるのですがRubyは動的に型付けされる言語のため FizzBuzz#generate メソッド実行までエラーになりません。そこで例外を投げる FizzBuzzType#create メソッドに変更しておきます。

class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
      end
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end
...
    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType02.new)
      end
...
    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType03.new)
      end
...
    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzzType.create(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end

それ以外のタイプの場合は ファクトリメソッド 経由でないと 例外 を出さなくなるので注意してください。

09:09:40 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 30 / 33 LOC (90.91%) covered.
Started with run options --guard --seed 17452

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00687s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

初めてコードを見る人でもテストコードを見ればコードの意図が読み取れるようになりましたのでコミットします。

$ git add .
$ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'

diag-c24dbdafdc4766204e9b3fba9938dbba.png

マジックナンバーの置き換え

まだプリミティグ型を使っている部分があります。ここは マジックナンバーの置き換え を実施して可読性を上げておきましょう。

...
class FizzBuzzType
  def self.create(type)
    case type
    when 1
      FizzBuzzType01.new
    when 2
      FizzBuzzType02.new
    when 3
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
 end
...
...
class FizzBuzzType
  TYPE_01 = 1
  TYPE_02 = 2
  TYPE_03 = 3

  def self.create(type)
    case type
    when FizzBuzzType::TYPE_01
      FizzBuzzType01.new
    when FizzBuzzType::TYPE_02
      FizzBuzzType02.new
    when FizzBuzzType::TYPE_03
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
  end
...
09:18:51 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 33 / 36 LOC (91.67%) covered.
Started with run options --guard --seed 41124

  32/32: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00909s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

テストは壊れていないのでコミットします。

$ git add .
$ git commit -m 'refactor: マジックナンバーの置き換え'

diag-be7c345eb3b4de2cc3b4530da0d96f5d.png

オブジェクトによるプリミティブの置き換え

次に 基本データ型への執着 の臭いがする箇所として FizzBuzz#generate メソッドが返すFizzBuzzの値が文字型である点です。文字列の代わりに 値オブジェクト FizzBuzzValue クラスを定義します。

値の種類ごとに専用の型を用意するとコードが安定し、コードの意図が明確になります。このように、値を扱うための専用クラスを作るやり方を値オブジェクト(ValueObject)と呼びます。

— 現場で役立つシステム設計の原則

...
class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    @number = number
    @value = value
  end

  def to_s
    "#{@number}:#{@value}"
  end

  def ==(other)
    @number == other.number && @value == other.value
  end

  alias eql? ==
end

各タイプクラスの generate メソッドが文字列のプリミティブ型を返しているので 値オブジェクト FizzBuzzValue を返すように変更します。

...
class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if fizz?(number) && buzz?(number)
    return 'Fizz' if fizz?(number)
    return 'Buzz' if buzz?(number)

    number.to_s
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    number.to_s
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return 'FizzBuzz' if fizz?(number) && buzz?(number)

    number.to_s
  end
end
...
...
class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
    return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
    return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, number.to_s)
  end
end

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end
...
...
 FAIL["test_配列の2番目は文字列のFizzを返す", #<Minitest::Reporters::Suite:0x000055feccc65ab8 @name="数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.012104410998290405]
 test_配列の2番目は文字列のFizzを返す#数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
        --- expected
        +++ actual
        @@ -1 +1 @@
        -"Fizz"
        +#<FizzBuzzValue:0xXXXXXX @number=3, @value="Fizz">
        /workspace/tdd_rb/test/fizz_buzz_test.rb:57:in `test_配列の2番目は文字列のFizzを返す'
...

変更によりテストが失敗しました。エラー内容を見てみると文字列からオブジェクトを返しているためアサーションが失敗しているようです。ここは、値オブジェクトアクセッサメソッド を経由して取得した値をアサーション対象に変更しましょう。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列Fizzを返す
          assert_equal 'Fizz', @fizzbuzz.generate(3).value
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列Buzzを返す
          assert_equal 'Buzz', @fizzbuzz.generate(5).value
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.generate(15).value
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1).value
        end
      end

      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end

        def test_配列の初めは文字列の1を返す
          assert_equal '1', @result.first.value
        end

        def test_配列の最後は文字列のBuzzを返す
          assert_equal 'Buzz', @result.last.value
        end

        def test_配列の2番目は文字列のFizzを返す
          assert_equal 'Fizz', @result[2].value
        end

        def test_配列の4番目は文字列のBuzzを返す
          assert_equal 'Buzz', @result[4].value
        end

        def test_配列の14番目は文字列のFizzBuzzを返す
          assert_equal 'FizzBuzz', @result[14].value
        end
      end
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType02.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3).value
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5).value
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.generate(15).value
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1).value
        end
      end
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzz.new(FizzBuzzType03.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.generate(3).value
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.generate(5).value
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.generate(15).value
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.generate(1).value
        end
      end
    end

    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzzType.create(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
08:49:28 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 41 / 46 LOC (89.13%) covered.
Started with run options --guard --seed 25972

  32/32: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00619s
32 tests, 35 assertions, 0 failures, 0 errors, 0 skips

08:49:29 - INFO - Inspecting Ruby code style of all files
 7/7 files |======================================= 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected
08:49:30 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |======================================= 100 =======================================>| Time: 00:00:00

0 files inspected, no offenses detected

テストコードをそれほど変更することなく 値オブジェクト を返すリファクタリングが出来ました。コミットしておきましょう。

$ git add .
$ git commit -m 'refactor: オブジェクトによるプリミティブの置き換え'

diag-719d1727313ce1fe35a0a3eaeca9a624.png

学習用テスト

値オブジェクト の理解を深めるために 学習用テスト を追加します。

...
  describe 'FizzBuzzValue' do
    def setup
      @fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    end

    def test_同じで値である
      value1 = @fizzbuzz.generate(1)
      value2 = @fizzbuzz.generate(1)

      assert value1.eql?(value2)
    end

    def test_to_stringメソッド
      value = @fizzbuzz.generate(3)

      assert_equal '3:Fizz', value.to_s
    end
  end
end
$ git add .
$ git commit -m 'test: 学習用テスト'

ファーストクラスコレクション

diag-719d1727313ce1fe35a0a3eaeca9a624.png

コレクションのカプセル化

値オブジェクト を扱うFizzBuzzリストですが コレクションのカプセル化 を適用して ファーストクラスコレクション オブジェクトを追加しましょう。

コレクションのカプセル化

メソッドがコレクションを返している。

読み取り専用のビューを返して、追加と削除のメソッドを提供する。

— 新装版 リファクタリング

このように、コレクション型のデータとロジックを特別扱いにして、コレクションを1つだけ持つ専用クラスを作るやり方をコレクションオブジェクトあるいはファーストクラスコレクションと呼びます。

— 現場で役立つシステム設計の原則

まず、 ファーストクラスコレクション クラスを追加します。

...
class FizzBuzzList
  attr_reader :value

  def initialize(list)
    @value = list
  end

  def to_s
    @value.to_s
  end

  def add(value)
    FizzBuzzList.new(@value + value)
  end
end

FizzBuzz配列を ファーストクラスコレクション から取得するように変更します。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
  end
...
  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = (1..MAX_NUMBER).map { |n| generate(n) }
  end
end
class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
    @list = FizzBuzzList.new([])
  end

...
  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = @list.add((1..MAX_NUMBER).map { |n| @type.generate(n) })
  end
end

なんだか紛らわしい書き方になってしましました。配列を作るのに以前の配列を元に新しい配列を作るとか回りくどいことをしないで既存の配列を使い回せばいいじゃんと思うかもしれませんが 変更可能なデータ はバグの原因となる傾向があります。変更可能な ミュータブル な変数ではなく 永続的に変更されない イミュータブル な変数を使うように心がけましょう。

変更可能なデータ

データの変更はしばし予期せぬ結果結果や、厄介なバグを引き起こします。他で違う値を期待していることに気づかないままに、ソフトウェアのある箇所で値を変更してしまえば、それだけで動かなくなってしまいます。これは値が変わる条件がまれにしかない場合、特に見つけにくいバグとなります。そのため、ソフトウェア開発の一つの潮流である関数型プログラミングは、データは不変であるべきで、更新時は常に元にデータ構造のコピーを返すようにし、元データには手を触れないという思想に基づいています。

— リファクタリング(第2版)

値オブジェクトと同じようにコレクションオブジェクトも、できるだけ「不変」スタイルで設計します。そのほうがプログラムが安定します。

— 現場で役立つシステム設計の原則

...
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x00005561331b7940 @name="FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.011710233025951311]
 test_配列の14番目は文字列のFizzBuzzを返す#FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError:         NoMethodError: undefined method `[]' for #<FizzBuzzList:0x0000556133198ba8 @value=[]>
            /workspace/tdd_rb/test/fizz_buzz_test.rb:66:in `test_配列の14番目は文字列のFizzBuzzを返す'
...

ファーストクラスコレクション 経由で取得するようになったので アクセッサメソッド を変更する必要があります。

class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
  end
...
class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :list
  attr_reader :type

  def initialize(type)
    @type = type
    @list = FizzBuzzList.new([])
  end
...
class FizzBuzz
  MAX_NUMBER = 100
  attr_reader :type

  def list
    @list.value
  end

  def initialize(type)
    @type = type
    @list = FizzBuzzList.new([])
  end
....
09:12:46 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 53 / 56 LOC (94.64%) covered.
Started with run options --guard --seed 61051

  34/34: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.01285s
34 tests, 37 assertions, 0 failures, 0 errors, 0 skips

09:12:47 - INFO - Inspecting Ruby code style of all files
 7/7 files |======================================= 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected
09:12:48 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |======================================= 100 =======================================>| Time: 00:00:00

0 files inspected, no offenses detected

テストが直ったのでコミットしておきます。

$ git add .
$ git commit -m 'refactor: コレクションのカプセル化'

diag-f753577a92fab80607a2e63cf53e8389.png

学習用テスト

ファーストクラスコレクション を理解するため 学習用テスト を追加しておきましょう。

...
  describe 'FizzBuzzValueList' do
    def setup
      @fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    end

    def test_新しいインスタンスが作られる
      list1 = @fizzbuzz.generate_list
      list2 = list1.add(list1.value)

      assert_equal 100, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
end
$ git add .
$ git commit -m 'refactor: 学習用テスト'

オブジェクト指向設計

diag-f753577a92fab80607a2e63cf53e8389.png

値オブジェクト 及び ファーストクラスコレクション の適用で 基本データ型への執着 の臭いはなくなりました。今度は設計の観点から全体を眺めてみましょう。ここで気になるのが FizzBuzz クラスです。このクラスは他のクラスと比べてやることが多いようです。このようなクラスは 単一責任の原則 に違反している可能性があります。そこで デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え 適用してみようと思います。

SRP:
単一責任の原則

かつて単一責任の原則(SRP)は、以下のように語られてきた。

モジュールを変更する理由はたったひとつだけであるべきである

ソフトウェアシステムに手を加えるのは、ユーザーやステークホルダーを満足させるためだ。この「ユーザーやステークホルダー」こそが、単一責任の原則(SRP)を指す「変更する理由」である。つまり、この原則は以下のように言い換えられる。

モジュールはたったひとりのユーザーやステークホルダーに対して責任を負うべきである。

残念ながら「たったひとりのユーザーやステークホルダー」という表現は適切ではない。複数のユーザーやステークホルダーがシステムを同じように変更したいと考えることもある。ここでは、変更を望む人たちをひとまとめにしたグループとして扱いたい。このグループのことをアクターと呼ぶことにしよう。
これを踏まえると、最終的な単一責任の原則(SRP)は以下のようになる。

モジュールはたったひとつのアクターに対して責任を負うべきである。

さて、ここでいう「モジュール」とは何のことだろう?端的に言えば、モジュールとはソースファイルのことである。たいていの場合は、この定義で問題ないだろう。だが、ソースファイル以外のところにコードを格納する言語や開発環境も存在する。そのような場合の「モジュール」は、いくつかの関数やデータをまとめた凝集性のあるものだと考えよう。

「凝集性のある」という言葉が単一責任の原則(SRP)を匂わせる。凝集性が、ひとつのアクターに対する責務を負うコードをまとめるフォースとなる。

— Clean Architecture 達人に学ぶソフトウェアの構造と設計

Commandパターン

処理の呼び出しが、シンプルなメソッド呼び出しよりも複雑になってきたときはどうすればよいだろうか---処理のためのオブジェクトを作成し、それを起動するようにしよう。

— テスト駆動開発

メソッドオブジェクトによるメソッドの置き換え

長いメソッドで、「メソッドの抽出」を適用できないようなローカル変数の使い方をしている。

メソッド自身をオブジェクトとし、すべてのローカル変数をそのオブジェクトのフィールドとする。そうすれば、そのメソッドを同じオブジェクト中のメソッド群に分解できる。

— 新装版 リファクタリング

メソッドオブジェクトによるメソッドの置き換え

まず、値オブジェクトFizzBuzzValue を返す責務だけを持った メソッドオブジェクト を抽出します。Rubyのような動的言語では必要が無いのですが Commandパターン の説明のため インターフェイス にあたるスーパークラスを継承した メソッドオブジェクト を定義します。

...
class FizzBuzzCommand
  def execute; end
end

class FizzBuzzValueCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    @type.generate(number).value
  end
end

テストコードを FizzBuzzValueCommand を呼び出すように変更します。

...
class FizzBuzzTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列Fizzを返す
          assert_equal 'Fizz', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列Buzzを返す
          assert_equal 'Buzz', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end

      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
          fizzbuzz.generate_list
          @result = fizzbuzz.list
        end

        def test_配列の初めは文字列の1を返す
          assert_equal '1', @result.first.value
        end

        def test_配列の最後は文字列のBuzzを返す
          assert_equal 'Buzz', @result.last.value
        end

        def test_配列の2番目は文字列のFizzを返す
          assert_equal 'Fizz', @result[2].value
        end

        def test_配列の4番目は文字列のBuzzを返す
          assert_equal 'Buzz', @result[4].value
        end

        def test_配列の14番目は文字列のFizzBuzzを返す
          assert_equal 'FizzBuzz', @result[14].value
        end
      end
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzzType.create(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
...
09:56:19 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 60 / 63 LOC (95.24%) covered.
Started with run options --guard --seed 27353

  35/35: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00692s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips

09:56:20 - INFO - Inspecting Ruby code style of all files
 7/7 files |======================================= 100 =======================================>| Time: 00:00:00

7 files inspected, no offenses detected
09:56:21 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/loading_background.png coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/border.png
 0/0 files |======================================= 100 =======================================>| Time: 00:00:00
 ...

FizzBuzzValueCommand の抽出ができたのでコミットしておきます。

$ git add .
$ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'

diag-ee5628564b01a6264bda537c84b0458b.png

メソッドオブジェクトによるメソッドの置き換え

続いて、ファーストクラスコレクション を扱う FizzBuzzList を返す責務だけを持った メソッドオブジェクト を抽出します。

...
class FizzBuzzListCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
  end
end

テストコードを FizzBuzzListCommand 経由から実行するように変更します

...
        describe '1から100までのFizzBuzzの配列を返す' do
          def setup
            fizzbuzz = FizzBuzz.new(FizzBuzzType01.new)
            fizzbuzz.generate_list
            @result = fizzbuzz.list
          end
...
...
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
          @result = fizzbuzz.execute(100)
        end
...
01:27:54 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 61 / 66 LOC (92.42%) covered.
Started with run options --guard --seed 62253

  35/35: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00652s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips

テストが通ったのでコミットします。

$ git add .
$ git commit -m 'refactor: メソッドオブジェクトによるメソッドの置き換え'

diag-4a094b5dcd7f922c01a8ad5b39f6839d.png

デッドコードの削除

FizzBuzz クラスの責務は各 メソッドオブジェクト が実行するようになったので削除しましょう。

class FizzBuzz
  MAX_NUMBER = 100

  def initialize(type)
    @type = type
    @list = FizzBuzzList.new([])
  end

  def list
    @list.value
  end

  def generate(number)
    @type.generate(number)
  end

  def generate_list
    # 1から最大値までのFizzBuzz配列を1発で作る
    @list = @list.add((1..MAX_NUMBER).map { |n| @type.generate(n) })
  end
end

class FizzBuzzType
...
class FizzBuzzType
...
...
ERROR["test_同じで値である", #<Minitest::Reporters::Suite:0x0000562fd34f7848 @name="FizzBuzzValue">, 0.008059715997660533]
 test_同じで値である#FizzBuzzValue (0.01s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::FizzBuzz
            /workspace/tdd_rb/test/fizz_buzz_test.rb:225:in `setup'

ERROR["test_to_stringメソッド", #<Minitest::Reporters::Suite:0x0000562fd37694a0 @name="FizzBuzzValue">, 0.01728590900893323]
 test_to_stringメソッド#FizzBuzzValue (0.02s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::FizzBuzz
            /workspace/tdd_rb/test/fizz_buzz_test.rb:225:in `setup'

ERROR["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000562fd39be070 @name="FizzBuzzValueList">, 0.028008958004647866]
 test_新しいインスタンスが作られる#FizzBuzzValueList (0.03s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::FizzBuzz
            /workspace/tdd_rb/test/fizz_buzz_test.rb:244:in `setup'

========================================|

Finished in 0.03539s
35 tests, 35 assertions, 0 failures, 3 errors, 0 skips
...

テストが失敗しました。これは 学習用テストFizzBuzz クラスを使っている箇所があるからですね。 メソッドオブジェクト 呼び出しに変更しておきましょう。

  describe 'FizzBuzzValue' do
    def setup
      @fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    end

    def test_同じで値である
      value1 = @fizzbuzz.generate(1)
      value2 = @fizzbuzz.generate(1)

      assert value1.eql?(value2)
    end

    def test_to_stringメソッド
      value = @fizzbuzz.generate(3)

      assert_equal '3:Fizz', value.to_s
    end
  end

  describe 'FizzBuzzValueList' do
    def setup
      @fizzbuzz = FizzBuzz.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    end

    def test_新しいインスタンスが作られる
      list1 = @fizzbuzz.generate_list
      list2 = list1.add(list1.value)

      assert_equal 100, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
end
...
  describe 'FizzBuzzValue' do
    def test_同じで値である
      value1 = FizzBuzzValue.new(1, '1')
      value2 = FizzBuzzValue.new(1, '1')

      assert value1.eql?(value2)
    end

    def test_to_stringメソッド
      value = FizzBuzzValue.new(3, 'Fizz')

      assert_equal '3:Fizz', value.to_s
    end
  end

  describe 'FizzBuzzValueList' do
    def test_新しいインスタンスが作られる
      command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
      array = command.execute(100)
      list1 = FizzBuzzList.new(array)
      list2 = list1.add(array)

      assert_equal 100, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
end
...
01:35:22 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 50 / 56 LOC (89.29%) covered.
Started with run options --guard --seed 10411

  35/35: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00704s
35 tests, 39 assertions, 0 failures, 0 errors, 0 skips
...

不要なコードを残しておくとメンテナンスの時に削除していいのかわからなくなり可読性を落とし原因となります。削除できる時に削除しておきましょう。後で必要になったとしてもバージョン管理システムを使えば問題ありません。ということでコミットします。

デッドコードの削除

コードが使用されなくなったら削除すべきです。そのコードが将来必要になるかもしれないなどという心配はしません。必要になったらいつでも、バージョン管理システムから再び掘り起こせるからです。

(中略)

デッドコードのコメントアウトは、かつては一般的な習慣でした。それは、バージョン管理システムが広く使用される以前の時代や、使いづらかった時代には有用でした。現在では、とても小さなコードベースでもバージョン管理システムに置けるため、もはや必要のない習慣です。

— リファクタリング(第2版)

$ git add .
$ git commit -m 'refactor: デッドコードの削除'

diag-55cb49d0a54190528d027b3768e4aa29.png

デザインパターン

メソッドオブジェクトによるメソッドの置き換え リファクタリングの結果として Commandパターン という デザインパターン を適用しました。実はこれまでにも オブジェクトによるプリミティブの置き換え では Value Objectパターンポリモーフィズムによる条件記述の置き換え では Factory Methodパターン をそして、 委譲の隠蔽 の実施による State/Strategyによるタイプコードの置き換え では Strategyパターン を適用しています。

Command パターン

diag-3f9e53f62bd2f1b3bfe0f476521170ca.png

Value Objectパターン

広く共有されるものの、同一インスタンスであることはさほど重要でないオブジェクトを設計するにはどうしたらよいだろうか----オブジェクト作成時に状態を設定したら、その後決して変えないようにする。オブジェクトへの操作は必ず新しいオブジェクトを返すようにしよう。

— テスト駆動開発

Factory Methodパターン

オブジェクト作成に柔軟性をもたせたいときは、どうすればよいだろうか---単にコンストラクタで作るのではなく、メソッドを使ってオブジェクトを作成しよう。

— テスト駆動開発

Strategy パターン

diag-4969f773bcc5408d3afec24f14c006d3.png

作成したコードはパターンと完全に一致しているわけではありませんし、Rubyのような動的言語ではもっと簡単な実現方法もありますがここでは先人の考えた設計パターンというものがありオブジェクト指向設計の イデオム として使えること。そしてテスト駆動開発では一般的な設計アプローチとは異なる形で導かれているということくらいを頭に残しておけば結構です。どのパターンをいつ適用するかはリファクタリングを繰り返しているうちに思いつくようになってきます(多分)。

ただ、書籍『デザインパターン』(通称Gof本)の大ヒットは、その反面、それらパターンを表現する方法の多様性を奪ってしまった。Gof本には、設計をフェーズとして扱うという暗黙の前提があるように見受けられる。つまり、リファクタリングを設計行為として捉えていない。TDDにおける設計は、デザインパターンを少しだけ違う側面から捉えなければならない。

— テスト駆動開発

あと、設計の観点から今回 単一責任の原則 に従って FizzBuzz クラスを メソッドオブジェクト に分割して削除しました。

diag-51044325ad9691ebfe6e60879f6c95e7.png

もし、新しい処理を追加する必要が発生した場合はどうしましょうか? FizzBuzzCommand インターフェイスを実装した メソッドオブジェクト を追加しましょう。

diag-ea3196c5b1015bd1c10121bc92095fcb.png

もし、新しいタイプが必要になったらどうしましょうか? FizzBuzzType クラスを継承した新しいタイプクラスを追加しましょう。

diag-479f7874fa6e2b242ebd5a2f54730e37.png

このように既存のコードを変更することなく振る舞いを変更できるので オープン・クローズドの原則 を満たした設計といえます。

OCP:オープン・クローズドの原則

「オープン・クローズドの原則(OCP)」は、1988年にBertrand Maeerが提唱した以下のような原則だ。

ソフトウェアの構成要素は拡張に対しては開いていて、修正に対しては閉じていなければならない。
            『アジャイルソフトウェア開発の奥義 第2版』(SBクリエイティブ)より引用

言い換えれば、ソフトウェアの振る舞いは、既存の成果物を変更せず拡張できるようにすべきである、ということだ。

— Clean Architecture 達人に学ぶソフトウェアの構造と設計

例外

diag-55cb49d0a54190528d027b3768e4aa29.png

ここまでは、正常系をリファクタリングして設計を改善してきました。しかし、アプリケーションは例外系も考慮する必要があります。続いて、アサーションの導入 を適用した例外系のリファクタリングに取り組むとしましょう。

アサーションの導入

前提を明示するためのすぐれたテクニックとして、アサーションを記述する方法があります。

— リファクタリング(第2版)

アサーションの導入

まず、 メソッドオブジェクトFizzBuzzValueCommand にマイナスの値が渡された場合の振る舞いをどうするか考えます。ここでは正の値のみ許可する振る舞いにしたいので以下のテストコードを追加します。

class FizzBuzzTest < Minitest::Test
...
  describe '例外ケース' do
    def test_値は正の値のみ許可する
      assert_raises Assertions::AssertionFailedError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end
    end
  end
end
...
ERROR["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x00007fadf30c45d8 @name="例外ケース">, 0.006546000000525964]
 test_値は正の値のみ許可する#例外ケース (0.01s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::Assertions
            /Users/k2works/Projects/sandbox/tdd_rb/test/fizz_buzz_test.rb:249:in `test_値は正の値のみ許可する'

  36/36: [=========================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.03159s
36 tests, 39 assertions, 0 failures, 1 errors, 0 skips
...

テストを通すためアサーションモジュールを追加します。Rubyでは モジュール を使います。

モジュールはクラスと非常によく似ていますが、以下の二点が異なります。

  • モジュールはインスタンス化できない

  • 本章後半可能なのは include や extend が可能なのはモジュールだけ

それ以外のクラスメソッドや定数の定義などはクラスと同じように定義することができます。

— かんたんRuby

...
module Assertions
  class AssertionFailedError < StandardError; end

  def assert(&condition)
    raise AssertionFailedError, 'Assertion Failed' unless condition.call
  end
end

class FizzBuzzValue
...

アサーションモジュールを追加してエラーはなくなりましたがテストは失敗したままです。

...
 FAIL["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x00007fdcfc0c2548 @name="例外ケース">, 0.005800000000817818]
 test_値は正の値のみ許可する#例外ケース (0.01s)
        Assertions::AssertionFailedError expected but nothing was raised.
        /Users/k2works/Projects/sandbox/tdd_rb/test/fizz_buzz_test.rb:249:in `test_値は正の値のみ許可する'

============================================================================================================|

Finished in 0.00621s
36 tests, 40 assertions, 1 failures, 0 errors, 0 skips
...

追加したモジュールを FizzBuzzValue クラスをに Mix-in します。そして、コンストラクタ 実行時に数値は0以上であるアサーションを追加します。

Rubyでの継承は一種類、単一継承しか実行できませんが、複数のクラスを継承する多重継承の代わりにMix-inというメソッドの共有方法を提供します。

— かんたんRuby

class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    @number = number
    @value = value
  end
...
end
class FizzBuzzValue
  include Assertions
  attr_reader :number, :value

  def initialize(number, value)
    assert { number >= 0 }
    @number = number
    @value = value
  end
...
end
...
Started with run options --seed 37354


Progress: |====================================================================================================|

Finished in 0.01433s
36 tests, 40 assertions, 0 failures, 0 errors, 0 skips
...

アサーションが機能するようになりました、コミットしておきます。

$ git add .
$ git commit -m 'refactor: アサーションの導入'

diag-31809a5e0bf909bd8ffd6bf80e82857a.png

次は、メソッドオブジェクトFizzBuzzListCommand の実行時に100件以上指定された場合の振る舞いをどうするか考えます。ここでは100までを許可する振る舞いにします。

...
  describe '例外ケース' do
    def test_値は正の値のみ許可する
      assert_raises Assertions::AssertionFailedError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end
    end

    def test_100より多い数を許可しない
      assert_raises Assertions::AssertionFailedError do
        FizzBuzzListCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(101)
      end
    end
  end
end

FizzBuzzList にアサーションモジュールを Mix-in します。コンストラクタ 実行時に配列のサイズは100までというアサーションを追加します。

...
class FizzBuzzList
  include Assertions
  attr_reader :value

  def initialize(list)
    assert { list.count <= 100 }
    @value = list
  end
...
...
ERROR["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x00005558ca6e8e80 @name="FizzBuzzValueList">, 0.010412617004476488]
 test_新しいインスタンスが作られる#FizzBuzzValueList (0.01s)
Minitest::UnexpectedError:         Assertions::AssertionFailedError: Assertion Failed
            /workspace/tdd_rb/lib/fizz_buzz.rb:58:in `assert'
            /workspace/tdd_rb/lib/fizz_buzz.rb:88:in `initialize'
            /workspace/tdd_rb/lib/fizz_buzz.rb:97:in `new'
            /workspace/tdd_rb/lib/fizz_buzz.rb:97:in `add'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:259:in `test_新しいインスタンスが作られる'

====================================================================================================|

Finished in 0.01238s
36 tests, 38 assertions, 0 failures, 1 errors, 0 skips
...

追加したテストはパスするようになりましたが既存のテストコードでエラーが出るようになりました。該当するテストコードを見たところ100件より多い 学習用テストファーストクラスコレクション を作ろうとしたため AssertionFailedError を発生させたようです。テストコードを修正しておきましょう。

...
  describe 'FizzBuzzValueList' do
    def test_新しいインスタンスが作られる
      command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
      array = command.execute(100)
      list1 = FizzBuzzList.new(array)
      list2 = list1.add(array)

      assert_equal 100, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
...

最初は50件作るように変更します。

...
  describe 'FizzBuzzValueList' do
    def test_新しいインスタンスが作られる
      command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
      array = command.execute(50)
      list1 = FizzBuzzList.new(array)
      list2 = list1.add(array)

      assert_equal 100, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
...

アサーションエラーはなくなりましたが期待した値と違うと指摘されています。テストコードのアサーションを修正します。

 FAIL["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000556b5137c780 @name="FizzBuzzValueList">, 0.003735148988198489]
 test_新しいインスタンスが作られる#FizzBuzzValueList (0.00s)
        Expected: 100
          Actual: 50
        /workspace/tdd_rb/test/fizz_buzz_test.rb:261:in `test_新しいインスタンスが作られる'

  36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00837s
36 tests, 39 assertions, 1 failures, 0 errors, 0 skips
...
  describe 'FizzBuzzValueList' do
    def test_新しいインスタンスが作られる
      command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
      array = command.execute(50)
      list1 = FizzBuzzList.new(array)
      list2 = list1.add(array)

      assert_equal 50, list1.value.count
      assert_equal 200, list2.value.count
    end
  end
...

2つ目のアサーションに引っかかってしまいました。こちらも修正します。

 FAIL["test_新しいインスタンスが作られる", #<Minitest::Reporters::Suite:0x0000563a0c4fc2b0 @name="FizzBuzzValueList">, 0.005684088013367727]
 test_新しいインスタンスが作られる#FizzBuzzValueList (0.01s)
        Expected: 200
          Actual: 100
        /workspace/tdd_rb/test/fizz_buzz_test.rb:262:in `test_新しいインスタンスが作られる'

  36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00809s
36 tests, 40 assertions, 1 failures, 0 errors, 0 skips
...
  describe 'FizzBuzzValueList' do
    def test_新しいインスタンスが作られる
      command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
      array = command.execute(50)
      list1 = FizzBuzzList.new(array)
      list2 = list1.add(array)

      assert_equal 50, list1.value.count
      assert_equal 100, list2.value.count
    end
  end
...
...
01:58:57 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 61 / 64 LOC (95.31%) covered.
Started with run options --guard --seed 44956

  36/36: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00717s
36 tests, 40 assertions, 0 failures, 0 errors, 0 skips
...

仕様変更による反映が出来たのでコミットしましょう。

$ git add .
$ git commit -m 'refactor: アサーションの導入'

diag-6390574a6b0b9b04721636e71b66aea3.png

アサーションの導入 とは別のアプローチとして 例外 を返す方法もあります。 例外によるエラーコードの置き換え を適用してアサーションモジュールを削除しましょう。

例外によるエラーコードの置き換え

エラーを示す特別なコードをメソッドがリターンしている。

代わりに例外を発生させる。

— 新装版 リファクタリング

例外によるエラーコードの置き換え

アサーションモジュールを削除してアサーション部分を 例外 に変更します。

...
module Assertions
  class AssertionFailedError < StandardError; end

  def assert(&condition)
    raise AssertionFailedError, 'Assertion Failed' unless condition.call
  end
end

class FizzBuzzValue
  include Assertions
  attr_reader :number, :value

  def initialize(number, value)
    assert { number >= 0 }
    @number = number
    @value = value
  end
...
end

class FizzBuzzList
  include Assertions
  attr_reader :value

  def initialize(list)
    assert { list.count <= 100 }
    @value = list
  end
...
end
...
...
class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    raise '正の値のみ有効です' if number < 0

    @number = number
    @value = value
  end
...
end

class FizzBuzzList
  attr_reader :value

  def initialize(list)
    raise '上限は100件までです' if list.count > 100

    @value = list
  end
...
end
...
ERROR["test_値は正の値のみ許可する", #<Minitest::Reporters::Suite:0x000055d30f0b8a50 @name="FizzBuzz::数を文字列にして返す::例外ケース">, 0.004186890990240499]
 test_値は正の値のみ許可する#FizzBuzz::数を文字列にして返す::例外ケース (0.00s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::Assertions
            /workspace/tdd_rb/test/fizz_buzz_test.rb:143:in `test_値は正の値のみ許可する'

ERROR["test_100より多い数を許可しない", #<Minitest::Reporters::Suite:0x000055d30f114210 @name="FizzBuzz::数を文字列にして返す::例外ケース">, 0.008254560001660138]
 test_100より多い数を許可しない#FizzBuzz::数を文字列にして返す::例外ケース (0.01s)
Minitest::UnexpectedError:         NameError: uninitialized constant FizzBuzzTest::Assertions
            /workspace/tdd_rb/test/fizz_buzz_test.rb:151:in `test_100より多い数を許可しない'

  37/37: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.01731s
37 tests, 39 assertions, 0 failures, 2 errors, 0 skips
...

アサーションモジュールを削除したのでエラーが発生しています。テストコードを修正しましょう。

...
  describe '例外ケース' do
    def test_値は正の値のみ許可する
      assert_raises Assertions::AssertionFailedError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end
    end

    def test_100より多い数を許可しない
      assert_raises Assertions::AssertionFailedError do
        FizzBuzzListCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(101)
      end
    end
  end
end
...
  describe '例外ケース' do
    def test_値は正の値のみ許可する
      e = assert_raises RuntimeError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end

      assert_equal '正の値のみ有効です', e.message
    end

    def test_100より多い数を許可しない
      e = assert_raises RuntimeError do
        FizzBuzzListCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(101)
      end

      assert_equal '上限は100件までです', e.message
    end
  end
end
...
02:13:46 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 55 / 58 LOC (94.83%) covered.
Started with run options --guard --seed 55179

  37/37: [=================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00738s
37 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...

再びテストが通るようになったのでコミットしておきます。

$ git add .
$ git commit -m 'refactor:  例外によるエラーコードの置き換え'

diag-cd1dbf997043a35c9bb55b407c0a2af9.png

アルゴリズムの置き換え

02:13:46 - INFO - Inspecting Ruby code style: test/fizz_buzz_test.rb lib/fizz_buzz.rb
lib/fizz_buzz.rb:58:26: C: Style/NumericPredicate: Use number.negative? instead of number < 0.
    raise '正の値のみ有効です' if number < 0
                         ^^^^^^^^^^
 2/2 files |====================================== 100 =======================================>| Time: 00:00:00

2 files inspected, 1 offense detected

テストは通りますが警告が表示されるようになりました。 Style/NumericPredicate: Use number.negative? instead of number < 0. とのことなので アルゴリズムの置き換え を適用しておきましょう。

アルゴリズムの取り替え

アルゴリズムをよりわかりやすいものに置き換えたい

メソッドの本体を新たなアルゴリズムで置き換える。

— 新装版 リファクタリング

...
class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    raise '正の値のみ有効です' if number < 0
...
...

class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    raise '正の値のみ有効です' if number.negative?
...
02:18:31 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |======================================= 100 =======================================>| Time: 00:00:00

1 file inspected, no offenses detected

警告が消えたのでコミットします。

$ git add .
$ git commit -m 'refactor: アルゴリズムの置き換え'

マジックナンバーの置き換え

件数に リテラル を使っています。ここは マジックナンバーの置き換え を適用するべきですね。

シンボリック定数によるマジックナンバーの置き換え

特別な意味を持った数字のリテラルがある。

定数を作り、それにふさわしい名前をつけて、そのリテラルを置き換える。

— 新装版 リファクタリング

...
class FizzBuzzList
  attr_reader :value

  def initialize(list)
    raise '上限は100件までです' if list.count > 100

    @value = list
  end
...

式展開 を使ってメッセージ内容も定数から参照するようにしましょう。

式展開

式展開とは、「#{}」の書式で文字列中に何らかの変数や式を埋め込むことが可能な機能です。これは、ダブルクオートを使用した場合のみの機能です。

— かんたんRuby

class FizzBuzzList
  MAX_COUNT = 100
  attr_reader :value

  def initialize(list)
    raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT

    @value = list
  end
...

テストは壊れていないようですが MAX_COUNT を変更したらテストが失敗するか確認しておきましょう。

class FizzBuzzList
  MAX_COUNT = 10
...
...
ERROR["test_配列の14番目は文字列のFizzBuzzを返す", #<Minitest::Reporters::Suite:0x000055942ab5e230 @name="FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す">, 0.008073228993453085]
 test_配列の14番目は文字列のFizzBuzzを返す#FizzBuzz::数を文字列にして返す::タイプ1の場合::1から100までのFizzBuzzの配列を返す (0.01s)
Minitest::UnexpectedError:         RuntimeError: 上限は10件までです
            /workspace/tdd_rb/lib/fizz_buzz.rb:80:in `initialize'
            /workspace/tdd_rb/lib/fizz_buzz.rb:112:in `new'
            /workspace/tdd_rb/lib/fizz_buzz.rb:112:in `execute'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:45:in `setup'
...

想定通りのエラーが発生したのでコードを元に戻してコミットしましょう。

class FizzBuzzList
  MAX_COUNT = 100
...
...
Started with run options --seed 5525


Progress: |====================================================================================================|

Finished in 0.01262s
37 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...
$ git add .
$ git commit -m 'refactor: マジックナンバーの置き換え'

diag-cd1dbf997043a35c9bb55b407c0a2af9.png

特殊ケースの導入

最後に ポリモーフィズム の応用としてタイプクラスが未定義の場合に 例外 ではなく未定義のタイプクラスを返す 特殊ケースの導入 を適用してみましょう。

ヌルオブジェクトの導入

null値のチェックが繰り返し現れる。

そのnull値をヌルオブジェクトで置き換える。

— 新装版 リファクタリング

特殊ケースの導入

旧:ヌルオブジェクトの導入

特殊ケースの処理を要する典型的な値がnullなので、このパターンをヌルオブジェクトパターンと呼ぶことがあります、しかし、通常の特殊ケースとアプローチは同じです。いわばヌルオブジェクトは「特殊ケース」の特殊ケースです。

— リファクタリング(第2版)

まず、それ以外のタイプの場合の振る舞いを変更します。

...
    describe 'それ以外のタイプの場合' do
      def test_例外を返す
        e = assert_raises RuntimeError do
          FizzBuzzType.create(4)
        end

        assert_equal '該当するタイプは存在しません', e.message
      end
    end
  end
...
...
   describe 'それ以外のタイプの場合' do
      def test_未定義のタイプを返す
        fizzbuzz = FizzBuzzType.create(4)

        assert_equal '未定義', fizzbuzz.to_s
      end
    end
  end
...
...
ERROR["test_未定義のタイプを返す", #<Minitest::Reporters::Suite:0x00005593e21297d0 @name="数を文字列にして返す::それ以外のタイプの場合">, 0.0065623498521745205]
 test_未定義のタイプを返す#数を文字列にして返す::それ以外のタイプの場合 (0.01s)
Minitest::UnexpectedError:         RuntimeError: 該当するタイプは存在しません
            /workspace/tdd_rb/lib/fizz_buzz.rb:17:in `create'
            /workspace/tdd_rb/test/fizz_buzz_test.rb:131:in `test_未定義のタイプを返す'

  37/37: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00780s
37 tests, 41 assertions, 0 failures, 1 errors, 0 skips
...

現時点では 例外 を投げるので未定義タイプ FizzBuzzTypeNotDefined を作成して ファクトリメソッド を変更します。

class FizzBuzzType
  TYPE_01 = 1
  TYPE_02 = 2
  TYPE_03 = 3

  def self.create(type)
    case type
    when FizzBuzzType::TYPE_01
      FizzBuzzType01.new
    when FizzBuzzType::TYPE_02
      FizzBuzzType02.new
    when FizzBuzzType::TYPE_03
      FizzBuzzType03.new
    else
      raise '該当するタイプは存在しません'
    end
  end

  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end

class FizzBuzzType01 < FizzBuzzType
...
class FizzBuzzType
  TYPE_01 = 1
  TYPE_02 = 2
  TYPE_03 = 3

  def self.create(type)
    case type
    when FizzBuzzType::TYPE_01
      FizzBuzzType01.new
    when FizzBuzzType::TYPE_02
      FizzBuzzType02.new
    when FizzBuzzType::TYPE_03
      FizzBuzzType03.new
    else
      FizzBuzzTypeNotDefined.new
    end
  end
...
class FizzBuzzTypeNotDefined < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, '')
  end

  def to_s
    '未定義'
  end
end

class FizzBuzzValue
...
...
Started with run options --seed 33939


Progress: |=====================================================================================================|

Finished in 0.01193s
37 tests, 42 assertions, 0 failures, 0 errors, 0 skips
06:46:48 - INFO - Inspecting Ruby code style: lib/fizz_buzz.rb
 1/1 file |======================================= 100 ========================================>| Time: 00:00:00

1 file inspected, no offenses detected
06:46:49 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
 0/0 files |======================================= 100 =======================================>| Time: 00:00:00

0 files inspected, no offenses detected
...

テストが通るようになりました。 メソッドオブジェクト から実行された場合の振る舞いも明記しておきましょう。

...
    describe 'それ以外のタイプの場合' do
      def test_未定義のタイプを返す
        fizzbuzz = FizzBuzzType.create(4)

        assert_equal '未定義', fizzbuzz.to_s
      end

      def test_空の文字列を返す
        type = FizzBuzzType.create(4)
        command = FizzBuzzValueCommand.new(type)

        assert_equal '', command.execute(3)
      end
    end
  end
...
...
06:48:54 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 62 / 65 LOC (95.38%) covered.
Started with run options --guard --seed 18202

  38/38: [==================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00747s
38 tests, 43 assertions, 0 failures, 0 errors, 0 skips
...

FizzBuzzTypeNotDefined オブジェクトは Null Objectパターン を適用したものです。

Null Objectパターン

特殊な状況をオブジェクトで表現するにはどうすればよいだろうか---その特殊な状況を表現するオブジェクトを作り、通常のオブジェクトと同じプロトコル(メソッド群)を実装しよう。

— テスト駆動開発

オープン・クローズドの原則 に従って未定義のタイプである Null Object を安全に追加することができたのでコミットしておきます。

$ git add .
$ git commit -m 'refactor: 特殊ケースの導入'

diag-88e1d9090efb8e346a8986204d1decac.png

モジュール分割

diag-88e1d9090efb8e346a8986204d1decac.png

クラスモジュールの抽出によってアプリケーションの構造が 抽象化 された結果、視覚的に把握できるようになりました。ここでアプリケーションを実行してみましょう。

$ ruby main.rb
Traceback (most recent call last):
main.rb:5:in `<main>': uninitialized constant FizzBuzz (NameError)
Did you mean?  FizzBuzzType

エラーが出ています、これはアプリケーションの構成が変わったためです。クライアントプログラムをアプリケーションの変更に合わせて修正します。

# frozen_string_literal: true

require './lib/fizz_buzz.rb'

puts FizzBuzz.generate_list
# frozen_string_literal: true

require './lib/fizz_buzz.rb'

command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
command.execute(100).each { |i| puts i.value }
$ ruby main.rb
1
2
Fizz
4
Buzz
...
Fizz

クライアントプログラムが直ったのでコミットしておきます。

$ git add .
$ git commit -m 'fix: プリントする'

ドメインモデル

fizz_buzz.rb ファイル内のクラスモジュールをファイルとして分割していきます。まずは ドメインオブジェクト を抽出して ドメインモデル として整理しましょう。既存のテストを壊さないように1つづつコピー&ペーストしていきます。

関連する業務データと業務ロジックを1つにまとめたこのようなオブジェクトをドメインオブジェクトと呼びます。

「ドメイン」とは、対象領域とか問題領域という意味です。業務アプリケーションの場合、そのアプリケーションが対象となる業務活動全体がドメインです。業務活動という問題領域(ドメイン)で扱うデータと業務ロジックを、オブジェクトとして表現したものドメインオブジェクトです。ドメインオブジェクトは、業務データと業務ロジックを密接に関係づけます。

— 現場で役立つシステム設計の原則

このように業務アプリケーションの対象領域(ドメイン)をオブジェクトのモデルとして整理したものをドメインモデルと呼びます。

— 現場で役立つシステム設計の原則

/main.rb
  |--lib/
      |
       -- fizz_buzz.rb
  |--test/
      |
       -- fizz_buzz_test.rb

/main.rb
  |--lib/
      |
      domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
               -- fizz_buzz_type_not_defined.rb
       -- fizz_buzz.rb
  |--test/
      |
       -- fizz_buzz_test.rb

値オブジェクトクラスタイプクラスdomain フォルダ以下に配置します。

# frozen_string_literal: true

class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    raise '正の値のみ有効です' if number.negative?

    @number = number
    @value = value
  end

  def to_s
    "#{@number}:#{@value}"
  end

  def ==(other)
    @number == other.number && @value == other.value
  end

  alias eql? ==
end
# frozen_string_literal: true

class FizzBuzzList
  MAX_COUNT = 100
  attr_reader :value

  def initialize(list)
    raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT

    @value = list
  end

  def to_s
    @value.to_s
  end

  def add(value)
    FizzBuzzList.new(@value + value)
  end
end
# frozen_string_literal: true

class FizzBuzzType
  TYPE_01 = 1
  TYPE_02 = 2
  TYPE_03 = 3

  def self.create(type)
    case type
    when FizzBuzzType::TYPE_01
      FizzBuzzType01.new
    when FizzBuzzType::TYPE_02
      FizzBuzzType02.new
    when FizzBuzzType::TYPE_03
      FizzBuzzType03.new
    else
      FizzBuzzTypeNotDefined.new
    end
  end

  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end
# frozen_string_literal: true

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
    return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
    return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end
# frozen_string_literal: true

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, number.to_s)
  end
end
# frozen_string_literal: true

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end
# frozen_string_literal: true

class FizzBuzzTypeNotDefined < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, '')
  end

  def to_s
    '未定義'
  end
end
...
07:29:03 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png lib/domain/type/fizz_buzz_type_not_defined.rb lib/domain/type/fizz_buzz_type_03.rb lib/domain/type/fizz_buzz_type_02.rb lib/domain/type/fizz_buzz_type_01.rb lib/domain/type/fizz_buzz_type.rb lib/domain/model/fizz_buzz_list.rb lib/domain/model/fizz_buzz_value.rb
lib/domain/type/fizz_buzz_type_not_defined.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzTypeNotDefined < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_03.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType03 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_02.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType02 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type_01.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType01 < FizzBuzzType
^^^^^
lib/domain/type/fizz_buzz_type.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzType
^^^^^
lib/domain/model/fizz_buzz_list.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzList
^^^^^
lib/domain/model/fizz_buzz_value.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class FizzBuzzValue
^^^^^
 7/7 files |======================== 100 =========================>| Time: 00:00:00

7 files inspected, 7 offenses detected
...

テストは壊れていないようですが警告が出るようになりました。まだ仕掛ですが一旦コミットしておきます。

$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'

diag-5f47ca3306a2dc4364311b77c4b5cea3.png

アプリケーション

続いて アプリケーション層 の分割を行います。

データクラスと機能クラスを分ける手続き型の設計では、アプリケーション層のクラスに業務ロジックの詳細を記述します。

— 現場で役立つシステム設計の原則

/main.rb
  |--lib/
      |
      domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
       -- fizz_buzz_test.rb

/main.rb
  |--lib/
      |
     application/
           |
           -- fizz_buzz_command.rb
           -- fizz_buzz_value_command.rb
           -- fizz_buzz_list_command.rb
     domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
       -- fizz_buzz_test.rb

ここでは ドメインオブジェクト を操作する メソッドオブジェクトapplication フォルダ以下に配置します。

# frozen_string_literal: true

class FizzBuzzCommand
  def execute; end
end
# frozen_string_literal: true

class FizzBuzzValueCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    @type.generate(number).value
  end
end
# frozen_string_literal: true

class FizzBuzzListCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
  end
end

テストは壊れていないのでコミットしておきます。

$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'

diag-84a49e2f281dfc169055d0bfc4b4aeb6.png

テスト

アプリケーションのメイン部分は分割できました。続いてテストも分割しましょう。

/main.rb
  |--lib/
      |
     application/
           |
           -- fizz_buzz_command.rb
           -- fizz_buzz_value_command.rb
           -- fizz_buzz_list_command.rb
     domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
       -- fizz_buzz_test.rb

/main.rb
  |--lib/
      |
     application/
           |
           -- fizz_buzz_command.rb
           -- fizz_buzz_value_command.rb
           -- fizz_buzz_list_command.rb
     domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
      application/
           |
           -- fizz_buzz_value_command_test.rb
           -- fizz_buzz_list_command_test.rb
      domain/
           |
           model/
                 |
                 -- fizz_buzz_value_test.rb
                 -- fizz_buzz_list_test.rb
      |
       -- learning_test.rb
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzValueCommandTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列Fizzを返す
          assert_equal 'Fizz', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列Buzzを返す
          assert_equal 'Buzz', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'それ以外のタイプの場合' do
      def test_未定義のタイプを返す
        fizzbuzz = FizzBuzzType.create(4)

        assert_equal '未定義', fizzbuzz.to_s
      end

      def test_空の文字列を返す
        type = FizzBuzzType.create(4)
        command = FizzBuzzValueCommand.new(type)

        assert_equal '', command.execute(3)
      end
    end
  end

  describe '例外ケース' do
    def test_値は正の値のみ許可する
      e = assert_raises RuntimeError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end

      assert_equal '正の値のみ有効です', e.message
    end
  end
end
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzListCommandTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
          @result = fizzbuzz.execute(100)
        end

        def test_配列の初めは文字列の1を返す
          assert_equal '1', @result.first.value
        end

        def test_配列の最後は文字列のBuzzを返す
          assert_equal 'Buzz', @result.last.value
        end

        def test_配列の2番目は文字列のFizzを返す
          assert_equal 'Fizz', @result[2].value
        end

        def test_配列の4番目は文字列のBuzzを返す
          assert_equal 'Buzz', @result[4].value
        end

        def test_配列の14番目は文字列のFizzBuzzを返す
          assert_equal 'FizzBuzz', @result[14].value
        end
      end
    end
  end

  describe '例外ケース' do
    def test_100より多い数を許可しない
      e = assert_raises RuntimeError do
        FizzBuzzListCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(101)
      end

      assert_equal '上限は100件までです', e.message
    end
  end
end
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzValueTest < Minitest::Test
  def test_同じで値である
    value1 = FizzBuzzValue.new(1, '1')
    value2 = FizzBuzzValue.new(1, '1')

    assert value1.eql?(value2)
  end

  def test_to_stringメソッド
    value = FizzBuzzValue.new(3, 'Fizz')

    assert_equal '3:Fizz', value.to_s
  end
end
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzListTest < Minitest::Test
  def test_新しいインスタンスが作られる
    command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    array = command.execute(50)
    list1 = FizzBuzzList.new(array)
    list2 = list1.add(array)

    assert_equal 50, list1.value.count
    assert_equal 100, list2.value.count
  end
end
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

class LearningTest < Minitest::Test
  describe '配列や繰り返し処理を理解する' do
    def test_繰り返し処理
      $stdout = StringIO.new
      [1, 2, 3].each { |i| p i * i }
      output = $stdout.string

      assert_equal "1\n" + "4\n" + "9\n", output
    end

    def test_selectメソッドで特定の条件を満たす要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].select(&:integer?)
      assert_equal [2, 4], result
    end

    def test_find_allメソッドで特定の条件を満たす要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].find_all(&:integer?)
      assert_equal [2, 4], result
    end

    def test_特定の条件を満たさない要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].reject(&:integer?)
      assert_equal [1.1, 3.3], result
    end

    def test_mapメソッドで新しい要素の配列を返す
      result = %w[apple orange pineapple strawberry].map(&:size)
      assert_equal [5, 6, 9, 10], result
    end

    def test_collectメソッドで新しい要素の配列を返す
      result = %w[apple orange pineapple strawberry].collect(&:size)
      assert_equal [5, 6, 9, 10], result
    end

    def test_findメソッドで配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry].find(&:size)
      assert_equal 'apple', result
    end

    def test_detectメソッドで配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry].detect(&:size)
      assert_equal 'apple', result
    end

    def test_指定した評価式で並び変えた配列を返す
      result1 = %w[2 4 13 3 1 10].sort
      result2 = %w[2 4 13 3 1 10].sort { |a, b| a.to_i <=> b.to_i }
      result3 = %w[2 4 13 3 1 10].sort { |b, a| a.to_i <=> b.to_i }

      assert_equal %w[1 10 13 2 3 4], result1
      assert_equal %w[1 2 3 4 10 13], result2
      assert_equal %w[13 10 4 3 2 1], result3
    end

    def test_配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry apricot].grep(/^a/)
      assert_equal %w[apple apricot], result
    end

    def test_ブロック内の条件式が真である間までの要素を返す
      result = [1, 2, 3, 4, 5, 6, 7, 8, 9].take_while { |item| item < 6 }
      assert_equal [1, 2, 3, 4, 5], result
    end

    def test_ブロック内の条件式が真である以降の要素を返す
      result = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].drop_while { |item| item < 6 }
      assert_equal [6, 7, 8, 9, 10], result
    end

    def test_injectメソッドで畳み込み演算を行う
      result = [1, 2, 3, 4, 5].inject(0) { |total, n| total + n }
      assert_equal 15, result
    end

    def test_reduceメソッドで畳み込み演算を行う
      result = [1, 2, 3, 4, 5].reduce { |total, n| total + n }
      assert_equal 15, result
    end
  end
end

ファイル分割でテストは壊れていないようですが警告がたくさん出てきました。

...
test/learning_test.rb:70:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
    def test_ブロック内の条件式が真である間までの要素を返す
             ^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:75:9: C: Naming/MethodName: Use snake_case for method names.
    def test_ブロック内の条件式が真である以降の要素を返す
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:75:14: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
    def test_ブロック内の条件式が真である以降の要素を返す
             ^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:80:9: C: Naming/MethodName: Use snake_case for method names.
    def test_injectメソッドで畳み込み演算を行う
        ^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:80:20: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
    def test_injectメソッドで畳み込み演算を行う
                   ^^^^^^^^^^^^^^
test/learning_test.rb:85:9: C: Naming/MethodName: Use snake_case for method names.
    def test_reduceメソッドで畳み込み演算を行う
        ^^^^^^^^^^^^^^^^^^^^^^^^^
test/learning_test.rb:85:20: C: Naming/AsciiIdentifiers: Use only ascii symbols in identifiers.
    def test_reduceメソッドで畳み込み演算を行う
                   ^^^^^^^^^^^^^^
 15/15 files |======================= 100 ========================>| Time: 00:00:00

15 files inspected, 87 offenses detected
...

これらはテストコードに関する警告がほとんどなので .rubocop.yml を編集してチェック対象から外しておきましょう。

inherit_from: .rubocop_todo.yml

Naming/AsciiIdentifiers:
  Exclude:
    - 'test/**/*'

Naming/MethodName:
  EnforcedStyle: snake_case
  Exclude:
    - 'test/**/*'

Metrics/BlockLength:
  Max: 62
  Exclude:
    - 'test/**/*'

Documentation:
  Enabled: false
...
08:21:55 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 144 / 215 LOC (66.98%) covered.
Started with run options --guard --seed 55977

  70/70: [=====================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.01518s
70 tests, 79 assertions, 0 failures, 0 errors, 0 skips

08:21:56 - INFO - Inspecting Ruby code style of all files
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
 22/22 files |======================= 100 ========================>| Time: 00:00:00

22 files inspected, no offenses detected
08:21:58 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
 0/0 files |======================== 100 =========================>| Time: 00:00:00

0 files inspected, no offenses detected
...

警告は消えました、仕上げに fizz_buzz_test.rb ファイルを削除します。

...
08:24:12 - INFO - Running: all tests
Coverage report generated for MiniTest, Unit Tests to /workspace/tdd_rb/coverage. 135 / 201 LOC (67.16%) covered.
Started with run options --guard --seed 40104

  32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00601s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips

08:24:13 - INFO - Inspecting Ruby code style of all files
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
 21/21 files |======================= 100 ========================>| Time: 00:00:00

21 files inspected, no offenses detected
08:24:14 - INFO - Inspecting Ruby code style: coverage/assets/0.10.2/colorbox/controls.png coverage/assets/0.10.2/colorbox/border.png coverage/assets/0.10.2/colorbox/loading.gif coverage/assets/0.10.2/colorbox/loading_background.png
/workspace/tdd_rb/.rubocop.yml: Warning: no department given for Documentation.
 0/0 files |======================== 100 =========================>| Time: 00:00:00

0 files inspected, no offenses detected
...

テストの分割も完了したのでコミットしておきます。

$ git add .
$ git commit -m 'refactor(WIP): モジュール分割'

エントリーポイント

仕上げはクラスモジュールのエントリーポイント作成とテストヘルパーの追加です。

/main.rb
  |--lib/
      |
     application/
           |
           -- fizz_buzz_command.rb
           -- fizz_buzz_value_command.rb
           -- fizz_buzz_list_command.rb
     domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
      application/
           |
           -- fizz_buzz_value_command_test.rb
           -- fizz_buzz_list_command._test.rb
      domain/
           |
           model/
                 |
                 -- fizz_buzz_value_test.rb
                 -- fizz_buzz_list_test.rb
      |
       -- learning_test.rb

/main.rb
  |--lib/
      |
     application/
           |
           -- fizz_buzz_command.rb
           -- fizz_buzz_value_command.rb
           -- fizz_buzz_list_command.rb
     domain/
           |
           model/
               |
               -- fizz_buzz_value.rb
               -- fizz_buzz_list.rb
           type/
               |
               -- fizz_buzz_type.rb
               -- fizz_buzz_type_01.rb
               -- fizz_buzz_type_02.rb
               -- fizz_buzz_type_03.rb
       -- fizz_buzz.rb
  |--test/
      |
      application/
           |
           -- fizz_buzz_value_command_test.rb
           -- fizz_buzz_list_command._test.rb
      domain/
           |
           model/
                 |
                 -- fizz_buzz_value_test.rb
                 -- fizz_buzz_list_test.rb
      |
       -- learning_test.rb
       -- test_helper.rb

fizz_buzz.rb ファイルの内容をクラスモジュール読み込みに変更します。

require './lib/application/fizz_buzz_command.rb'
require './lib/application/fizz_buzz_value_command.rb'
require './lib/application/fizz_buzz_list_command.rb'
require './lib/domain/model/fizz_buzz_value.rb'
require './lib/domain/model/fizz_buzz_list.rb'
require './lib/domain/type/fizz_buzz_type.rb'
require './lib/domain/type/fizz_buzz_type_01.rb'
require './lib/domain/type/fizz_buzz_type_02.rb'
require './lib/domain/type/fizz_buzz_type_03.rb'
require './lib/domain/type/fizz_buzz_type_not_defined.rb'
...
08:34:32 - INFO - Running: all tests
Coverage report generated for MiniTest to /workspace/tdd_rb/coverage. 119 / 211 LOC (56.4%) covered.
Started with run options --guard --seed 18696

  32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00561s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips
....

コードカバレッジがうまく機能していないようなので、test_helper.rb を追加して共通部分を各テストファイルから読み込むように変更します。

# frozen_string_literal: true

require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'simplecov'
SimpleCov.start
require 'minitest/reporters'
Minitest::Reporters.use!
require 'minitest/autorun'
require './lib/fizz_buzz'

...
require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

...

テストタスクを実行したところ動作しなくなりました。

$ rake test

テスト対象をテストディレクトリ内のすべてのテストコードに変更します。

...
Rake::TestTask.new do |test|
  test.test_files = Dir['./test/fizz_buzz_test.rb']
  test.verbose = true
end
...
...
Rake::TestTask.new do |test|
  test.test_files = Dir['./test/**/*_test.rb']
  test.verbose = true
end
...
$ rake test
Started with run options --seed 46929

  32/32: [=====================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.00800s
32 tests, 36 assertions, 0 failures, 0 errors, 0 skips

テストも壊れていないし警告も出ていません。モジュール分割完了です。

$ git add .
$ git commit -m 'refactor: モジュール分割'

diag-c335939a7cce862d0b767629390a1852.png

ふりかえり

今回、 オブジェクト指向プログラム から オブジェクト指向設計 そして モジュール分割テスト駆動開発を通じて実践しました。各トピックを振り返ってみましょう。

オブジェクト指向プログラム

エピソード1で作成したプログラムの追加仕様を テスト駆動開発 で実装しました。 次に 手続き型コード との比較から オブジェクト指向プログラム を構成する カプセル化 ポリモフィズム 継承 という概念をコードベースの リファクタリング を通じて解説しました。

具体的には フィールドのカプセル から setterの削除 を適用することにより カプセル化 を実現しました。続いて、 ポリモーフィズムによる条件記述の置き換え から State/Strategyによるタイプコードの置き換え を適用することにより ポリモーフィズム の効果を体験しました。そして、 スーパークラスの抽出 から メソッド名の変更 メソッドの移動 の適用を通して 継承 の使い方を体験しました。さらに 値オブジェクトファーストクラス というオブジェクト指向プログラミングに必要なツールの使い方も学習しました。

オブジェクト指向設計

次に設計の観点から 単一責任の原則 に違反している FizzBuzz クラスを デザインパターン の1つである Commandパターン を使ったリファクタリングである メソッドオブジェクトによるメソッドの置き換え を適用してクラスの責務を分割しました。オブジェクト指向設計のイデオムである デザインパターン として Commandパターン 以外に Value Objectパターン Factory Methodパターン Strategyパターンリファクタリング を適用する過程ですでに実現していたことを説明しました。そして、オープン・クローズドの原則 を満たすコードに リファクタリング されたことで既存のコードを変更することなく振る舞いを変更できるようになりました。

加えて、正常系の設計を改善した後 アサーションの導入 例外によるエラーコードの置き換え といった例外系の リファクタリング を適用しました。最後に ポリモーフィズム の応用として 特殊ケースの導入 の適用による Null Objectパターン を使った オープン・クローズドの原則 に従った安全なコードの追加方法を解説しました。

モジュールの分割

仕上げに、モノリシック なファイルから個別のクラスモジュールへの分割を ドメインオブジェクト の抽出を通して ドメインモデル へと整理することにより モジュール分割 を実現しました。最終的にプログラムからアプリケーションへと体裁を整えることが出来ました。以下が最終的なモジュール構造とコードです。

diag-c335939a7cce862d0b767629390a1852.png

  • Application

/main.rb.

# frozen_string_literal: true

require './lib/fizz_buzz.rb'

command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
command.execute(100).each { |i| puts i.value }

/lib/application/fizz_buzz_command.rb.

# frozen_string_literal: true

class FizzBuzzCommand
  def execute; end
end

/lib/application/fizz_buzz_value_command.rb.

# frozen_string_literal: true

class FizzBuzzValueCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    @type.generate(number).value
  end
end

/lib/application/fizz_buzz_list_command.rb.

# frozen_string_literal: true

class FizzBuzzListCommand < FizzBuzzCommand
  def initialize(type)
    @type = type
  end

  def execute(number)
    FizzBuzzList.new((1..number).map { |i| @type.generate(i) }).value
  end
end
  • Domain

/lib/domain/model/fizz_buzz_value.rb.

# frozen_string_literal: true

class FizzBuzzValue
  attr_reader :number, :value

  def initialize(number, value)
    raise '正の値のみ有効です' if number.negative?

    @number = number
    @value = value
  end

  def to_s
    "#{@number}:#{@value}"
  end

  def ==(other)
    @number == other.number && @value == other.value
  end

  alias eql? ==
end

/lib/domain/model/fizz_buzz_list.rb.

# frozen_string_literal: true

class FizzBuzzList
  MAX_COUNT = 100
  attr_reader :value

  def initialize(list)
    raise "上限は#{MAX_COUNT}件までです" if list.count > MAX_COUNT

    @value = list
  end

  def to_s
    @value.to_s
  end

  def add(value)
    FizzBuzzList.new(@value + value)
  end
end

/lib/domain/type/fizz_buzz_type.rb.

# frozen_string_literal: true

class FizzBuzzType
  TYPE_01 = 1
  TYPE_02 = 2
  TYPE_03 = 3

  def self.create(type)
    case type
    when FizzBuzzType::TYPE_01
      FizzBuzzType01.new
    when FizzBuzzType::TYPE_02
      FizzBuzzType02.new
    when FizzBuzzType::TYPE_03
      FizzBuzzType03.new
    else
      FizzBuzzTypeNotDefined.new
    end
  end

  def fizz?(number)
    number.modulo(3).zero?
  end

  def buzz?(number)
    number.modulo(5).zero?
  end
end

/lib/domain/type/fizz_buzz_type_01.rb.

# frozen_string_literal: true

class FizzBuzzType01 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)
    return FizzBuzzValue.new(number, 'Fizz') if fizz?(number)
    return FizzBuzzValue.new(number, 'Buzz') if buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end

/lib/domain/type/fizz_buzz_type_02.rb.

# frozen_string_literal: true

class FizzBuzzType02 < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, number.to_s)
  end
end

/lib/domain/type/fizz_buzz_type_03.rb.

# frozen_string_literal: true

class FizzBuzzType03 < FizzBuzzType
  def generate(number)
    return FizzBuzzValue.new(number, 'FizzBuzz') if fizz?(number) && buzz?(number)

    FizzBuzzValue.new(number, number.to_s)
  end
end

/lib/domain/type/fizz_buzz_type_not_defined.b.

# frozen_string_literal: true

class FizzBuzzTypeNotDefined < FizzBuzzType
  def generate(number)
    FizzBuzzValue.new(number, '')
  end

  def to_s
    '未定義'
  end
end
  • Test

/test/application/fizz_buzz_value_command_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzValueCommandTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType01.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列Fizzを返す
          assert_equal 'Fizz', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列Buzzを返す
          assert_equal 'Buzz', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'タイプ2の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType02.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列15を返す
          assert_equal '15', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'タイプ3の場合' do
      def setup
        @fizzbuzz = FizzBuzzValueCommand.new(FizzBuzzType03.new)
      end

      describe '三の倍数の場合' do
        def test_3を渡したら文字列3を返す
          assert_equal '3', @fizzbuzz.execute(3)
        end
      end

      describe '五の倍数の場合' do
        def test_5を渡したら文字列5を返す
          assert_equal '5', @fizzbuzz.execute(5)
        end
      end

      describe '三と五の倍数の場合' do
        def test_15を渡したら文字列FizzBuzzを返す
          assert_equal 'FizzBuzz', @fizzbuzz.execute(15)
        end
      end

      describe 'その他の場合' do
        def test_1を渡したら文字列1を返す
          assert_equal '1', @fizzbuzz.execute(1)
        end
      end
    end

    describe 'それ以外のタイプの場合' do
      def test_未定義のタイプを返す
        fizzbuzz = FizzBuzzType.create(4)

        assert_equal '未定義', fizzbuzz.to_s
      end

      def test_空の文字列を返す
        type = FizzBuzzType.create(4)
        command = FizzBuzzValueCommand.new(type)

        assert_equal '', command.execute(3)
      end
    end
  end

  describe '例外ケース' do
    def test_値は正の値のみ許可する
      e = assert_raises RuntimeError do
        FizzBuzzValueCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(-1)
      end

      assert_equal '正の値のみ有効です', e.message
    end
  end
end

/test/application/fizz_buzz_list_command_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzListCommandTest < Minitest::Test
  describe '数を文字列にして返す' do
    describe 'タイプ1の場合' do
      describe '1から100までのFizzBuzzの配列を返す' do
        def setup
          fizzbuzz = FizzBuzzListCommand.new(FizzBuzzType01.new)
          @result = fizzbuzz.execute(100)
        end

        def test_配列の初めは文字列の1を返す
          assert_equal '1', @result.first.value
        end

        def test_配列の最後は文字列のBuzzを返す
          assert_equal 'Buzz', @result.last.value
        end

        def test_配列の2番目は文字列のFizzを返す
          assert_equal 'Fizz', @result[2].value
        end

        def test_配列の4番目は文字列のBuzzを返す
          assert_equal 'Buzz', @result[4].value
        end

        def test_配列の14番目は文字列のFizzBuzzを返す
          assert_equal 'FizzBuzz', @result[14].value
        end
      end
    end
  end

  describe '例外ケース' do
    def test_100より多い数を許可しない
      e = assert_raises RuntimeError do
        FizzBuzzListCommand.new(
          FizzBuzzType.create(FizzBuzzType::TYPE_01)
        ).execute(101)
      end

      assert_equal '上限は100件までです', e.message
    end
  end
end

/test/domain/model/fizz_buzz_value_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzValueTest < Minitest::Test
  def test_同じで値である
    value1 = FizzBuzzValue.new(1, '1')
    value2 = FizzBuzzValue.new(1, '1')

    assert value1.eql?(value2)
  end

  def test_to_stringメソッド
    value = FizzBuzzValue.new(3, 'Fizz')

    assert_equal '3:Fizz', value.to_s
  end
end

/test/domain/model/fizz_buzz_list_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

class FizzBuzzListTest < Minitest::Test
  def test_新しいインスタンスが作られる
    command = FizzBuzzListCommand.new(FizzBuzzType.create(FizzBuzzType::TYPE_01))
    array = command.execute(50)
    list1 = FizzBuzzList.new(array)
    list2 = list1.add(array)

    assert_equal 50, list1.value.count
    assert_equal 100, list2.value.count
  end
end

/test/learning_test.rb.

# frozen_string_literal: true

require './test/test_helper'
require 'minitest/autorun'
require './lib/fizz_buzz'

class LearningTest < Minitest::Test
  describe '配列や繰り返し処理を理解する' do
    def test_繰り返し処理
      $stdout = StringIO.new
      [1, 2, 3].each { |i| p i * i }
      output = $stdout.string

      assert_equal "1\n" + "4\n" + "9\n", output
    end

    def test_selectメソッドで特定の条件を満たす要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].select(&:integer?)
      assert_equal [2, 4], result
    end

    def test_find_allメソッドで特定の条件を満たす要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].find_all(&:integer?)
      assert_equal [2, 4], result
    end

    def test_特定の条件を満たさない要素だけを配列に入れて返す
      result = [1.1, 2, 3.3, 4].reject(&:integer?)
      assert_equal [1.1, 3.3], result
    end

    def test_mapメソッドで新しい要素の配列を返す
      result = %w[apple orange pineapple strawberry].map(&:size)
      assert_equal [5, 6, 9, 10], result
    end

    def test_collectメソッドで新しい要素の配列を返す
      result = %w[apple orange pineapple strawberry].collect(&:size)
      assert_equal [5, 6, 9, 10], result
    end

    def test_findメソッドで配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry].find(&:size)
      assert_equal 'apple', result
    end

    def test_detectメソッドで配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry].detect(&:size)
      assert_equal 'apple', result
    end

    def test_指定した評価式で並び変えた配列を返す
      result1 = %w[2 4 13 3 1 10].sort
      result2 = %w[2 4 13 3 1 10].sort { |a, b| a.to_i <=> b.to_i }
      result3 = %w[2 4 13 3 1 10].sort { |b, a| a.to_i <=> b.to_i }

      assert_equal %w[1 10 13 2 3 4], result1
      assert_equal %w[1 2 3 4 10 13], result2
      assert_equal %w[13 10 4 3 2 1], result3
    end

    def test_配列の中から条件に一致する要素を取得する
      result = %w[apple orange pineapple strawberry apricot].grep(/^a/)
      assert_equal %w[apple apricot], result
    end

    def test_ブロック内の条件式が真である間までの要素を返す
      result = [1, 2, 3, 4, 5, 6, 7, 8, 9].take_while { |item| item < 6 }
      assert_equal [1, 2, 3, 4, 5], result
    end

    def test_ブロック内の条件式が真である以降の要素を返す
      result = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].drop_while { |item| item < 6 }
      assert_equal [6, 7, 8, 9, 10], result
    end

    def test_injectメソッドで畳み込み演算を行う
      result = [1, 2, 3, 4, 5].inject(0) { |total, n| total + n }
      assert_equal 15, result
    end

    def test_reduceメソッドで畳み込み演算を行う
      result = [1, 2, 3, 4, 5].reduce { |total, n| total + n }
      assert_equal 15, result
    end
  end
end

良い設計

エピソード1では 良いコード について考えました。

TDDは「より良いコードを書けば、よりうまくいく」という素朴で奇妙な仮設によって成り立っている

— テスト駆動開発

「動作するきれいなコード」。RonJeffriesのこの簡潔な言葉が、テスト駆動開発(TDD)のゴールだ。動作するきれいなコードはあらゆる意味で価値がある。

— テスト駆動開発

良いコードかどうかは、変更がどれだけ容易なのかで決まる。

— リファクタリング(第2版)

コードは理解しやすくなければいけない。

— リーダブルコード

本エピソードでは テスト駆動開発 による オブジェクト指向プログラミングリファクタリング を経てコードベースを改善してきました。そして オブジェクト指向設計 により 良いコード のプログラムを 良い設計 のアプリケーションへと進化させることができました。

どこに何が書いてあるかをわかりやすくし、変更の影響を狭い範囲に閉じ込め、安定して動作する部品を柔軟に組み合わせながらソフトウェアを構築する技法がオブジェクト指向設計です。

— 現場で役立つシステム設計の原則

設計の良し悪しは、ソフトウェアを変更するときにはっきりします。

構造が入り組んだわかりづらいプログラムは内容の理解に時間がかかります。重複したコードをあちこちで修正する作業が増え、変更の副作用に悩まされます。

一方、うまく設計されたプログラムは変更が楽で安全です。変更すべき箇所がかんたんにわかり、変更するコード量が少なく、変更の影響を狭い範囲に限定できます。

プログラムの修正に3日かかるか、それとも半日で済むか。その違いを生むのが「設計」なのです。

— 現場で役立つシステム設計の原則

では、いつ設計をしていたのでしょうか? わかりますよね、このエピソードの始まりから終わりまで常に設計をしていたのです。

TDDは分析技法であり、設計技法であり、実際には開発のすべてのアクティビティを構造化する技法なのだ。

— テスト駆動開発

参考サイト

参考図書

  • テスト駆動開発 Kent Beck (著), 和田 卓人 (翻訳): オーム社; 新訳版 (2017/10/14)

  • 新装版 リファクタリング―既存のコードを安全に改善する― (OBJECT TECHNOLOGY SERIES) Martin
    Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 新装版
    (2014/7/26)

  • リファクタリング(第2版): 既存のコードを安全に改善する (OBJECT TECHNOLOGY SERIES) Martin
    Fowler (著), 児玉 公信 (翻訳), 友野 晶夫 (翻訳), 平澤 章 (翻訳), その他: オーム社; 第2版
    (2019/12/1)

  • リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)
    Dustin Boswell (著), Trevor Foucher (著), 須藤 功平 (解説), 角 征典 (翻訳):
    オライリージャパン; 初版八刷版 (2012/6/23)

  • Clean Code アジャイルソフトウェア達人の技 (アスキードワンゴ) Robert C.Martin (著), 花井
    志生 (著) ドワンゴ (2017/12/28)

  • 現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法 増田 亨 (著) 技術評論社
    (2017/7/5)

  • かんたん Ruby (プログラミングの教科書) すがわらまさのり (著) 技術評論社 (2018/6/21)

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?