iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Software Development

30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用系列 第 28 篇

CLI 測試:從 Command 單元測試到 Process 驗證

  • 分享至 

  • xImage
  •  

前面的章節已經做出 Command、Flag、輸出格式等功能,從這篇開始進入測試,用測試來確保 CLI 的輸入與輸出行為。也避免在重構 Flag 解析、調整輸出格式或新增 Subcommand 時改壞了 CLI。

CLI 的公開介面由三個要素組成,接著我們就以這三項來測試:

  • 輸出頻道(stdout / stderr):執行結果寫入 stdout,錯誤與診斷訊息寫入 stderr。若除錯 log 混入 stdout,會直接破壞管道串接(例如接給 jq 解析時失敗)。
  • Exit Code:成功回傳 0,失敗回傳非 0。這決定了 CI/CD 或 Shell 腳本(如 set -e)能否在命令出錯時正確中斷。
  • 輸入組合:傳入不同的 Arg、Flag、stdin 或環境變數時,程式能否正確解析與回應。

先用 Go 測試與 Testify 建立驗證基礎

在建立 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))
}

測試 Cobra Command 的輸入與輸出

掌握了 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())
}

這個測試依序完成四件事:

  1. 每次測試呼叫 newSayCmd() 建立獨立的 Command 實例,確保測試間不會互相干擾。
  2. 建立 bytes.Buffer 綁定至 SetOut 與 SetErr。
  3. 透過 SetArgs([]string{"Evan"}) 模擬使用者輸入,再呼叫 command.Execute() 執行。
  4. 使用 require.NoError 確保命令執行成功,再用 assert.Equal 驗證 stdout 是否為 Hello, Evan\n,並確認 stderr 沒有任何診斷訊息。

其他常見輸入來源與對應的測試 API:

  • Argument:command.SetArgs([]string{"Evan"})
  • Flag:root.SetArgs([]string{"say", "--upper"})
  • Stdin:command.SetIn(strings.NewReader("Evan\n"))
  • 環境變數:t.Setenv("APP_ENV", "test")

驗證參數錯誤與 Stderr 訊息

除了正常成功的情境,使用者也可能在未傳入名字的情況下執行 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")
}

測試的處理重點如下:

  1. 用 command.SetArgs([]string{}) 傳入空陣列,觸發參數數量檢查失敗。注意不要傳入 nil,否則 Cobra 會改為讀取測試 Process 的 os.Args,導致測試受到 go test 的執行參數影響。
  2. 呼叫 Execute() 取得錯誤,並用 require.ErrorContains 確認錯誤訊息包含失敗原因。
  3. 驗證 stdout 為空(確認業務邏輯未被觸發),並用 assert.Contains 驗證 stderr 包含錯誤訊息。
  4. 設定 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,單元測試也無法察覺。

透過獨立 Process 驗證 Binary 行為

若使用 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。

驗證 Command 的輸出

前面的 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。

驗證 Process 的 Exit Code

回到一開始的 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 請求等外部依賴時,下一篇將介紹如何透過介面與依賴注入,將核心邏輯與外部環境解耦並進行測試。


上一篇
CLI 編輯器的文字編輯:增刪、存檔與 Undo/Redo
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言