---
title: "可以測試的規格 - Rails 開發實踐"
date: 2023-07-28T00:00:00+08:00
publishDate: 2023-07-28T00:00:00+08:00
lastmod: 2023-09-03T17:33:12+08:00
tags: ["規格","測試","經驗","心得","Rails","Rails 開發實踐"]
series: "rails-in-practice"
toc: true
permalink: "https://blog.aotoki.me/posts/2023/07/28/rails-in-practice-testable-specification/"
language: "zh-tw"
---


當我們有了關鍵案例（Key Examples）後，規格已經相對的明確，如果還能夠被自動化測試的話，是不是一件更好的事情呢？我們可以利用 Cucumber 來實踐 E2E Testing（端對端測試）讓我們模擬使用者實際操作來驗證規格的完善。

<!--more-->

## 意外的快{#fast}

相對於 Unit Test（單元測試）過去我對 Cucumber 的印象就是準備起來非常耗時，這是因為我們需要寫成一般人可以閱讀的文件，一步一步的描述操作，然後再去實現每一個步驟的動作，然而熟悉之後其實比想像中還更快。

實踐測試最為困難的一個地方，其實是「知道該測什麼」也就是當你不清楚要對什麼做測試的時候，會感受到「這樣做是否有用嗎？」的感覺，也很難直覺地寫出測試，然而從規格下手，測試的目的就非常的明確，其實不用思考太多就能夠完成定義。

當 E2E Testing 的測試完成後，我們逐步拆解的物件其實就是單元測試的對象，如此一來在重構過程中補上單元測試，就很自然的將一個完整的測試加入到軟體中，隨著測試的增加反而會覺得開發速度越來越快速，因為很多需要手動檢查或者「邏輯卡關」的部分都會被測試阻擋下來。

## 基本語法{#basic-syntax}

這系列的文章不會特別說明語法的應用，我會在未來新增一系列針對 Cucumber 測試的文章來補足這部分，這次我們會在文章中將差例子來作為示範。

想要實現符合「蒼時在 2023-01-01 訂閱後，畫面上會顯示 2023-01-30 到期的訊息」這條規格，我們可以像這樣撰寫。

```gherkin
# features/subscription.feature
# language:zh-TW
功能: 會員訂閱
  背景:
    假設 這裡有一些會員
      | name   | email              |
      | Aotoki | aotoki@example.com |
    並且 作為使用者 Aotoki 登入

  場景: 當 Aotoki 在當下進行訂閱，會看到 30 天後到期的訊息
    當 我打開訂閱頁面
    並且 點選 "訂閱"
    並且 我打開訂閱狀態頁面
    那麼 我會看到 "30 天後到期"
```

在上面這個 Cucumber 的規格定義中，我們會看到一些關鍵字，像是 `功能`、`背景` 這類，這些是 Cucumber 用來描述行為跟步驟的關鍵字，剩下的部分基本上都是自訂的「步驟」像是 `這裡有一些會員` 和 `點選 "訂閱"` 等等。

以下是關鍵字的簡介，詳細的資訊可以參考 [Cucumber 文件](https://cucumber.io/docs/gherkin/languages/)的說明。

| 關鍵字 | 說明 |
|--------|------|
| 功能 | 定義是什麼功能，建議細一點。如：「新增文章」 |
| 背景 | 所有場景執行之前要先執行的步驟 |
| 場景 | 可以視為對應一條 Key Examples，描述想要得到怎樣的效果 |
| 假設 | 執行動作之前的測試資料準備，像是預先建立好的會員、訂閱方案 |
| 當 | 實際上執行的動作，通常是使用這個功能的行為 |
| 那麼 | 結果，驗證動作執行完畢後有得到對應的結果 |
| 並且 | 連接詞，如果這個步驟跟上一個相同就可以使用 |

> 這裡在規格測試時把日期（2023-01-30）改為相對時間 30 天前，這是因為 E2E Testing 是模擬真實使用者操作，比起修改到「指定時間」的造假方式，更應該選擇不需要模擬的方案，同時 30 天後到期也更能反應「每次延長 30 天」的規格，如果沒有實際撰寫可能不會發現這個情況。

## 步驟定義{#step-definitation}

因為我們讓規格可以用人類能夠理解的方式定義，因此需要讓這些文件的步驟可以被程式執行，Cucumber 會幫助我們解析裡面有意義的關鍵字作為參數（Parameter）然而我們還是需要撰寫對應的描述。

```ruby
# features/step_definitions/subscription.rb

Given('這裡有一些會員') do |table|
  @users ||= {}
  @passwords ||= {}

  table.hashes.each do |row|
    name = row.delete('name')
    @passwords[name] = Devise.friendly_token[0..20]
    @users[name] = User.create!(
      row.merge(password: @passwords[name])
    ).id
  end
end
```

基本上不太需要依靠 FactoryBot 來建立資料，雖然這樣寫起來有點不太好看，然而大多數時候是足夠使用的，一些會被參考的物件，可以先用實例變數暫存起來。

> 在 Cucumber 的設計中，整個測試是一個世界（World）裡面會有許多不同的場景，也因此跟 Ruby 常見的 RSpec 每次都重設所有測試環境不太一樣，實例變數（Instance Variable）會是共通的，不過我們還是可以透過一些簡單的處理清除掉測試資料。

```ruby
# features/step_definitions/subscription.rb

# ...

Given('作為使用者 {word} 登入') do |name|
  visit new_user_session_path
  fill_in 'user_email', with: User.find(@users[name]).email
  fill_in 'user_password', with: @passwords[name]
  click_on '登入'
end

When('我打開訂閱頁面') do
  visit plans_path
end

When('我打開訂閱狀態頁面') do
  visit subscriptions_path
end

When('點選 {string}') do |text|
  click_on text
end

Then('我會看到 {string}') do |text|
  expect(page).to have_text(text)
end
```

其他的步驟以此類推去實作，雖然 Cucumber 有用中文實作像是 `當('我打開訂閱頁面')` 這樣的方法，但還是會推薦使用英文撰寫，步驟內的內容大多是以 RSpec 和 Capybara 為基礎的，這是因為 Cucumber 是以 RSpec 為基礎拓展，某方面來說可以視為 RSpec 的延伸。

> 稍微習慣 Cucumber 後，其實會發現只是將以往寫 RSpec 的部分重新改寫成數個小步驟，然後透過 Cucumber 的功能定義檔案組合起來。

