Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

contract.md

maoxiaoyue edited this page May 22, 2026 · 3 revisions

pkg/contract — Contract Testing 內建驗證

根據 schema-first 路由的 metadata 自動驗證 handler 行為,確保 AI 生成的程式碼符合宣告的合約。


目錄

  1. 設計理念
  2. 快速上手
  3. Test — 手動單一路由測試
  4. TestAll — 自動測試所有路由
  5. TestRoute — 簡易存在性測試
  6. Observe — 完整 HTTP 觀察模式
  7. HTML 報告
  8. CLI:hyp observe
  9. 驗證機制
  10. 自動測試生成策略
  11. 架構與依賴

設計理念

AI 生成了一個 handler,怎麼知道它是對的?Contract Testing 讓「schema 即合約」——請求和回應的 body 結構都必須符合 Schema() 宣告的 Input/Output 型別:

Schema 宣告 Input: CreateUserRequest{Name, Email}
Schema 宣告 Output: UserResponse{ID, Name, Email}
 ↓
Request {"name":"alice"} ← 缺 email → Input 驗證失敗 ✅
Response {"id":1,"name":"alice"} ← 缺 email → Output 驗證失敗 ✅

三種測試模式

模式 函式 特點
Test Test(t, r, TestCase{...}) 手動指定路由、body、預期結果,精準控制
TestAll TestAll(t, r) 自動生成並測試所有 schema 路由,一行程式碼
Observe ObserveAll(t, r) / Observe(t, r, "func") 完整 HTTP 捕捉 + 逐步驗證 + HTML 報告

快速上手

import (
 "testing"
 "github.com/maoxiaoyue/hypgo/pkg/contract"
)
// 方式一:手動測試單一路由
func TestCreateUser(t *testing.T) {
 contract.Test(t, setupRouter(), contract.TestCase{
 Route: "POST /api/users",
 Input: `{"name":"alice","email":"alice@test.com"}`,
 ExpectStatus: 201,
 ExpectSchema: true,
 })
}
// 方式二:自動測試所有 schema 路由
func TestAllContracts(t *testing.T) {
 contract.TestAll(t, setupRouter())
}
// 方式三:觀察模式(完整報告)
func TestObserveAll(t *testing.T) {
 contract.ObserveAll(t, setupRouter())
 // → 生成 .hyp/observe_20260523_143022.html
}
// 方式四:觀察特定函式相關的路由
func TestObserveOrder(t *testing.T) {
 contract.Observe(t, setupRouter(), "createOrderHandler")
}

Test — 手動單一路由測試

Test 讓你精確控制一個路由的測試場景,適合邊界條件和錯誤路徑測試。

函式簽名

func Test(t *testing.T, r *router.Router, tc TestCase) bool

TestCase 欄位

type TestCase struct {
 Route string // "METHOD /path",例如 "POST /api/users"
 Input string // JSON 請求 body
 Headers map[string]string // 自訂請求標頭
 Query map[string]string // URL query 參數
 ExpectStatus int // 期望的 HTTP 狀態碼(0 = 不驗證)
 ExpectSchema bool // 是否驗證回應符合 Output schema
 ExpectBody string // 精確比對回應 body(空字串 = 不驗證)
}

使用範例

// 基本狀態碼驗證
func TestGetUser(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "GET /api/users/1",
 ExpectStatus: 200,
 })
}
// 帶 body 的 POST 測試,並驗證回應 schema
func TestCreateUser(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "POST /api/users",
 Input: `{"name":"alice","email":"alice@test.com"}`,
 ExpectStatus: 201,
 ExpectSchema: true,
 })
}
// 帶 query 參數
func TestSearchUsers(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "GET /api/users",
 Query: map[string]string{"page": "1", "limit": "10"},
 ExpectStatus: 200,
 })
}
// 帶認證標頭
func TestAuthRequired(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "GET /api/admin",
 Headers: map[string]string{"Authorization": "Bearer valid-token"},
 ExpectStatus: 200,
 })
}
// 精確 body 比對
func TestPingBody(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "GET /ping",
 ExpectStatus: 200,
 ExpectBody: `{"message":"pong"}`,
 })
}

TestAll — 自動測試所有路由

TestAllschema.Global() 取得所有已註冊的 schema 路由,自動生成測試案例並執行。

函式簽名

func TestAll(t *testing.T, r *router.Router)

使用方式

