蒼時弦也
蒼時弦也
資深軟體工程師
發表於

優雅的 RSpec 測試 - 探索式的測試與重構

經過接近半年的連載,最後我們要來聊一下如何「加入測試」的問題,雖然我們了解到了各種撰寫測試的技巧,但往往我們是卡在不知道從何開始測試,因此跟大家分享一些實用的小技巧。

自上而下

自上而下簡單來說就是從進入點(Entry Point)開始測試的意思,以網站來說可能是一個頁面、API Endpoint 之類的,某方面來說也可以視為 E2E Testing 類型的驗證。

其實 E2E Testing 聽起來很麻煩,因為我們需要去搭建一個可以實際模擬真實操作環境的測試環境,然而這類型的測試反而是最容易做的,因為我們只需要考慮輸入跟輸出是正確的,就可以涵蓋到非常多的程式。

我們都知道「重構」的目標是不去破壞原有的行為,那麼被測試覆蓋是必須的,當我們要修改、擴充一個軟體時,重構基本上會是這類動作的前提,那麼實踐了 E2E Testing 類型的測試後,我們就完成了最低限度的測試,也就得以重構以及進行後續的擴充與修改。

雖然 E2E Testing 的測試搭建稍微麻煩,然而當我們搭建完一次之後就可以在所有後續的測試類型使用,不要讓這件事情成為後續開發的阻力。

案例分析

為了讓大家更容易了解到這個技巧的實踐,以下會用一個簡單的例子(非真實程式)來解釋,我們以一個 Ruby on Rails 所實現的 API 來作為範例。

對 Rails 專案來說 Controller 是進入點,使用者的輸入會直接在這裡被處理,因此我們先假設有一個回合制遊戲的「行動階段」的 API 讓使用者可以選擇行動並且進行操作。

 1class Battle::ActionsController < API::BaseController
 2  before_action :find_battle
 3
 4  # 採取行動
 5  def create
 6    @action = ActionForm.new(params)
 7
 8    # 行動判定
 9    res = case @action.type
10          when :attack
11            @battle.attack_by(current_player, @action.target)
12          when :item
13            @battle.use_item_by(current_player, @action.item)
14          when :escape
15            @battle.escape_by(current_player)
16          else
17            false
18          end
19
20    return invalid_action if res == false
21
22    # 電腦對應行動,完成一回合攻防
23    ai_res = @battle.take_action_by_ai
24    return invalid_action if res == false
25
26    render json: {
27      result: [
28        res,
29        ai_res
30      ]
31    }
32  end
33end

假設我們想要繼續擴充,會遇到非常多問題,像是需要增加更多的邏輯(判斷式)到裡面,同時不同類型動作所需要的 ActionForm 屬性不同,在輸入的時候也會有差異,擴充上也會越來越複雜。

Controller 測試

因為這個系統是一個黑箱(不清楚以往實作)因此我們只能實際操作看看,也就是直接呼叫 Controller 觀察反應,並且記錄下來,這也是所謂的「探索」的過程,一步一步的將未知的邏輯摸索出來。

以 RSpec 測試來說,對 Rails 可以使用 Request 類型的測試來實踐,或者使用 Cucumber 來協助做 E2E Testing 的驗證,因為是 API 類型的進入點,我們使用 Request 測試就足以處理這個情況。

 1RSpec.describe 'Battle Action', type: :request do
 2  # ...
 3  subject(:response_json) { JSON.parse(response.body) }
 4
 5  before { post '/api/battle/actions', params: params }
 6
 7  context 'when take attack action' do
 8    let(:params) do
 9      {
10        action: {
11          type: :attack,
12          target: 0
13        }
14      }
15    end
16
17    it { is_expected.to include({'result' => include(/玩家造成 \d+ 點傷害/)}) }
18    # 使用自訂匹配器(推薦)
19    it { is_expected.to have_action_result(/玩家造成 \d+ 點傷害/)}
20  end
21  # ...
22end

像這樣,我們先將每一種動作類型會產生的結果(訊息)當作檢查的條件,他可能會是回傳含有某個數值、顯示某個按鈕,不論如何我們都可以透過這樣的測試先確認「有做出正確的行為」

分離邏輯

