前面的章節已經做出 Command、Flag、輸出格式等功能,從這篇開始進入測試,用測試來確保 CLI 的輸入與輸出行為。也避免在重構 Flag 解析、調整輸出格式或新增 Subcommand 時改壞了 CLI。
CLI 的公開介面由三個要素組成,接著我們就以這三項來測試:
jq 解析時失敗)。0,失敗回傳非 0。這決定了 CI/CD 或 Shell 腳本(如 set -e)能否在命令出錯時正確中斷。在建立 CLI 的輸入與輸出測試前,先從最簡單的 Go 函式開始理解測試運作機制。以 add.go 的加法函式為例:
package main
func add(left, right int) int {
return left + right
}
在同一個 package 建立 add_test.go,使用 Testify 的 assert package 撰寫測試。Go 標準函式庫內建了 testing package,會自動尋找檔名以 _test.go 結尾的檔案,並執行名稱為 TestXxx 且接收 *testing.T 的函式。
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAdd(t *testing.T) {
actual := add(2, 3)
expected := 5
assert.Equal(t, expected, actual)
}
寫完測試後,在終端機執行 go test:
$ go test . -run TestAdd -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
-run TestAdd 指定只執行名稱符合 TestAdd 的測試,-v(verbose)則會印出詳細執行過程與每個測試的結果。若要執行整個專案目錄下的所有測試,可以使用 go test ./...。
除了 assert,Testify 還有幾個常用的 package(例如 require 與 suite)。
assert 與 require 的差別在於驗證失敗時的中斷機制:
assert:驗證失敗時透過 t 記錄錯誤,但會繼續執行後續程式碼。適合用在彼此獨立的欄位或輸出比對。require:驗證失敗時記錄錯誤並立刻停止目前的測試(觸發 t.FailNow())。適合驗證後續步驟依賴的前置條件。例如在執行 CLI Command 時,若執行過程出錯,繼續檢查 stdout 或 stderr 就沒有意義,這時用 require.NoError 能確保程式出錯時立刻中斷:
// 若 err != nil,require.NoError 會印出錯誤並立刻停止測試,
// 避免後續程式碼存取無效資料或引發 panic。
err := command.Execute()
require.NoError(t, err)
// 前置條件通過後,再用 assert 檢查輸出
assert.Equal(t, "Hello, Evan\n", stdout.String())
assert.Empty(t, stderr.String())
當多個測試案例需要共用初始狀態或生命週期(例如在每個測試執行前建立臨時目錄,或在測試後刪除檔案)時,suite package 能將測試關聯的變數與步驟封裝進 Go struct 中。透過設定 SetupTest ,Testify 會在執行 struct 內的每一個測試方法前自動重置狀態,避免不同測試之間相互干擾:
package main
import (
"testing"
"github.com/stretchr/testify/suite"
)
type AddTestSuite struct {
suite.Suite
base int
}
// SetupTest 會在 Suite 中的每一個 TestXxx 方法執行前自動被呼叫
func (s *AddTestSuite) SetupTest() {
s.base = 10
}
func (s *AddTestSuite) TestAddWithBase() {
// Suite 內建了 assert 與 require 方法,可以直接呼叫 s.Equal
s.Equal(15, s.base+add(2, 3))
}
// 將 Suite 接回 Go 標準 testing 框架的進入點
func TestAddTestSuite(t *testing.T) {
suite.Run(t, new(AddTestSuite))
}
掌握了 Testify 的基礎後,接下來進入 Cobra Command 的單元測試。
以一個單純的 say Command 為例,當使用者傳入名字 Arg,這個 Command 會將問候語輸出到 stdout。實作位於 cmd/say.go:
func newSayCmd() *cobra.Command {
command := &cobra.Command{
Use: "say <name>",
Args: cobra.ExactArgs(1),
RunE: func(command *cobra.Command, args []string) error {
_, err := fmt.Fprintf(command.OutOrStdout(), "Hello, %s\n", args[0])
return err
},
}
return command
}
在測試中直接呼叫 newSayCmd() 時。為了驗證這個 Command 的行為,需要解決兩個問題:如何傳入輸入參數,以及如何攔截輸出內容。
Cobra 允許我們在測試中接管輸入與輸出:
SetArgs 直接指定 CLI 的參數陣列,不需要經由終端機輸入。SetOut 與 SetErr 把原本要印到終端機螢幕的 stdout 與 stderr 改接到記憶體中的 bytes.Buffer,測試就能直接讀取 Buffer 裡的字串來驗證輸出內容。在 cmd/say_test.go 撰寫成功情境的測試:
// TestSayCommand 驗證 say command 會讀取名字 argument,並把問候語寫到 stdout。
func TestSayCommand(t *testing.T) {
// 建立獨立的 Command 實例,避免測試案例相互干擾
command := newSayCmd()
// 建立記憶體 Buffer 並接管原本要印到終端機螢幕的 stdout 與 stderr
var stdout bytes.Buffer
var stderr bytes.Buffer
command.SetOut(&stdout)
command.SetErr(&stderr)
// 指定傳入 CLI 的參數陣列,模擬使用者在終端機輸入的 argument
command.SetArgs([]string{"Evan"})
// 執行命令並確保過程沒有出錯(若 err != nil 則立刻中斷測試)
require.NoError(t, command.Execute())
// 驗證 stdout 輸出問候語,並確認 stderr 沒有殘留診斷訊息
assert.Equal(t, "Hello, Evan\n", stdout.String())
assert.Empty(t, stderr.String())
}
這個測試依序完成四件事:
newSayCmd() 建立獨立的 Command 實例,確保測試間不會互相干擾。bytes.Buffer 綁定至 SetOut 與 SetErr。SetArgs([]string{"Evan"}) 模擬使用者輸入,再呼叫 command.Execute() 執行。require.NoError 確保命令執行成功,再用 assert.Equal 驗證 stdout 是否為 Hello, Evan\n,並確認 stderr 沒有任何診斷訊息。其他常見輸入來源與對應的測試 API:
command.SetArgs([]string{"Evan"})
root.SetArgs([]string{"say", "--upper"})
command.SetIn(strings.NewReader("Evan\n"))
t.Setenv("APP_ENV", "test")
除了正常成功的情境,使用者也可能在未傳入名字的情況下執行 say。say Command 設定了 cobra.ExactArgs(1),當收到 0 個參數時,Cobra 會在執行 RunE 前驗證失敗,回傳錯誤訊息,並寫入 stderr。
在 cmd/say_test.go 新增 TestSayCommandRequiresName 驗證錯誤處理:
// TestSayCommandRequiresName 驗證缺少名字時,Cobra 會在執行 RunE 前回傳錯誤。
func TestSayCommandRequiresName(t *testing.T) {
command := newSayCmd()
// 關閉失敗時自動印出 Usage 說明,精確檢查錯誤訊息本身
command.SilenceUsage = true
var stdout bytes.Buffer
var stderr bytes.Buffer
command.SetOut(&stdout)
command.SetErr(&stderr)
// 傳入空陣列,模擬未提供 argument 的失敗情境
command.SetArgs([]string{})
err := command.Execute()
// 驗證回傳錯誤包含參數不足的訊息
require.ErrorContains(t, err, "accepts 1 arg(s), received 0")
// 確認業務邏輯未被觸發(stdout 為空),且錯誤訊息被寫入 stderr
assert.Empty(t, stdout.String())
assert.Contains(t, stderr.String(), "Error: accepts 1 arg(s), received 0")
}
測試的處理重點如下:
command.SetArgs([]string{}) 傳入空陣列,觸發參數數量檢查失敗。注意不要傳入 nil,否則 Cobra 會改為讀取測試 Process 的 os.Args,導致測試受到 go test 的執行參數影響。Execute() 取得錯誤,並用 require.ErrorContains 確認錯誤訊息包含失敗原因。assert.Contains 驗證 stderr 包含錯誤訊息。command.SilenceUsage = true 能避免 Cobra 預設將完整的 Usage 說明印到 stderr,讓測試能精確檢查錯誤訊息本身。在終端機執行測試:
$ cd cli-sample/cli-testing-basics
$ go test ./cmd -run TestSayCommand -v
=== RUN TestSayCommand
--- PASS: TestSayCommand (0.00s)
=== RUN TestSayCommandRequiresName
--- PASS: TestSayCommandRequiresName (0.00s)
PASS
在測試中呼叫 command.Execute(),本質上只是在同一個 Go Process 裡執行一個函式。這樣寫雖然執行極快,但無法驗證真實的 Exit Code:如果程式在錯誤時呼叫 os.Exit(1),會直接把整個 go test Process 關掉導致測試中斷;如果程式忘記回傳非 0 的 Exit Code,單元測試也無法察覺。
若使用 Go 標準函式庫的 os/exec 手動撰寫測試,必須自己處理 go build 編譯 binary、建立與清理臨時目錄,以及對接管道輸出,過程十分繁瑣。
testscript 是建立在 Go testing 框架上的 CLI 測試工具。它會自動在隔離的沙盒空間中編譯並執行 binary,並透過宣告式文字腳本依序執行命令,檢查 stdout、stderr、環境變數與 Exit Code。
testscript 測試分成 測試檔與腳本檔。先在 main.go 同一層新增 script_test.go,之後再把測試案例放進 testdata/script:
cli-testing-basics/
├── main.go
├── script_test.go
└── testdata/
└── script/
└── say.txtar
script_test.go 放入以下完整內容:
package main
import (
"testing"
"github.com/rogpeppe/go-internal/testscript"
)
func TestMain(m *testing.M) {
// 讓腳本中的 exec testable-cli 在獨立 process 呼叫本專案的 main。
testscript.Main(m, map[string]func(){
"testable-cli": main,
})
}
// TestCLI 執行 testdata/script 裡的所有 testscript 測試腳本。
func TestCLI(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/script",
RequireExplicitExec: true,
})
}
當我們執行 go test 時,這兩個函式的分工與流程如下:
TestMain(註冊命令):整個 package 的測試入口。將腳本會呼叫的命令名稱(如 testable-cli)映射到本專案的 main() 函式。TestCLI(執行腳本):讀取 testdata/script/ 目錄下的測試腳本。當腳本遇到 exec 時,會在獨立 Process 中啟動 main(),並自動驗證 stdout、stderr 與 Exit Code。前面的 script_test.go 負責建立測試進入點,而 testdata/script 目錄下的 .txtar 腳本(例如 say.txtar)才是真正撰寫測試案例的地方。新增 testdata/script/say.txtar,在腳本內寫入測試步驟:
# 執行 testable-cli say Evan,並要求以 exit code 0 結束
exec testable-cli say Evan
# 驗證 stdout 包含預期的問候語
stdout 'Hello, Evan\n'
# 驗證 stderr 完全為空(! 表示反向斷言,. 代表任何字元)
! stderr .
從 sample 目錄執行 say.txtar:
cd cli-sample/cli-testing-basics
go test . -run 'TestCLI/say$' -v
TestCLI 對應 script_test.go 的 Go 測試函式,say 則對應 testdata/script/say.txtar 的檔名。testscript 會依序執行該檔案裡的所有步驟。要執行 testdata/script 目錄內的全部腳本,使用 go test . -run TestCLI -v。
回到一開始的 Exit Code 問題。testdata/script/say.txtar 同時執行成功與輸入錯誤兩種情境:
# 提供名字時,exec 要求指令以 exit code 0 結束。
exec testable-cli say Evan
stdout 'Hello, Evan\n'
! stderr .
# 缺少名字時,! exec 要求指令以非 0 exit code 結束。
! exec testable-cli say
! stdout .
stderr 'accepts 1 arg\(s\), received 0'
testscript 預設以 exec 要求命令成功(Exit Code 為 0);加上 ! 則表示預期執行失敗(Exit Code 為非 0)。
因此 ! exec testable-cli say 代表預期 Command 會出錯;後續的 ! stdout . 與 stderr ... 則進一步驗證失敗時沒有輸出到 stdout,且 stderr 寫入了正確的錯誤訊息。
執行測試:
$ go test . -run TestCLI -v
=== RUN TestCLI
=== RUN TestCLI/say
--- PASS: TestSayCommand (0.00s)
--- PASS: TestCLI/say (0.00s)
PASS
當 CLI 開始處理檔案、系統時間、環境變數或 HTTP 請求等外部依賴時,下一篇將介紹如何透過介面與依賴注入,將核心邏輯與外部環境解耦並進行測試。