func TestAllContracts(t *testing.T) {
 r := setupRouter()
 contract.TestAll(t, r)
 // 輸出類似:
 // --- PASS: TestAllContracts/POST_/api/users (0.00s)
 // --- PASS: TestAllContracts/GET_/api/users/:id (0.00s)
 // --- FAIL: TestAllContracts/DELETE_/api/users/:id (0.00s)
 // contract: status = 500, want 204
}

多協議行為

協議 行為
REST(Protocol 為空或 "rest") 發送完整 HTTP 請求,驗證 Input/Output schema 和狀態碼
gRPC / Bot / MCP / WebSocket / CLI 驗證 schema 定義完整性(Command 非空、Summary 非空、型別名稱已填入)
// 混合多協議
schema.RegisterGRPC("UserService/CreateUser", "Create user", req, resp)
schema.RegisterBot("/start", "Start the bot", nil, WelcomeMsg{})
contract.TestAll(t, router)
// → REST 路由:完整 HTTP 測試
// → gRPC / Bot 路由:驗證 schema 定義完整性

TestRoute — 簡易存在性測試

不需要 schema,只驗證路由是否存在且回傳正確狀態碼。

func TestRoute(t *testing.T, r *router.Router, method, path string, expectStatus int)
func TestHealthCheck(t *testing.T) {
 r := setupRouter()
 contract.TestRoute(t, r, "GET", "/health", 200)
 contract.TestRoute(t, r, "GET", "/metrics", 200)
 contract.TestRoute(t, r, "GET", "/nonexistent", 404)
}

Observe — 完整 HTTP 觀察模式 🆕

Observe 模式在執行 Contract Testing 的同時,完整捕捉每條路由的 HTTP 交換記錄,包含請求/回應的 headers、body,並逐步列出驗證結果,最終生成可視化的 HTML 報告。

與 TestAll 的差異:TestAll 只回傳 pass/fail 結果。Observe 額外捕捉完整的 HTTP 交換記錄,讓你看到實際發出了什麼、收到了什麼,以及每個驗證步驟為何通過或失敗。

函式簽名

// 觀察所有 schema-registered REST 路由
func ObserveAll(t *testing.T, r *router.Router, opts ...ObserveOptions) []ObserveResult
// 觀察符合 funcName 過濾條件的路由
func Observe(t *testing.T, r *router.Router, funcName string, opts ...ObserveOptions) []ObserveResult

ObserveOptions 配置

type ObserveOptions struct {
 // OutputPath 指定 HTML 報告的輸出路徑
 // 預設:.hyp/observe_YYYYMMDD_HHMMSS.html
 OutputPath string
 // OpenBrowser 生成報告後自動在瀏覽器開啟
 // macOS: open, Linux: xdg-open, Windows: start
 OpenBrowser bool
 // Silent 抑制 t.Logf 的報告路徑輸出
 Silent bool
}

使用範例

// 觀察所有路由,生成 HTML 報告
func TestObserveAll(t *testing.T) {
 contract.ObserveAll(t, setupRouter())
 // [observe] report → .hyp/observe_20260523_143022.html
}
// 觀察後自動開啟瀏覽器
func TestObserveWithBrowser(t *testing.T) {
 contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{
 OpenBrowser: true,
 })
}
// 觀察特定函式名稱相關的路由
func TestObserveCreateOrder(t *testing.T) {
 contract.Observe(t, setupRouter(), "createOrderHandler")
 // 只測試 handler 名稱含 "createOrderHandler" 的路由
}
// 以路徑片段過濾
func TestObserveOrderRoutes(t *testing.T) {
 contract.Observe(t, setupRouter(), "/orders")
 // 測試所有路徑含 "/orders" 的路由
}
// 以標籤過濾
func TestObservePayments(t *testing.T) {
 contract.Observe(t, setupRouter(), "payments")
 // 測試 Tags 含 "payments" 的路由
}
// 以 Summary 關鍵字過濾
func TestObserveUserCreation(t *testing.T) {
 contract.Observe(t, setupRouter(), "建立使用者")
}
// 指定輸出路徑(不使用預設的 .hyp/ 目錄)
func TestObserveCustomOutput(t *testing.T) {
 contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{
 OutputPath: "testdata/observe_report.html",
 OpenBrowser: false,
 Silent: true,
 })
}
// 在 CI 中使用(靜默模式)
func TestObserveCI(t *testing.T) {
 results := contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{
 Silent: true,
 })
 for _, r := range results {
 if !r.Pass {
 t.Errorf("路由 %s %s 驗證失敗:%s", r.Route.Method, r.Route.Path, r.FailReason)
 }
 }
}

