← 上一章:寫測試讓你更有信心 Part 1 下一章:Rails 程式碼整理術(入門) →
寫測試讓你更有信心 Part 2
前面介紹了什麼是測試,接下來就讓我們捲起袖子,動手寫測試吧!
哥寫的不是測試,是規格
客人:「我想要做一個銀行帳戶系統,很簡單的,只要可以存錢、領錢以及顯示餘額就行了」
雖然客人開的需求有點「簡單」(而且你會發現客人的需求總是一開始很簡單),但這個時候先不急著電腦打開就開始寫程式,讓我們先來把規格用文字寫下來吧:
- 存錢功能
- 原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元
- 原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)
- 領錢功能
- 原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元
- 原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)
- 原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)
以上這些就是客人期望的「規格」。再次強調一次,我們不是在「為了寫測試而寫測試」,而是在「寫規格」,然後藉由一步一步滿足這些規格的過程來完成系統功能。
安裝 RSpec
在 Ruby/Rails 的世界有好幾套測試用的框架,目前比較受歡迎的主要有 minitest
跟 RSpec
,這邊我們將使用 RSpec 來做介紹。選擇 RSpec 主要是因為它的語法較為豐富,相關資源也較多。要安裝 RSpec 只要一行:
$ gem install rspec
Successfully installed rspec-3.5.0
Parsing documentation for rspec-3.5.0
Installing ri documentation for rspec-3.5.0
Done installing documentation for rspec after 0 seconds
1 gem installed
這樣就行了。
把規格轉成測試
接下來,我們要把上面寫的規格轉成程式碼。
因為這不是一個 Rails 專案,所以不是用前面介紹的 rails new PROJECT_NAME
方式產生專案。請找一個你喜歡的地方,隨便開一個資料夾,並在這個資料夾裡新增一個名為 bank_account_spec.rb
的檔案,然後試著把上面的「規格」轉換成「測試」:
# 檔案:bank_account_spec.rb
RSpec.describe BankAccount do
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元"
it "原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)"
end
describe "領錢功能" do
it "原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元"
it "原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)"
it "原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)"
end
end
說明:
- 檔名不一定要叫這個名字,只是習慣上會在要測試的對象的後面加上
_spec
或是_test
以表示這是測試用的檔案。 - 這只有先把要測試的方向用
it
方法列出來而已,還沒開始寫。 - 可以用中文沒關係,重點是清楚就好。
紅燈停、綠燈行
TDD 的流程,大概是一個「紅綠燈」的概念:
- 先寫規格(測試),執行它,這時候一定會發生錯誤(紅燈)。
- 實作功能,想辦法通過第 1 步發生錯誤的測試(綠燈)。
- 回到第 1 步。
測試失敗!(紅燈)
接下來使用 rspec
程式來執行一下剛剛寫的「規格」:
$ rspec bank_account_spec.rb
An error occurred while loading ./bank_account_spec.rb.
Failure/Error:
RSpec.describe BankAccount do
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元"
it "原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)"
end
describe "領錢功能" do
it "原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元"
it "原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)"
it "原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)"
NameError:
uninitialized constant BankAccount
# ./bank_account_spec.rb:1:in `<top (required)>'
No examples found.
Finished in 0.00039 seconds (files took 0.15952 seconds to load)
0 examples, 0 failures, 1 error occurred outside of examples
咦?發生錯誤了!如果你是第一次接觸 TDD 開發流程,可能會驚訝「哇!好多錯誤訊息啊!這怎麼解決?」
請不要擔心,也請大家要開始習慣,在寫測試的時候,這樣的錯誤是很常見的,甚至應該說這是正常的。仔細閱讀錯誤訊息,真正的錯誤訊息是 uninitialized constant BankAccount
,表示還沒有 BankAccount
這個類別(事實上是「常數」)。這是當然的,因為我們根本還沒寫,這時候執行測試如果會過,如果不是你有養小精靈幫你寫程式,就是你在做夢還沒醒。
想辦法解決錯誤(綠燈)
我們來把 BankAccount
類別做出來吧,在同一個目錄下新增一個名為 bank_account.rb
的檔案,內容如下:
class BankAccount
end
再回到剛剛的 bank_account_spec.rb
的第一行加上:
require "./bank_account"
require
可以引入這個檔案的內容。接著,再執行一次測試:
$ rspec bank_account_spec.rb
*****
Pending: (Failures listed here are expected and do not affect your suite's status)
1) BankAccount 存錢功能 原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元
# Not yet implemented
# ./bank_account_spec.rb:6
...[略]...
5) BankAccount 領錢功能 原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)
# Not yet implemented
# ./bank_account_spec.rb:13
Finished in 0.00566 seconds (files took 0.16392 seconds to load)
5 examples, 0 failures, 5 pending
雖然我們的測試裡面還沒有寫任何的內容,但至少沒有剛剛的錯誤訊息了。因為前面使用 it
方法「描述」了規格,it
方法如果後面沒有加上 Block,預設會是 pending
狀態,所以這邊會發現有五個 pending
狀態的測試提醒你記得要補上測試。
另外,RSpec 的測試結果主要有三種,一個點 .
表示測試成功,F
表示測試失敗,而 *
則表示 pending
狀態。所以上面的測試結果會是 *****
,正是代表 RSpec 跑了五個測試而且狀態是 pending
的意思;如果出現的是 .F...
,則是表示執行了五個測試,而這五個測試裡的第二個測試是有問題的,其它四個是正確的。
繼續寫測試(紅燈)
require "./bank_account"
RSpec.describe BankAccount do
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元" do
account = BankAccount.new(10)
account.deposit 5
expect(account.balance).to be 15
end
it "原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)"
end
describe "領錢功能" do
# ... [略] ...
end
end
等等!那個 deposit
跟 balance
方法是哪來的?其實現在這個還沒寫,我們只是先假設(或期待)這個類別有這些功能,而且最後算出來的餘額數字是對的。
「明明功能都還沒寫,是要寫什麼測試啊」這樣的疑惑,應該算是對 TDD 新手來說最難想像的部份了。事實上,你可以把它當做是一種「假設」或「期待」,先假設它存在並期待它可以正常運作。不用想太多,就放心的假設吧,在公堂之上大膽假設一下,根據大清律例也是沒有罪的。
這時候執行測試,沒意外的話應該會出錯:
rspec bank_account_spec.rb
F****
Pending: (Failures listed here are expected and do not affect your suite's status)
...[略]...
Failures:
1) BankAccount 存錢功能 原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元
Failure/Error: account = BankAccount.new(10)
ArgumentError:
wrong number of arguments (given 1, expected 0)
# ./bank_account_spec.rb:7:in `initialize'
# ./bank_account_spec.rb:7:in `new'
# ./bank_account_spec.rb:7:in `block (3 levels) in <top (required)>'
Finished in 0.00811 seconds (files took 0.20113 seconds to load)
5 examples, 1 failure, 4 pending
Failed examples:
rspec ./bank_account_spec.rb:6 # BankAccount 存錢功能 原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元
果然出錯了!有錯才是正確的,因為我們根本還沒寫真正的功能,這個測試只是先「假設」我們有這些功能而已。
繼續想辦法解決錯誤(綠燈)
好啦,既然知道這個 BankAccount
類別應該要有 deposit
跟 balance
功能,那就真的來寫吧。回到 bank_account.rb
,先讓我們把上面需要的功能寫出來:
class BankAccount
def initialize(amount)
@amount = amount
end
def balance
@amount
end
def deposit(amount)
@amount += amount
end
end
執行測試:
$ rspec bank_account_spec.rb
.****
Pending: (Failures listed here are expected and do not affect your suite's status)
...[略]...
Finished in 0.01317 seconds (files took 0.18159 seconds to load)
5 examples, 0 failures, 4 pending
過了!搞定一個測試,再讓我們繼續往下看下一個測試。
繼續寫測試(紅燈)
接下來是存錢功能的第二個測試:
require "./bank_account"
RSpec.describe BankAccount do
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元" do
# ...[略]...
end
it "原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)" do
account = BankAccount.new(10)
account.deposit -5
expect(account.balance).to be 10
end
end
describe "領錢功能" do
#...[略]...
end
end
提醒一下,在寫測試的時候,測試本身的程式碼不需要寫得很漂亮,只要寫得清楚就好。在我們加上「不能存入小於等於零的金額」的測試之後,繼續執行 rspec
:
$ rspec bank_account_spec.rb
.F***
Pending: (Failures listed here are expected and do not affect your suite's status)
...[略]...
Failures:
1) BankAccount 存錢功能 原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)
Failure/Error: expect(account.balance).to be 10
expected #<Integer:21> => 10
got #<Integer:11> => 5
Compared using equal?, which compares object identity,
but expected and actual are not the same object. Use
`expect(actual).to eq(expected)` if you don't care about
object identity in this example.
# ./bank_account_spec.rb:25:in `block (3 levels) in <top (required)>'
Finished in 0.04837 seconds (files took 0.17231 seconds to load)
5 examples, 1 failure, 3 pending
Failed examples:
rspec ./bank_account_spec.rb:22 # BankAccount 存錢功能 原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)
咦?又壞了!先想一下,為什麼測試會壞掉?
繼續想辦法解決錯誤(綠燈)
如果回去看我們 BankAccount
的 deposit
方法的實作就會發現,我們根本沒檢查傳入的金額是不是小於零。修正一下:
class BankAccount
def initialize(amount)
@amount = amount
end
def balance
@amount
end
def deposit(amount)
@amount += amount if amount > 0
end
end
在 deposit
方法裡加上了 if amount > 0
的判斷後,再執行一次測試:
$ rspec bank_account_spec.rb
..***
Pending: (Failures listed here are expected and do not affect your suite's status)
...[略]...
Finished in 0.02032 seconds (files took 0.20689 seconds to load)
5 examples, 0 failures, 3 pending
這樣就過了,而且之前的那個測試也沒壞。很好,就是維持這個手感,再讓我們繼續往下一個測試前進。
再繼續測試
避免拖太長的篇幅,我先一口氣把剩下的三個測試寫完:
require "./bank_account"
RSpec.describe BankAccount do
describe "存錢功能" do
# ... [略] ...
end
describe "領錢功能" do
it "原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元" do
account = BankAccount.new(10)
amount = account.withdraw 5
expect(amount).to be 5
expect(account.balance).to be 5
end
it "原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)" do
account = BankAccount.new(10)
amount = account.withdraw(20)
expect(amount).to be 0
expect(account.balance).to be 10
end
it "原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)" do
account = BankAccount.new(10)
amount = account.withdraw(-5)
expect(amount).to be 0
expect(account.balance).to be 10
end
end
end
這時候執行測試,想當然爾一定是會發生錯誤訊息的:
$ rspec bank_account_spec.rb
..FFF
Failures:
1) BankAccount 領錢功能 原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元
Failure/Error: amount = account.withdraw 5
NoMethodError:
undefined method `withdraw' for #<BankAccount:0x007fc4d69b8bb8 @amount=10>
# ./bank_account_spec.rb:32:in `block (3 levels) in <top (required)>'
2) BankAccount 領錢功能 原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)
Failure/Error: amount = account.withdraw(20)
NoMethodError:
undefined method `withdraw' for #<BankAccount:0x007fc4d69a8380 @amount=10>
# ./bank_account_spec.rb:39:in `block (3 levels) in <top (required)>'
3) BankAccount 領錢功能 原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)
Failure/Error: amount = account.withdraw(-5)
NoMethodError:
undefined method `withdraw' for #<BankAccount:0x007fc4d698b848 @amount=10>
# ./bank_account_spec.rb:46:in `block (3 levels) in <top (required)>'
Finished in 0.01013 seconds (files took 0.17782 seconds to load)
5 examples, 3 failures
Failed examples:
rspec ./bank_account_spec.rb:30 # BankAccount 領錢功能 原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元
rspec ./bank_account_spec.rb:37 # BankAccount 領錢功能 原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)
rspec ./bank_account_spec.rb:44 # BankAccount 領錢功能 原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)
實作領錢功能,繼續想辦法解決錯誤
BankAccount
的存錢功能實作如下:
class BankAccount
def initialize(amount)
@amount = amount
end
def balance
@amount
end
def deposit(amount)
@amount += amount if amount > 0
end
def withdraw(amount)
if amount > 0 && @amount >= amount
@amount -= amount
amount
else
0
end
end
end
執行一下測試:
$ rspec bank_account_spec.rb
.....
Finished in 0.0123 seconds (files took 0.21829 seconds to load)
5 examples, 0 failures
新增的三個測試全部都通過了,之前寫的存錢功能也沒有因此被弄壞。
整理重複的測試程式碼
雖然測試的程式碼寫得很重複或是不好看也沒關係,重點在於可以清楚的表達你想要測試的內容,但其實 RSpec 還是有提供一些好用的方法讓測試變得更清楚、易讀。
使用 before
整理常用的變數
觀察一下現在的測試,會發現每個測試幾乎都是先做出一個 BankAccount
的實體,然後測試該實體的功能是否正確。RSpec 提供一些方便的「鉤子」(Hook),可以在特定的時間點做一些事,例如 before(:each)
可以在執行每個測試之前先做某件事,before(:all)
則是可在所有測試之前做一次,我們前面的範例就可以稍微簡化成這樣:
RSpec.describe BankAccount do
before :each do
@account = BankAccount.new(10)
end
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元" do
@account.deposit 5
expect(@account.balance).to be 15
end
# ..[略]..
end
describe "領錢功能" do
it "原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元" do
amount = @account.withdraw 5
expect(amount).to be 5
expect(@account.balance).to be 5
end
# .. [略] ..
end
end
使用 before(:each)
就可以在每次執行之前做出一個 @account
的實體變數。
使用 let
雖然用 before
可以在測試開始之前先把需要的變數先準備好,但要用實體變數來寫還是覺得不太舒服,在 RSpec 可以使用 let
方法來改寫:
RSpec.describe BankAccount do
let(:account) { BankAccount.new(10) }
describe "存錢功能" do
it "原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元" do
account.deposit 5
expect(account.balance).to be 15
end
# ..[略]..
end
describe "領錢功能" do
it "原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元" do
amount = account.withdraw 5
expect(amount).to be 5
expect(account.balance).to be 5
end
# .. [略] ..
end
end
這樣一來就可以用原本類似區域變數的寫法了。其實只要去看一下 RSpec 的原始碼就會發現,這個 let
方法是動態的定義出一個叫做 account
的方法,並利用在 Ruby 呼叫方法的時候可以省略小括號的特性,用起來感覺就像在使用區域變數一樣。
更多 RSpec 的使用方法,可參考官網說明
小結
雖然這個例子可能有點簡單,但希望藉由這樣一連串的「紅綠燈」練習,可以讓大家更了解寫測試的手感,以及為什麼要寫測試。
因為在正式開工之前,先把規格好好的想清楚,可以讓開發者多想幾分鐘,不僅在類別及方法的命名上可以想到比較適合的名字,也因為我們的類別或方法也都是照著「規格」寫出來的,比較不會寫出多餘的類別或方法。
不過,測試全部都通過也不表示程式就完全不會有 Bug,只能說「目前的程式碼實作都有滿足現有的規格」。但藉由越完整的測試,除了可以減少 Bug 的出現,最重要的是可避免「修改完 A 功能結果 B 功能跟著壞掉」的問題。
在 Ruby/Rails 的世界,寫測試是算是業界很常見的標準技能,希望大家可以趕快習慣寫測試的「紅綠燈」手感,並藉由撰寫測試的過程讓你對你的程式碼實作更有信心!
Comments