← 上一章:寫測試讓你更有信心 Part 1 下一章:Rails 程式碼整理術(入門) →

寫測試讓你更有信心 Part 2

前面介紹了什麼是測試,接下來就讓我們捲起袖子,動手寫測試吧!

哥寫的不是測試,是規格

客人:「我想要做一個銀行帳戶系統,很簡單的,只要可以存錢、領錢以及顯示餘額就行了」

雖然客人開的需求有點「簡單」(而且你會發現客人的需求總是一開始很簡單),但這個時候先不急著電腦打開就開始寫程式,讓我們先來把規格用文字寫下來吧:

  • 存錢功能
    • 原本帳戶有 10 元,存入 5 元之後,帳戶餘額變 15 元
    • 原本帳戶有 10 元,存入 -5 元之後,帳戶餘額還是 10 元(不能存入小於等於零的金額)
  • 領錢功能
    • 原本帳戶有 10 元,領出 5 元之後,帳戶餘額變 5 元
    • 原本帳戶有 10 元,試圖領出 20 元,帳戶餘額還是 10 元,但無法領出(餘額不足)
    • 原本帳戶有 10 元,領出 -5 元之後,帳戶餘額還是 10 元(不能領出小於或等於零的金額)

以上這些就是客人期望的「規格」。再次強調一次,我們不是在「為了寫測試而寫測試」,而是在「寫規格」,然後藉由一步一步滿足這些規格的過程來完成系統功能。

安裝 RSpec

在 Ruby/Rails 的世界有好幾套測試用的框架,目前比較受歡迎的主要有 minitestRSpec,這邊我們將使用 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

說明:

  1. 檔名不一定要叫這個名字,只是習慣上會在要測試的對象的後面加上 _spec 或是 _test 以表示這是測試用的檔案。
  2. 這只有先把要測試的方向用 it 方法列出來而已,還沒開始寫。
  3. 可以用中文沒關係,重點是清楚就好。

紅燈停、綠燈行

TDD 的流程,大概是一個「紅綠燈」的概念:

  1. 先寫規格(測試),執行它,這時候一定會發生錯誤(紅燈)。
  2. 實作功能,想辦法通過第 1 步發生錯誤的測試(綠燈)。
  3. 回到第 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

等等!那個 depositbalance 方法是哪來的?其實現在這個當下還沒寫,我們只是先假設(或期待)這個類別有這些功能,而且在最後算出來的餘額數字是對的。

「明明功能都還沒寫,是要寫什麼測試啊」這樣的疑惑,應該算是對 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 類別應該要有 depositbalance 功能,那就真的來寫吧。回到 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 元(不能存入小於等於零的金額)

咦?又壞了!先想一下,為什麼測試會壞掉?

繼續想辦法解決錯誤(綠燈)

如果回去看我們 BankAccountdeposit 方法的實作就會發現,我們根本沒檢查傳入的金額是不是小於零。修正一下:

  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 的世界,寫測試是算是業界很常見的標準技能,希望大家可以趕快習慣寫測試的「紅綠燈」手感,並藉由撰寫測試的過程讓你對你的程式碼實作更有信心!

← 上一章:寫測試讓你更有信心 Part 1 下一章:Rails 程式碼整理術(入門) →

Comments