過濾邏輯

ObservefuncName 參數對以下四個維度做大小寫不敏感的子字串比對:

維度 範例
Handler 函式名稱(HandlerNames) "createOrder" 匹配 "controllers.CreateOrderHandler"
路由路徑(Path) "orders" 匹配 "/api/v1/orders/:id"
摘要(Summary) "建立" 匹配 "建立訂單"
標籤(Tags) "pay" 匹配 ["payments", "billing"]

空字串等同於 ObserveAll(不過濾)。

HandlerNames 自動填入:使用 r.Schema(...).Handle(myHandler) 時,框架自動透過反射取得函式名稱並儲存至 route.HandlerNames,無需手動填寫。

ObserveResult 結構

type ObserveResult struct {
 Route schema.Route // 路由的 schema metadata
 Request CapturedRequest // 實際發出的 HTTP 請求詳情
 Response CapturedResponse // 實際收到的 HTTP 回應詳情
 Steps []ValidationStep // 逐步驗證記錄
 Pass bool // 整體是否通過
 FailReason string // 失敗原因(Pass=false 時非空)
 Duration time.Duration // 請求執行耗時
 Timestamp time.Time // 執行時間戳
}
type CapturedRequest struct {
 Method string
 Path string // 含 query string 的完整 URL
 Headers map[string]string
 Body string
}
type CapturedResponse struct {
 StatusCode int
 Headers map[string]string
 Body string
}
type ValidationStep struct {
 Name string // 驗證步驟名稱
 Pass bool
 Detail string // 詳細說明(通過:describes match;失敗:error message)
}

驗證步驟(四步)

每條路由依序執行以下驗證,結果記錄於 ObserveResult.Steps:

步驟 條件 說明
Step 1: Status Code 始終執行 got X, want Y
Step 2: Response Body 有 Output schema 時 檢查 body 非空
Step 3: Input Schema 有 Input schema 且有生成 body 時 驗證請求 body 符合 Input struct
Step 4: Output Schema 有 Output schema 且 body 非空時 驗證回應 body 符合 Output struct

HTML 報告

Observe 模式自動生成完整的自包含 HTML 報告,儲存於 .hyp/observe_YYYYMMDD_HHMMSS.html

