iT邦幫忙

2023 iThome 鐵人賽

DAY 21
0
自我挑戰組

入坑 RoR 必讀 - Ruby 物件導向設計實踐系列 第 21

Day21 CH9 設計節省成本的測試(延伸)

  • 分享至 

  • xImage
  •  

本章的篇幅比較長,但又不想捨棄任何精彩的部分,謝謝大家陪我讀到這,再努力一下!
昨天,我們成功測試鴨子類型程式碼;今天,當然不能放過繼承程式碼囉(笑

測試繼承程式碼

里氏代替原則聲明子類型應該都要能夠代替它們的父類型。要證明層次結構裡的所有物件都符合里氏代替原則,最簡單的方法是撰寫一項共用的測試,並且將這項測試包含在所有物件裡。

以第6章的Bicycle類別為例,RoadBikeBicycle的子類別 :

class Bicycle
  attr_reader :size, :chain, :tire_size

  def initialize(args ={})
    @size = args[:size]
    @chain = args[:chain] || default_chain
    @tire_size = args[:tire_size] || default_tire_size
    post_initialize(args)
  end

  def spares
    {tire_size: tire_size,
      chain: chain}.merge(local_spares) 
  end

  def default_tire_size 
    raise NotInclementedError 
  end

  # 子類別可以覆蓋
  def post_initialize(args)
    nil
  end

  def local_spares
    {)
  end

  def default_chain
    '10-speed'
  end
end
class RoadBike < Bicycle 
  attr_reader :tape_color 

  def post_initialize(args)
    @tape_color = args[:tape_color]
  end

  def local_spares
    {tape_color: tape_color}
  end

  def default_tire_size
    '23'
  end
end

我們會預設一個情境是通過BicyclelnterfaceTest的任何物件,都可以被認為像一個Bicycle

module BicyclelnterfaceTest
  def test_responds_to_default_tire_size
    assert_respond_to(@object, :default_tire_size) 
  end

  def test_responds_to_default_chain
    assert_respond_to(@object, :default_chain)
  end

  def test_responds_to_chain
    assert_respond_to(@object, :chain)
  end

  def test_responds_to_size
    assert_respond_to(@object, :size)
  end

  def test_responds_to_tire_size
    assert_respond_to(@object, :tire_size)
  end

  def test_responds_to_spares
    assert_respond_to(@object, :spares)
  end
end
class BicycleTest < MiniTest::Unit::TestCase 
  include BicyclelnterfaceTest

  def setup
    @bike = @object = Bicycle.new({tire_size: 0})
  end
end 

class RoadBikeTest < MiniTest::Unit::TestCase 
  include BicyclelnterfaceTest

  def setup
    @bike = @object = RoadBike.new 
  end
end
BicycleTest
  PASS test_responds_to_defau1t_chain
  PASS test_responds_to_size
  PASS test_responds_to_tire_size
  PASS test_responds_to_chain
  PASS test_responds_to_spares
  PASS test_responds_to_default_tire_size

RoadBikeTest
  PASS test_responds_to_chain
  PASS test_responds_to_tire_size
  PASS test_responds_to_default_chain
  PASS test_responds_to_spares
  PASS test_responds_to_default_tire_size
  PASS test_responds_to_size

指定子類別責任

  1. 確認子類別行為
    這項測試彙集了對Bicycle子類別的要求。任何子類別都可以任意地繼承post_initializelocal_spares。子類別唯一必須實作的方法是default_tire_size。父類別的default_tire_size實作會引發錯誤,而這項測試會出現失敗,除非子類別實作了自己的特殊化版本。

    module BicycleSubclassTest
      def test_responds_to_post_initialize
        assert_respond_to(@object, :post_initialize) 
      end
    
      def test_responds_to_local_spares
        assert_respond_to(@object, :local_spares)
      end
    
      def test_responds_to_default_tire_size
        assert_respond_to(@object, :default_tire_size)
      end
    end
    
    class RoadBikeTest < MiniTest::Unit::TestCase
      include BicyclelnterfaceTest
      include BicycleSubclassTest
    
      def setup
        @bike = @object = RoadBike.new 
      end
    end
    
    RoadBikeTest
      PASS test_responds_to_default_tire_size
      PASS test_responds_to_spares
      PASS test_responds_to_chain
      PASS test_responds_to_post_initialize
      PASS test_responds_to_local_spares
      PASS test_responds_to_size
      PASS test_responds_to_tire_size
      PASS test_responds_to_default_chain
    
  2. 確認父類別實作
    如果子類別沒有實作default_tire_size,那麼 Bicycle類別應該會引發錯誤。

    class BicycleTest < MiniTest::Unit::TestCase
     include BicyclelnterfaceTest
    
     def setup
       @bike = @object = Bicycle.new({tire_size: 0})
     end
    
     def test_forces_subclasses_to_implement_default_tire_size 
       assert_raises(NotInplementedError) {@bike.default_tire_size}
     end
    end
    

    BicycleTest需要一個用於進行測試的物件,而最明顯的候選項就是Bicycle實例。就目前而言,只要提供 tire_size參數就能夠順利運作。現在執行BicycleTest,其輸出訊息看起來就更像是一個抽象父類別。

    BicycleTest
     PASS test_responds_to_default_tire_size
     PASS test_responds_to_size
     PASS test_responds_to_default_chain 
     PASS test_responds_to_tire_size 
     PASS test_responds_to_chain
     PASS test_responds_to_spares
     PASS test_forces_subclasses_to_implement_default_tire_size
    

測試獨特行為

  1. 測試具體子類別的行為
    測試這些特殊化時不將父類別的知識嵌入到測試裡很重要。例如,RoadBike實作了local_spares,並且會回應spares。而RoadBikeTest則應該確保local_spares可以運作,同時還要刻意忽視spares方法的存在。

    class RoadBikeTest < MiniTest::Unit::Testcase
      include BicyclelnterfaceTest 
      include BicycleSubclassTest
    
      def setup
        @bike = @object = RoadBike.new(tape_color: 'red')
      end
    
      def test_puts_tape_color_in_local_spares
        assert_equal 'red', @bike.local_spares[:tape_color] 
      end
    end
    

    執行RoadBikeTest即可表明:它符合共同職責,並且也提供了自己的特殊化:

    RoadBikeTest
     PASS test_responds_to_default_chain
     PASS test_responds_to_default_tire_size
     PASS test_puts_tape_color_in_local_spares
     PASS test_responds_to_spares
     PASS test_responds_to_size
     PASS test_responds_to_local_spares
     PASS test_responds_to_post_initialize
     PASS test_responds_to_tire_size
     PASS test_responds_to_chain
    
  2. 測試抽象父類別的行為
    Bicycle是一 個抽象父類別。建立Bicycle實例不僅困難,並且這個實例可能缺乏執行測試所需的全部行為。由於Bicycle使用「範本方法」來取得具體的特殊化,將通常是由子類別所提供的行為截短,並建立一個僅用於此測試的新子類別, 輕易地製造出可供測試的Bicycle實例。

    class StubbedBike < Bicycle
      def default_tire_size
        0
      end
    
      def local_spares
        {saddle: 'painful'}
      end
    end
    
    class BicycleTest < MiniTest::Unit::Testcase
      include BicycleInterfaceTest
    
      def setup
        @bike = @object = Bicycle.new({tire_size: 0})
        @stubbed_bike = StubbedBike.new
      end
    
      def test_forces_subclasses_to_implement_default_tire_size 
        assert_raises (NotlirplementedError) { 
          @bike.default_tire_size)
      end
    
      def test_includes_local_spares_in_spares
        assert_equal @stubbed_bike.spares, 
     	 {tire_size: 0,
     	  chain: '10-speed',
     	  saddle: 'painful'}
      end
    end
    

    藉由建立子類別來提供截短功能的概念,現在執行BicycleTest即可證明它在spares的列表裡包含了子類別所提供的內容:

    BicycleTest
     PASS test_responds_to_spares
     PASS test_responds_to_tire_size 
     PASS test_responds_to_default_chain
     PASS test_responds_to_defau1t_tire_size
     PASS test_forces_subclasses_to_implement_default_tire_size
     PASS test_responds_to_chain
     PASS test_includes_local_spares_in_spares
     PASS test_responds_to_size
    

    如果擔心StubbedBike會變得過時,使得BicycleTest在應該失敗時卻通過,使用BicycleSubclassTest來確保StubbedBike的正確性能夠延續。

    #證明測試替身遵從了
    #這項測試所期望的介面。
    class StubbedBikeTest < MiniTest::Uiiit::TestCase
      include BicycleSubclassTest
    
      def setup
        @object = StubbedBike.new
      end 
    end
    
    StubbedBikeTest
     PASS test_responds_to_default_tire_size
     PASS test_responds_to_local_spares
     PASS test_responds_to_post_initialize
    

    為整體介面撰寫一個可共用的測試,並為子類別的職責撰寫其他測試。儘可能將職責隔離起來。特別要注意子類別特殊化的測試,應防止父類別知識洩漏到子類別的測試裡。

結論

設計良好的應用程式具有高度抽象的特點,如果沒有測試,這些應用程式既無法被理解,也無法安全地進行修改。最佳的測試是保持與底層程式碼之間的鬆散耦合,並且所有事物都只在適當的地方測試一次。

參考資料:

  • Practical Object-Oriented Design in Ruby: An Agile Primer

上一篇
Day20 CH9 設計節省成本的測試(下)
下一篇
Day22 Ruby物件導向實踐-類別與實體
系列文
入坑 RoR 必讀 - Ruby 物件導向設計實踐30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言