contract.md - maoxiaoyue/hypgo GitHub Wiki
pkg/contract — Contract Testing 內建驗證
根據 schema-first 路由的 metadata 自動驗證 handler 行為,確保 AI 生成的程式碼符合宣告的合約。
設計理念
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 驗證失敗 ✅
雙向 Schema 驗證
Contract Testing 同時驗證 Input(請求 body)和 Output(回應 body):
- Input 驗證:當
ExpectSchema: true且 Schema 定義了Input型別時,自動驗證tc.InputJSON 是否符合 Input struct - Output 驗證:驗證 handler 回應的 JSON 是否符合 Output struct(含必填欄位檢查)
快速上手
手動測試
import (
"testing"
"github.com/maoxiaoyue/hypgo/pkg/contract"
)
func TestCreateUser(t *testing.T) {
r := setupRouter() // 已註冊 Schema 路由的 router
contract.Test(t, r, contract.TestCase{
Route: "POST /api/users",
Input: `{"name":"alice","email":"[email protected]"}`,
ExpectStatus: 201,
ExpectSchema: true, // 驗證 response 符合 Output schema
})
}
自動測試所有路由
一行程式碼測試所有 schema-registered 路由:
func TestAllRoutes(t *testing.T) {
r := setupRouter()
contract.TestAll(t, r) // 自動為每個 schema 路由生成測試
}
TestAll 會:
- 從
schema.Global()取得所有已註冊的 schema 路由 - 為每個路由自動生成最小有效的請求 body
- 自動解析路徑參數(
:id→1) - 驗證狀態碼和回應 schema
簡易路由存在性測試
不需要 schema,只驗證路由是否存在且回傳正確狀態碼:
func TestHealthEndpoint(t *testing.T) {
r := setupRouter()
contract.TestRoute(t, r, "GET", "/health", 200)
}
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 狀態碼
ExpectSchema bool // 是否驗證回應符合 Output schema
ExpectBody string // 精確比對回應 body(可選)
}
驗證機制
Schema 驗證 (ExpectSchema: true)
當 ExpectSchema 為 true 時,contract 會:
- 從
schema.Global()查找該路由的 schema - 取得
Output型別(Go struct) - 嘗試將回應 body JSON 反序列化為該 struct
- 檢查所有必填欄位是否存在
// 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" |
✅ 是 |
json:"bio,omitempty" |
❌ 否 |
Bio *string \json:"bio"`` |
❌ 否(pointer) |
狀態碼驗證
若 ExpectStatus > 0,驗證回應狀態碼是否匹配。
Body 精確比對
若 ExpectBody 非空,去除首尾空白後精確比對回應 body。
自動測試生成(TestAll)
TestAll 內部使用以下策略自動生成測試案例:
路徑參數解析
| 路徑 | 解析結果 |
|---|---|
/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 |
狀態碼推測
| 條件 | 推測的狀態碼 |
|---|---|
| Responses 中有明確宣告的 2xx | 使用最小的 2xx |
| 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 |
{} |
完整範例
package api_test
import (
"testing"
"github.com/maoxiaoyue/hypgo/pkg/contract"
"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: "建立使用者",
Input: CreateUserReq{},
Output: UserResp{},
Responses: map[int]schema.ResponseSchema{
201: {Description: "Created"},
},
}).Handle(createUserHandler)
r.Schema(schema.Route{
Method: "GET",
Path: "/api/users/:id",
Output: UserResp{},
}).Handle(getUserHandler)
r.GET("/health", healthHandler)
return r
}
// 手動測試特定路由
func TestCreateUser(t *testing.T) {
r := setupRouter()
contract.Test(t, r, contract.TestCase{
Route: "POST /api/users",
Input: `{"name":"alice","email":"[email protected]"}`,
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 test-token"},
ExpectStatus: 200,
})
}
// 一鍵測試所有 schema 路由
func TestAllContracts(t *testing.T) {
r := setupRouter()
contract.TestAll(t, r)
}
// 簡易路由檢查
func TestHealth(t *testing.T) {
r := setupRouter()
contract.TestRoute(t, r, "GET", "/health", 200)
}
架構
pkg/contract/
├── contract.go Test()、TestAll()、TestRoute() 核心測試函式
├── validate.go validateResponse()、validateRequest()、validateRequiredFields()
├── generate.go generateTestCase()、generateMinimalJSON()、resolvePath()
└── contract_test.go 24 個單元測試
依賴關係
pkg/contract → pkg/router(Router.ServeHTTP 執行請求)
→ pkg/schema(Global() 查找 schema metadata)
→ net/http/httptest(模擬 HTTP 請求)
與 Schema 的關係
Schema-first 路由 Contract Testing
┌──────────────────┐ ┌──────────────────┐
│ Route{ │ │ TestCase{ │
│ Input: Req{} │─────→│ ExpectSchema: │
│ Output: Resp{} │ │ true │
│ Responses: ... │ │ } │
│ } │ │ │
└──────────────────┘ └──────────────────┘
│ │
└─── schema.Global() ────┘
(共用 Registry)
不使用 Schema() 註冊的路由無法進行 schema 驗證,但仍可使用 TestRoute() 檢查狀態碼。
測試
go test ./pkg/contract/... -v
涵蓋:基本測試、schema 驗證、body 比對、query 參數、自訂標頭、404、TestAll 自動測試、TestRoute、validateResponse(正向/缺欄位/空 body/nil type/optional)、validateRequest、generateTestCase、路徑解析、狀態碼推測。