經過接近半年的連載,最後我們要來聊一下如何「加入測試」的問題,雖然我們了解到了各種撰寫測試的技巧,但往往我們是卡在不知道從何開始測試,因此跟大家分享一些實用的小技巧。
自上而下
自上而下簡單來說就是從進入點(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
這邊呈現的是最終的狀態,我們把所有判斷(if
和 case
)都移除掉,只留下描述「行動」相關的實作,實際上在重構時應該要分次處理,像是先讓 @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 測試回饋表單告訴我。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 優雅的 RSpec 測試 - 前言
- 優雅的 RSpec 測試 - 撰寫測試的方式
- 優雅的 RSpec 測試 - RSpec 概觀
- 優雅的 RSpec 測試 - 測試案例
- 優雅的 RSpec 測試 - 組織測試
- 優雅的 RSpec 測試 - 前置處理
- 優雅的 RSpec 測試 - 常見匹配器
- 優雅的 RSpec 測試 - 內容匹配
- 優雅的 RSpec 測試 - 錯誤匹配
- 優雅的 RSpec 測試 - 共用案例
- 優雅的 RSpec 測試 - 自訂匹配器
- 優雅的 RSpec 測試 - 輔助方法
- 優雅的 RSpec 測試 - 測試替身
- 優雅的 RSpec 測試 - Mock 與 Stub
- 優雅的 RSpec 測試 - Allow 的使用方式
- 優雅的 RSpec 測試 - Allow 變化應用
- 優雅的 RSpec 測試 - Spy 的應用
- 優雅的 RSpec 測試 - 物件的可測試性
- 優雅的 RSpec 測試 - 耦合與依賴注入
- 優雅的 RSpec 測試 - 探索式的測試與重構