---
title: "優雅的 RSpec 測試 - 探索式的測試與重構"
date: 2023-05-19T00:00:00+08:00
publishDate: 2023-05-19T00:00:00Z
lastmod: 2023-09-03T14:30:07+08:00
tags: ["RSpec","教學","測試","探索","重構"]
series: "elegant-rspec"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/05/19/elegant-rspec-exploration-testing-and-refactor/"
language: "zh-tw"
---


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

<!--more-->

## 自上而下{#top-down}

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

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

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

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

## 案例分析{#case-study}

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

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

```ruby
class Battle::ActionsController < API::BaseController
  before_action :find_battle

  # 採取行動
  def create
    @action = ActionForm.new(params)

    # 行動判定
    res = case @action.type
          when :attack
            @battle.attack_by(current_player, @action.target)
          when :item
            @battle.use_item_by(current_player, @action.item)
          when :escape
            @battle.escape_by(current_player)
          else
            false
          end

    return invalid_action if res == false

    # 電腦對應行動，完成一回合攻防
    ai_res = @battle.take_action_by_ai
    return invalid_action if res == false

    render json: {
      result: [
        res,
        ai_res
      ]
    }
  end
end
```

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

### Controller 測試{#test-for-controller}

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

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

```ruby
RSpec.describe 'Battle Action', type: :request do
  # ...
  subject(:response_json) { JSON.parse(response.body) }

  before { post '/api/battle/actions', params: params }

  context 'when take attack action' do
    let(:params) do
      {
        action: {
          type: :attack,
          target: 0
        }
      }
    end

    it { is_expected.to include({'result' => include(/玩家造成 \d+ 點傷害/)}) }
    # 使用自訂匹配器（推薦）
    it { is_expected.to have_action_result(/玩家造成 \d+ 點傷害/)}
  end
  # ...
end
```

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

### 分離邏輯{#extract-logic}

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

```ruby
class Battle::ActionsController < API::BaseController
  before_action :find_battle

  # 採取行動
  def create
    action = ActionForm.by_type(params)
    # => #<AttackAction:0x00007fadea0c9820 args=[0]>

    ActiveRecord::Base.transaction do
      result = [
        @battle.take_action!(current_player, action)
        @battle.take_action_by_ai!
      ]
    end

    render json: {
      result: result
    }

  # 可以使用 rescue_from 處理
  rescue Battle::PlayerActionFailed,
         Battle::AIActionFailed
    render invalid_action
  end
end
```

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

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

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

### 重構邏輯{#refactor-logic}

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

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

```ruby
    # 行動判定
    res = case @action.type
          when :attack
            @battle.attack_by(current_player, @action.target)
          when :item
            @battle.use_item_by(current_player, @action.item)
          when :escape
            @battle.escape_by(current_player)
          else
            false
          end
```

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

```ruby
class Battle < ApplicationRecord
  # ...
  def escape_by(player, *args)
    # ...
  end
end
```

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

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

```ruby
class Battle < ApplicationRecord
  # ...
  def take_action!(player, action)
    res = case action
          when AttackAction then attack_by(player, *action.args)
          when ItemAction then use_item_by(player, *action.args)
          when EscapeAction then escape_by(player, *action.args)
          else
            false
          end
    raise PlayerActionFailed if res == false
  end
end
```

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

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

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

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

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

---

如果想在第一時間收到更新，歡迎[訂閱弦而時習之](https://mailchi.mp/aotoki/graceful-rspec)在這系列文章更新時收到通知，如果有希望了解的知識，可以利用[優雅的 RSpec 測試回饋表單](https://us4.list-manage.com/survey?u=dd3d68032c0510041f1302539&id=5ddf86cae1&attribution=false)告訴我。