接下來要處理的是將「(商業)邏輯」從 Controller 抽離,只留下「流程」在進入點上,這樣我們在閱讀 Controller 時會變得簡單,除此之外也能夠更好的去擴充、修改這個行為。

 1class Battle::ActionsController < API::BaseController
 2  before_action :find_battle
 3
 4  # 採取行動
 5  def create
 6    action = ActionForm.by_type(params)
 7    # => #<AttackAction:0x00007fadea0c9820 args=[0]>
 8
 9    ActiveRecord::Base.transaction do
10      result = [
11        @battle.take_action!(current_player, action)
12        @battle.take_action_by_ai!
13      ]
14    end
15
16    render json: {
17      result: result
18    }
19
20  # 可以使用 rescue_from 處理
21  rescue Battle::PlayerActionFailed,
22         Battle::AIActionFailed
23    render invalid_action
24  end
25end

這邊呈現的是最終的狀態,我們把所有判斷(ifcase)都移除掉,只留下描述「行動」相關的實作,實際上在重構時應該要分次處理,像是先讓 @battle.take_action_by_ai 轉換為會拋出錯誤的版本,再處理玩家的行動判斷,最後再讓 ActionForm 可以產生不同類型的物件。

這裡我們還可以再進一步重構為使用 Service Object 處理的版本,受限於篇幅就先維持目前的版本。

從最終狀態可以觀察到,整個實作相對於之前的版本相對更容易理解「採取行動」會發生哪些事情。

重構邏輯

為了要分離邏輯,我們需要在每一階段的處理中都對引用到的物件進行重構,並且逐步的改善,在這個階段先從影響比較小的物件開始會比較容易,我們以比較複雜的 @battle.take_action! 的實踐來進行說明。

最原始我們是個別呼叫方法,但在重構後的版本會被合併在 #take_action! 這個方法中,因此我們需要先區分出共通點。

 1    # 行動判定
 2    res = case @action.type
 3          when :attack
 4            @battle.attack_by(current_player, @action.target)
 5          when :item
 6            @battle.use_item_by(current_player, @action.item)
 7          when :escape
 8            @battle.escape_by(current_player)
 9          else
10            false
11          end

這幾個方法基本上都會接受 current_player 作為參考,而且一部分會接受第二個參數,在這個階段我們可以先讓 #escape_by 也能夠接受第二個參數,來統一介面。

1class Battle < ApplicationRecord
2  # ...
3  def escape_by(player, *args)
4    # ...
5  end
6end

以 Ruby 來說我們可以利用 *args 的方式製作一個「選用」的陣列,這樣不管傳入多少都不會影響到原本的行為,如此一來就可以使用 @battle.escape_by(current_player, nil) 這樣的方式來跟其他行為統一。

接下來我們要加入 @battle.take_action! 的實作。

 1class Battle < ApplicationRecord
 2  # ...
 3  def take_action!(player, action)
 4    res = case action
 5          when AttackAction then attack_by(player, *action.args)
 6          when ItemAction then use_item_by(player, *action.args)
 7          when EscapeAction then escape_by(player, *action.args)
 8          else
 9            false
10          end
11    raise PlayerActionFailed if res == false
12  end
13end

這裡我們先假設已經完成第一次的 #take_action! 重構,並且將 ActionForm 改為能依照類型回傳不同物件的版本,因此我們就可以直接基於物件類型判斷要採取怎樣的行為。

像這樣子,我們基本上就可以很輕鬆的對遊戲的行動採取後續的擴充,以及針對重構後的物件單獨的進行單元測試來覆蓋一些失敗的情況(例如 Battle::PlayerActionFailed 被拋出的狀況,正常狀況不應該出現)

實際上這樣的重構遠比我們去思考該先修改哪個 Model 的方法,再回去尋找使用到到 Controller 還更加的快速而且更有效率,同時也不需要馬上掌握完整的系統,只需要根據修改到的地方再進一步的閱讀相關的邏輯即可。

如果有注意到 #take_action! 內的實作其實是有規律的,大概就可以猜到下一步可以怎樣繼續重構,將方法更明確、精細的切分開來獲得更好的可擴充性。

那麼,這次的「優雅的 RSpec 測試」系列就到此告一段落,我們在下一個系列中再跟大家一起探索 Ruby 軟體開發的技巧。


如果想在第一時間收到更新,歡迎訂閱弦而時習之在這系列文章更新時收到通知,如果有希望了解的知識,可以利用優雅的 RSpec 測試回饋表單告訴我。