Go 語言:透過TDD測試驅動開發學習 Mocking (模擬)的思想

slowlydance2me發表於2023-03-27

正文:

現在需要你寫一個程式,從 3 開始依次向下,當到 0 時列印 「GO!」 並退出,要求每次列印從新的一行開始且列印間隔一秒的停頓。

3
 
2
 
1
 
Go!
 
我們將透過編寫一個 Countdown 函式來處理這個問題,然後放入 main 程式,所以它看起來這樣:
package main

func main() {
    Countdown()
}
雖然這是一個非常簡單的程式,但要完全測試它,我們需要像往常一樣採用迭代的、測試驅動的方法。
所謂迭代是指:確保我們採取最小的步驟讓軟體可用。
我們不想花太多時間寫那些在被攻擊後理論上還能執行的程式碼,因為這經常導致開發人員陷入開發的無底深淵。盡你所能拆分需求是一項很重要的技能,這樣你就能擁有可以工作的軟體
下面是我們如何劃分工作和迭代的方法:
  •  列印 3
  •  列印 3 到 Go!
  • 在每行中間等待一秒

先寫測試

我們的軟體需要將結果列印到標準輸出介面。在 DI(依賴注入) 的部分,我們已經看到如何使用 DI 進行方便的測試。

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := "3"

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}
如果你對 buffer 不熟悉,請重新閱讀前面的部分。
我們清楚,我們的目的是讓 Countdown 函式將資料寫到某處,io.writer就是作為 Go 的一個介面來抓取資料的一種方式。
  • main 中,我們將資訊傳送到 os.Stdout,所以使用者可以看到 Countdown 的結果列印到終端
  •  在測試中,我們將傳送到 bytes.Buffer,所以我們的測試能夠抓取到正在生成的資料

嘗試並執行測試

./countdown_test.go:11:2: undefined: Countdown

為測試的執行編寫最少量的程式碼,並檢查失敗測試的輸出

定義 Countdown 函式

func Countdown() {}

 

再次嘗試執行

./countdown_test.go:11:11: too many arguments in call to Countdown
have (*bytes.Buffer)
want ()

 

編譯器正在告訴你函式的問題,所以更正它

func Countdown(out *bytes.Buffer) {}

countdown_test.go:17: got '' want '3'

這樣結果就完美了!

編寫足夠的程式碼使程式透過

func Countdown(out *bytes.Buffer) {
    fmt.Fprint(out, "3")
}

我們正在使用 fmt.Fprint 傳入一個 io.Writer(例如 *bytes.Buffer)併傳送一個 string。這個測試應該可以透過。

重構程式碼

雖然我們都知道 *bytes.Buffer 可以執行,但最好使用通用介面代替。

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}
重新執行測試他們應該就可以透過了。
為了完成任務,現在讓我們將函式應用到 main中。這樣的話,我們就有了一些可工作的軟體來確保我們的工作正在取得進展。
package main

import (
    "fmt"
    "io"
    "os"
)

func Countdown(out io.Writer) {
    fmt.Fprint(out, "3")
}

func main() {
    Countdown(os.Stdout)
}
嘗試執行程式,這些成果會讓你感到神奇。
當然,這仍然看起來很簡單,但是我建議任何專案都使用這種方法。
在測試的支援下,將功能切分成小的功能點,並使其首尾相連順利的執行。
接下來我們可以讓它列印 2,1 然後輸出「Go!」。
 

先寫測試

透過花費一些時間讓整個流程正確執行,我們就可以安全且輕鬆的迭代我們的解決方案。我們將不再需要停止並重新執行程式,要對它的工作充滿信心因為所有的邏輯都被測試過了。

func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}

    Countdown(buffer)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}

反引號語法是建立 string 的另一種方式,但是允許你放置東西例如放到新的一行,對我們的測試來說是完美的。

嘗試並執行測試

countdown_test.go:21: got '3' want '3
2
1
Go!'

寫足夠的程式碼令測試透過