報告功能

  • 深色主題(--bg: #0f1419,--primary: #6ee7b7),與 HypGo 工具鏈視覺一致
  • 摘要統計列:總路由數、通過、失敗、通過率
  • 過濾條件顯示:一眼看出本次觀察的範圍
  • 路由卡片:預設折疊,失敗路由自動展開
    • Method badge(顏色對應 HTTP 方法)
    • 路由路徑、Summary、Tags、Handler 函式名稱
    • Input / Output Schema 型別名稱
    • HTTP 請求詳情(方法、完整 URL、headers、body)
    • HTTP 回應詳情(狀態碼、headers、body)
    • 逐步驗證結果(✅ / ❌)
    • 執行耗時
  • 失敗原因 banner:失敗路由頂部顯示紅色說明文字

手動呼叫 GenerateObserveHTML

若需要自行控制 HTML 生成(例如整合到自訂報告系統):

import "github.com/maoxiaoyue/hypgo/pkg/contract"
// 取得結果
results := contract.ObserveAll(t, r, contract.ObserveOptions{Silent: true})
// 手動生成 HTML
html := contract.GenerateObserveHTML(results, "myFilter")
os.WriteFile("custom_report.html", []byte(html), 0o644)

報告目錄管理

所有報告預設存放於 .hyp/ 目錄(自動建立)。建議將此目錄加入 .gitignore:

# HypGo generated files
.hyp/

CLI:hyp observe

hyp observe 命令用於列出和開啟現有的 observe HTML 報告。

注意:CLI 命令只能管理已生成的報告,報告本身須透過 Go 測試(contract.ObserveAll 等函式)生成。

安裝

go install github.com/maoxiaoyue/hypgo/cmd/hyp@latest
# 確認 $GOPATH/bin 已在 PATH 中

命令說明

hyp observe [func-name] [flags]
Flags:
 -o, --open 在系統預設瀏覽器中開啟報告(預設:最新報告)
 -n int 指定開啟第 n 個報告(1 = 最新,2 = 次新,...)(預設:1)

使用範例

# 列出所有可用報告
hyp observe
# 輸出:
# 📊 找到 3 個 observe 報告:
#
# ▶ [1] observe_20260523_143022.html 42.1 KB
# [2] observe_20260522_091845.html 38.7 KB
# [3] observe_20260521_163310.html 35.2 KB
#
# 💡 使用 `hyp observe --open` 在瀏覽器中開啟最新報告
# 開啟最新報告
hyp observe --open
# 開啟第 2 個(次新)報告
hyp observe --open -n 2
# 顯示如何觀察特定函式的提示
hyp observe createOrderHandler
# 💡 提示:在測試中使用以下命令觀察 "createOrderHandler" 相關路由:
# contract.Observe(t, setupRouter(), "createOrderHandler")

在測試中生成報告

先在測試中生成報告,再用 CLI 開啟:

# 1. 執行測試(生成報告)
go test ./... -run TestObserve -v
# 2. 列出報告
hyp observe
# 3. 開啟最新報告
hyp observe --open

驗證機制

Schema 驗證(Test + Observe 共用)

ExpectSchema: true(Test 模式)或 route 有 Output schema(Observe 模式)時:

  1. schema.Global() 查找路由的 schema
  2. 取得 Output 型別(Go struct)
  3. 嘗試將回應 body JSON 反序列化為該 struct
  4. 檢查所有必填欄位是否存在
// schema 宣告
r.Schema(schema.Route{
 Method: "GET",
 Path: "/api/users/:id",
 Output: UserResponse{}, // 有 ID, Name, Email 三個必填欄位
}).Handle(getUserHandler)
// 若 handler 回傳 {"id":1} → 失敗(缺 name, email)
// 若 handler 回傳 {"id":1,"name":"a","email":"b"} → 通過

必填欄位判定

struct tag 形式 Required? 說明
json:"name" ✅ 是 無 omitempty,非 pointer
json:"bio,omitempty" ❌ 否 有 omitempty
Bio *string \json:"bio"`` ❌ 否 pointer 型別視為可選

雙向 Schema 驗證

Contract Testing 同時驗證 Input(請求 body)和 Output(回應 body):

  • Input 驗證:Auto-generated body 或 tc.Input 是否符合 Input struct 的必填欄位
  • Output 驗證:Handler 回應的 JSON 是否包含所有必填欄位

自動測試生成策略

TestAllObserve 使用相同的自動生成策略,基於 schema metadata 產生測試案例。

路徑參數解析

路徑 解析結果
/api/users/:id /api/users/1
/api/users/:userId/posts/:postId /api/users/1/posts/1
/api/:slug /api/test-slug
/api/:name /api/test
/files/*filepath /files/test.txt

所有 :param 佔位符均替換為對應的預設值,確保請求能路由到正確的 handler。

狀態碼推測

條件 推測的狀態碼
Responses 中有明確宣告的 2xx 使用最小的 2xx(例如 201)
POST(無宣告) 201
DELETE(無宣告) 204
其他(無宣告) 200

請求 Body 生成

僅對 POST、PUT、PATCH 自動生成 body,根據 Input struct 欄位填入合理預設值:

Go 型別 生成的值
string "test"
int, int64 0
float64 0.0
bool false
[]T []
map[K]V {}

完整範例

設定 Router(setupRouter)

package api_test
import (
 "testing"
 "github.com/maoxiaoyue/hypgo/pkg/contract"
 hypcontext "github.com/maoxiaoyue/hypgo/pkg/context"
 "github.com/maoxiaoyue/hypgo/pkg/router"
 "github.com/maoxiaoyue/hypgo/pkg/schema"
)
type CreateUserReq struct {
 Name string `json:"name"`
 Email string `json:"email"`
}
type UserResp struct {
 ID int `json:"id"`
 Name string `json:"name"`
 Email string `json:"email"`
}
func setupRouter() *router.Router {
 r := router.New()
 r.Schema(schema.Route{
 Method: "POST",
 Path: "/api/users",
 Summary: "建立使用者",
 Tags: []string{"users"},
 Input: CreateUserReq{},
 Output: UserResp{},
 Responses: map[int]schema.ResponseSchema{
 201: {Description: "Created"},
 },
 }).Handle(createUserHandler)
 r.Schema(schema.Route{
 Method: "GET",
 Path: "/api/users/:id",
 Summary: "查詢使用者",
 Tags: []string{"users"},
 Output: UserResp{},
 }).Handle(getUserHandler)
 r.GET("/health", healthHandler)
 return r
}
func createUserHandler(c *hypcontext.Context) {
 c.JSON(201, UserResp{ID: 1, Name: "alice", Email: "alice@test.com"})
}
func getUserHandler(c *hypcontext.Context) {
 c.JSON(200, UserResp{ID: 1, Name: "alice", Email: "alice@test.com"})
}
func healthHandler(c *hypcontext.Context) {
 c.JSON(200, map[string]string{"status": "ok"})
}

Test — 手動測試

// 基本合約測試
func TestCreateUser(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "POST /api/users",
 Input: `{"name":"alice","email":"alice@test.com"}`,
 ExpectStatus: 201,
 ExpectSchema: true,
 })
}
// 缺少必填欄位 → 應測試 handler 的 validation 行為
func TestCreateUserMissingEmail(t *testing.T) {
 r := setupRouter()
 contract.Test(t, r, contract.TestCase{
 Route: "POST /api/users",
 Input: `{"name":"alice"}`,
 ExpectStatus: 400, // handler 應回傳 400
 })
}

TestAll — 自動測試

func TestAllContracts(t *testing.T) {
 schema.Global().Reset() // 清除其他測試留下的 schema
 r := setupRouter()
 contract.TestAll(t, r)
}

Observe — 觀察報告

// 觀察所有路由(開發階段除錯用)
func TestObserveAll(t *testing.T) {
 r := setupRouter()
 contract.ObserveAll(t, r)
}
// 觀察特定函式,並在瀏覽器中開啟結果
func TestObserveCreateUser(t *testing.T) {
 r := setupRouter()
 contract.Observe(t, r, "createUserHandler", contract.ObserveOptions{
 OpenBrowser: true,
 })
}
// 觀察 "users" 相關的所有路由
func TestObserveUserRoutes(t *testing.T) {
 r := setupRouter()
 results := contract.Observe(t, r, "users", contract.ObserveOptions{
 Silent: true,
 })
 // 可對結果進行程式化斷言
 for _, res := range results {
 if !res.Pass {
 t.Errorf("[%s %s] %s", res.Route.Method, res.Route.Path, res.FailReason)
 }
 }
}

架構與依賴

檔案結構

pkg/contract/
├── contract.go Test()、TestAll()、TestRoute() — 核心測試函式
├── validate.go validateResponse()、validateRequest()、validateRequiredFields()
├── generate.go generateTestCase()、generateMinimalJSON()、resolvePath()
├── observe.go ObserveAll()、Observe()、captureRouteExchange() 🆕
├── report.go GenerateObserveHTML() — HTML 報告生成 🆕
├── contract_test.go 24 個單元測試
└── observe_test.go 34 個 Observe 相關測試 🆕

依賴關係

pkg/contract → pkg/router (Router.ServeHTTP 執行 HTTP 請求)
 → pkg/schema (Global() 查找 schema metadata)
 → net/http/httptest (模擬 HTTP 請求/回應)
 → html (report.go HTML 轉義)
 → os / filepath (report.go 檔案輸出)
 → os/exec / runtime (observe.go 瀏覽器開啟)

與 Schema 的關係

Schema-first 路由 Contract Testing
┌──────────────────────┐ ┌──────────────────────────────┐
│ Route{ │ │ Test:ExpectSchema: true │
│ Input: Req{} │────→│ TestAll:自動 schema 驗證 │
│ Output: Resp{} │ │ Observe:捕捉完整 HTTP 交換 │
│ HandlerNames: [..] │ │ + 逐步驗證 │
│ Tags: [..] │ │ + HTML 報告 │
│ } │ └──────────────────────────────┘
└──────────────────────┘ │
 │ │
 └────── schema.Global() ─────┘
 (共用 Registry)

各模式對照

功能 Test TestAll Observe
手動指定請求 body ❌(自動生成) ❌(自動生成)
自動生成所有路由
過濾特定路由 ✅(直接指定) ✅(funcName 過濾)
捕捉完整 HTTP 交換
逐步驗證記錄
HTML 報告
回傳結果物件 bool []ObserveResult
適用場景 精準邊界測試 CI 全覆蓋驗證 除錯 / AI 觀察 / 文檔

不使用 Schema() 註冊的路由無法進行 schema 驗證,但仍可使用 TestRoute() 檢查狀態碼。

HypGo

繁體中文 | English


中文文件

設計文件

套件

AI 協作工具鏈

CLI 命令


English Docs

Design Docs

Packages

AI Collaboration Toolchain

CLI Commands

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /