正文:
現在需要你寫一個程式,從 3 開始依次向下,當到 0 時列印 「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) }
先寫測試
透過花費一些時間讓整個流程正確執行,我們就可以安全且輕鬆的迭代我們的解決方案。我們將不再需要停止並重新執行程式,要對它的工作充滿信心因為所有的邏輯都被測試過了。
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 秒鐘感到滿意呢?
-
我們還沒有測試這個函式的一個重要屬性。
Sleep
ing 的注入,需要抽離出來然後我們才可以在測試中控制它。time.Sleep
,我們可以用 依賴注入 的方式去來代替「真正的」time.Sleep
,然後我們可以使用斷言 監視呼叫先寫測試
讓我們將依賴關係定義為一個介面。這樣我們就可以在 main
使用 真實的 Sleeper
,並且在我們的測試中使用 spy sleeper。透過使用介面,我們的 Countdown
函式忽略了這一點,併為呼叫者增加了一些靈活性。
type Sleeper interface { Sleep() }
Countdown
函式將不會負責 sleep
的時間長度。 這至少簡化了我們的程式碼,也就是說,我們函式的使用者可以根據喜好配置休眠的時長。type SpySleeper struct { Calls int } func (s *SpySleeper) Sleep() { s.Calls++ }
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) }
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.writer
和 Sleeper
,把每一次呼叫記錄到 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 是有害的」
這通常是您測試太多 實現細節 的標誌。盡力克服這個問題,所以你的測試將測試 有用的行為,除非這個實現對於系統執行非常重要。
有時候很難知道到底要測試到 什麼級別,但是這裡有一些我試圖遵循的思維過程和規則。
-
重構的定義是程式碼更改,但行為保持不變。 如果您已經決定在理論上進行一些重構,那麼你應該能夠在沒有任何測試更改的情況下進行提交。所以,在寫測試的時候問問自己。
-
我是在測試我想要的行為還是實現細節?
-
如果我要重構這段程式碼,我需要對測試做很多修改嗎?
-
雖然 Go 允許你測試私有函式,但我將避免它作為私有函式與實現有關。
-
我覺得如果一個測試 超過 3 個模擬,那麼它就是警告 —— 是時候重新考慮設計。
- 小心使用監視器。監視器讓你看到你正在編寫的演算法的內部細節,這是非常有用的,但是這意味著你的測試程式碼和實現之間的耦合更緊密。如果你要監視這些細節,請確保你真的在乎這些細節。
總結
-
當面對不太簡單的例子,把問題分解成「簡單的模組」。試著讓你的工作軟體儘快得到測試的支援,以避免掉進兔子洞(rabbit holes,意指未知的領域)和採取「最終測試(Big bang)」的方法。
-
一旦你有一些正在工作的軟體,小步迭代