func Countdown(out io.Writer) {
    for i := 3; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, "Go!")
}
使用 for 迴圈與 i-- 反向計數,並且用 fmt.println 列印我們的數字到 out,後面跟著一個換行符。最後用 fmt.Fprint 傳送 「Go!」。

 重構程式碼

這裡已經沒有什麼可以重構的了,只需要將變數重構為命名常量

const finalWord = "Go!"
const countdownStart = 3

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        fmt.Fprintln(out, i)
    }
    fmt.Fprint(out, finalWord)
}

如果你現在執行程式,你應該可以獲得想要的輸出,但是向下計數的輸出沒有 1 秒的暫停。

Go 可以透過 time.Sleep 實現這個功能。嘗試將其新增到我們的程式碼中。

func Countdown(out io.Writer) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

如果你執行程式,它會以我們期望的方式工作。

Mocking

測試可以透過,軟體按預期的工作。但是我們有一些問題:

  • 我們的測試花費了 4 秒的時間執行
  •  每一個關於軟體開發的前沿思考性文章,都強調快速反饋迴圈的重要性。
  •  緩慢的測試會破壞開發人員的生產力。
  •  想象一下,如果需求變得更復雜,將會有更多的測試。對於每一次新的 Countdown 測試,我們是否會對被新增到測試執行中 4 秒鐘感到滿意呢?
  •  我們還沒有測試這個函式的一個重要屬性。
我們有個 Sleeping 的注入,需要抽離出來然後我們才可以在測試中控制它。
如果我們能夠 mock time.Sleep,我們可以用 依賴注入 的方式去來代替「真正的」time.Sleep,然後我們可以使用斷言 監視呼叫

先寫測試

讓我們將依賴關係定義為一個介面。這樣我們就可以在 main 使用 真實的 Sleeper,並且在我們的測試中使用 spy sleeper。透過使用介面,我們的 Countdown 函式忽略了這一點,併為呼叫者增加了一些靈活性。

type Sleeper interface {
    Sleep()
}
我做了一個設計的決定,我們的 Countdown 函式將不會負責 sleep 的時間長度。 這至少簡化了我們的程式碼,也就是說,我們函式的使用者可以根據喜好配置休眠的時長。
現在我們需要為我們使用的測試生成它的 mock
type SpySleeper struct {
    Calls int
}

func (s *SpySleeper) Sleep() {
    s.Calls++
}
監視器(spies)是一種 mock,它可以記錄依賴關係是怎樣被使用的。它們可以記錄被傳入來的引數,多少次等等。在我們的例子中,我們跟蹤記錄了 Sleep() 被呼叫了多少次,這樣我們就可以在測試中檢查它。
更新測試以注入對我們監視器的依賴,並斷言 sleep被呼叫了 4 次。
func TestCountdown(t *testing.T) {
    buffer := &bytes.Buffer{}
    spySleeper := &SpySleeper{}

    Countdown(buffer, spySleeper)

    got := buffer.String()
    want := `3
2
1
Go!`

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }

    if spySleeper.Calls != 4 {
        t.Errorf("not enough calls to sleeper, want 4 got %d", spySleeper.Calls)
    }
}

嘗試並執行測試

too many arguments in call to Countdown
have (*bytes.Buffer, Sleeper)
want (io.Writer)

為測試的執行編寫最少量的程式碼,並檢查失敗測試的輸出

我們需要更新 Countdow 來接受我們的 Sleeper

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        time.Sleep(1 * time.Second)
        fmt.Fprintln(out, i)
    }

    time.Sleep(1 * time.Second)
    fmt.Fprint(out, finalWord)
}

如果您再次嘗試,你的 main 將不會出現相同編譯錯誤的原因

./main.go:26:11: not enough arguments in call to Countdown
have (*os.File)
want (io.Writer, Sleeper)

讓我們建立一個 真正的 sleeper 來實現我們需要的介面

type ConfigurableSleeper struct {
    duration time.Duration
}

func (o *ConfigurableSleeper) Sleep() {
    time.Sleep(o.duration)
}
我決定做點額外的努力,讓它成為我們真正的可配置的 sleeper。但你也可以在 1 秒內毫不費力地編寫它。
我們可以在實際應用中使用它,就像這樣:
 
func main() {
    sleeper := &ConfigurableSleeper{1 * time.Second}
    Countdown(os.Stdout, sleeper)
}

足夠的程式碼令測試透過

現在測試正在編譯但是沒有透過,因為我們仍然在呼叫 time.Sleep 而不是依賴注入。讓我們解決這個問題。

func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        sleeper.Sleep()
        fmt.Fprintln(out, i)
    }

    sleeper.Sleep()
    fmt.Fprint(out, finalWord)
}

測試應該可以該透過,並且不再需要 4 秒。

仍然還有一些問題

還有一個重要的特性,我們還沒有測試過。
Countdown 應該在第一個列印之前 sleep,然後是直到最後一個前的每一個,例如:
  •  Sleep
  •  Print N
  •  Sleep
  •  Print N-1
  •  Sleep
我們最新的修改只斷言它已經 sleep 了 4 次,但是那些 sleeps 可能沒按順序發生。
 當你在寫測試的時候,如果你沒有信心,你的測試將給你足夠的信心,儘管推翻它!(不過首先要確定你已經將你的更改提交給了原始碼控制)。將程式碼更改為以下內容。
func Countdown(out io.Writer, sleeper Sleeper) {
    for i := countdownStart; i > 0; i-- {
        sleeper.Sleep()
    }

    for i := countdownStart; i > 0; i-- {
        fmt.Fprintln(out, i)
    }

    sleeper.Sleep()
    fmt.Fprint(out, finalWord)
}
如果你執行測試,它們仍然應該透過,即使實現是錯誤的。
讓我們再用一種新的測試來檢查操作的順序是否正確。
我們有兩個不同的依賴項,我們希望將它們的所有操作記錄到一個列表中。所以我們會為它們倆建立 同一個監視器
type CountdownOperationsSpy struct {
    Calls []string
}

func (s *CountdownOperationsSpy) Sleep() {
    s.Calls = append(s.Calls, sleep)
}

func (s *CountdownOperationsSpy) Write(p []byte) (n int, err error) {
    s.Calls = append(s.Calls, write)
    return
}

const write = "write"
const sleep = "sleep"
我們的 CountdownOperationsSpy 同時實現了 io.writerSleeper,把每一次呼叫記錄到 slice。在這個測試中,我們只關心操作的順序,所以只需要記錄操作的代名片語成的列表就足夠了。
 現在我們可以在測試套件中新增一個子測試。
t.Run("sleep after every print", func(t *testing.T) {
    spySleepPrinter := &CountdownOperationsSpy{}
    Countdown(spySleepPrinter, spySleepPrinter)

    want := []string{
        sleep,
        write,
        sleep,
        write,
        sleep,
        write,
        sleep,
        write,
    }

    if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
        t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
    }
})
現在這個測試應該會失敗。恢復原狀新測試應該又可以透過。
我們現在在 Sleeper 上有兩個測試監視器,所以我們現在可以重構我們的測試,一個測試被列印的內容,另一個是確保我們在列印時間 sleep。最後我們可以刪除第一個監視器,因為它已經不需要了。
func TestCountdown(t *testing.T) {

    t.Run("prints 3 to Go!", func(t *testing.T) {
        buffer := &bytes.Buffer{}
        Countdown(buffer, &CountdownOperationsSpy{})

        got := buffer.String()
        want := `3
2
1
Go!`

        if got != want {
            t.Errorf("got '%s' want '%s'", got, want)
        }
    })

    t.Run("sleep after every print", func(t *testing.T) {
        spySleepPrinter := &CountdownOperationsSpy{}
        Countdown(spySleepPrinter, spySleepPrinter)

        want := []string{
            sleep,
            write,
            sleep,
            write,
            sleep,
            write,
            sleep,
            write,
        }

        if !reflect.DeepEqual(want, spySleepPrinter.Calls) {
            t.Errorf("wanted calls %v got %v", want, spySleepPrinter.Calls)
        }
    })
}

我們現在有了自己的函式,並且它的兩個重要的屬性已經透過合理的測試。

 

難道 mocking 不是在作惡(evil)嗎?

你可能聽過 mocking 是在作惡。就像軟體開發中的任何東西一樣,它可以被用來作惡,就像 DRY(Don't repeat yourself) 一樣。
當人們 不聽從他們的測試 並且 不尊重重構階段時,他們通常會陷入糟糕的境地。
如果你的模擬程式碼變得很複雜,或者你需要模擬很多東西來測試一些東西,那麼你應該 傾聽 那種糟糕的感覺,並考慮你的程式碼。通常這是一個徵兆:
  • 你正在進行的測試需要做太多的事情
     
  •  把模組分開就會減少測試內容
  •  它的依賴關係太細緻
     
  •  考慮如何將這些依賴項合併到一個有意義的模組中
  • 你的測試過於關注實現細節
  •  最好測試預期的行為,而不是功能的實現
通常,在你的程式碼中有大量的 mocking 指向 錯誤的抽象
人們在這裡看到的是測試驅動開發的弱點,但它實際上是一種力量 通常情況下,糟糕的測試程式碼是糟糕設計的結果,而設計良好的程式碼很容易測試。
 

但是模擬和測試仍然讓我舉步維艱!

曾經遇到過這種情況嗎?
  •  你想做一些重構
  •  為了做到這一點,你最終會改變很多測試
  • 你對測試驅動開發提出質疑,並在媒體上發表一篇文章,標題為「Mocking 是有害的」

這通常是您測試太多 實現細節 的標誌。盡力克服這個問題,所以你的測試將測試 有用的行為,除非這個實現對於系統執行非常重要。

有時候很難知道到底要測試到 什麼級別,但是這裡有一些我試圖遵循的思維過程和規則。

  • 重構的定義是程式碼更改,但行為保持不變。 如果您已經決定在理論上進行一些重構,那麼你應該能夠在沒有任何測試更改的情況下進行提交。所以,在寫測試的時候問問自己。
     
  •  我是在測試我想要的行為還是實現細節?
  •  如果我要重構這段程式碼,我需要對測試做很多修改嗎?
  •  雖然 Go 允許你測試私有函式,但我將避免它作為私有函式與實現有關。 
  • 我覺得如果一個測試 超過 3 個模擬,那麼它就是警告 —— 是時候重新考慮設計。
  •  小心使用監視器。監視器讓你看到你正在編寫的演演算法的內部細節,這是非常有用的,但是這意味著你的測試程式碼和實現之間的耦合更緊密。如果你要監視這些細節,請確保你真的在乎這些細節。
和往常一樣,軟體開發中的規則並不是真正的規則,也有例外。Uncle Bob 的文章 「When to mock」 有一些很好的指南。

總結

更多關於測試驅動開發的方法:
  •  當面對不太簡單的例子,把問題分解成「簡單的模組」。試著讓你的工作軟體儘快得到測試的支援,以避免掉進兔子洞(rabbit holes,意指未知的領域)和採取「最終測試(Big bang)」的方法。
  •  一旦你有一些正在工作的軟體,小步迭代
 Mocking:
  一旦開發人員學會了 mocking,就很容易對系統的每一個方面進行過度測試,按照 它工作的方式 而不是 它做了什麼。始終要注意 測試的價值,以及它們在將來的重構中會產生什麼樣的影響。
在這篇關於 mocking 的文章中,我們只提到了 監視器(Spies),他們是一種 mock。也有不同型別的 mocks。Uncle Bob 的一篇極易閱讀的文章中解釋了這些型別。在後面的章節中,我們將需要編寫依賴於其他資料的程式碼,屆時我們將展示 Stubs 行為

相關文章