template-monorepo/test/e2e/journey.go

87 lines
2.6 KiB
Go
Raw Normal View History

test(e2e): 加 banner / e2e-list / k6 風格 user journey 讓「我有哪些測試、現在在測什麼」一眼看得到,並補上跨 endpoint 的狀態流測試: 每個測試開頭印中文 banner - 新增 e2eStep(t, id, method, path, desc) helper(test/e2e/setup_test.go) - 17 個 contract test 開頭加 banner,go test -v 會逐個顯示 ▶ [M-01] GET /api/v1/members/me — 讀 profile(tenant/uid/status) - 對外 ID 與 docs/e2e-testing.md 的測試覆蓋矩陣對齊 新增 make e2e-list - scripts/e2e-list.sh 掃 _test.go,分兩節印 contract tests + journeys; 每個 journey 列出所有 step ID + 描述(Step 用 ▶、SkipStep 用 ⊘) scripts 彩色 step banner + optional MailHog - scripts/e2e-lib.sh 抽共用 helpers(e2e_step/info/ok/warn、e2e_print_services) - e2e-run.sh / e2e-up.sh 改用 step banner + 服務面板(執行完印出 Mongo/Redis/ Gateway/MailHog 的 URL) - E2E_WITH_SMTP=1 會額外起 MailHog(http://localhost:8025),方便肉眼確認流程 k6 風格 user journey - 新增 test/e2e/journey.go:NewJourney + Step + SkipStep + Summary, 任一步 fail 自動 skip 後續,輸出 ▶ [J-x.y] 階層 banner - J-1 Tenant Owner 入職第一天(12 steps):/me → PATCH → email verify → phone verify → TOTP enroll/verify/replay/disable - J-2 Tenant Admin 建 qa_engineer 角色 → 指派 → 二人視角驗證 → 撤銷(8 steps) - J-3 Session 生命週期 refresh → /me → logout → 舊 token 401(4 steps,ZZZ 排最後) - J-4 完整註冊 → 登入(5 steps stub,標 SkipStep;接 ZITADEL container 後改 Step 即可) - make e2e-journey / make test-e2e-journey 拆獨立 target;e2e-run.sh 透過 E2E_MODE=journey + E2E_TEST_PATTERN_ZZZ 切換 docs/e2e-testing.md - 首節改為「我現在有哪些測試?make e2e-list」並附 banner 範例輸出 - 加 Journeys 章節:journey 列表、執行範例、失敗時的輸出、寫新 journey 範本 - 補 e2e-journey / test-e2e-journey / E2E_WITH_SMTP 環境變數 Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 09:18:36 +00:00
//go:build e2e
package e2e
import (
"sync/atomic"
"testing"
)
// Journey is a k6-style user journey: an ordered sequence of HTTP steps that
// share local state (closures over the parent test). If any step fails the
// remaining steps are auto-skipped, mirroring k6 scenario "abort on fail"
// behaviour so logs are easy to read — you stop at the first broken step.
//
// Use j.Step(id, desc, fn) to add steps; the framework prints:
//
// ▶ [J-1] Tenant Owner 入職第一天
// ▶ [J-1.1] GET /me 看自己是誰
// ▶ [J-1.2] PATCH /me 更新 display_name
// ⊘ [J-1.3] skipped (journey aborted)
type Journey struct {
t *testing.T
id string
title string
aborted atomic.Bool
total int
ran int
failed bool
}
// NewJourney prints the journey banner and returns a builder.
// Call j.Run() at the end to print the final summary.
func NewJourney(t *testing.T, id, title string) *Journey {
t.Helper()
t.Logf("▶ [%s] %s", id, title)
return &Journey{t: t, id: id, title: title}
}
// Step adds a step. fn receives a sub-test t scoped to this step so testify
// require.* abort just this step (not the whole Test function), letting the
// framework print a clean "aborted" line for subsequent steps.
//
// Pre-existing failures in earlier steps short-circuit the step and emit a
// "⊘ skipped" line.
func (j *Journey) Step(id, desc string, fn func(t *testing.T)) {
j.total++
stepID := j.id + "." + id
name := stepID + " " + desc
j.t.Run(name, func(t *testing.T) {
if j.aborted.Load() {
t.Skipf("⊘ [%s] skipped — journey aborted at an earlier step", stepID)
return
}
t.Logf(" ▶ [%s] %s", stepID, desc)
j.ran++
// fn may call require.* which marks t failed and FailNow's. After it
// returns we inspect t.Failed() to decide whether to abort siblings.
fn(t)
if t.Failed() {
j.aborted.Store(true)
j.failed = true
t.Logf("✗ [%s] FAIL — aborting remaining steps", stepID)
}
})
}
// SkipStep marks a step as intentionally skipped (e.g. requires ZITADEL).
// The journey is NOT aborted; the next Step still runs.
func (j *Journey) SkipStep(id, desc, reason string) {
j.total++
stepID := j.id + "." + id
name := stepID + " " + desc
j.t.Run(name, func(t *testing.T) {
t.Skipf("⊘ [%s] %s — %s", stepID, desc, reason)
})
}
// Summary prints the final journey result. Always defer this right after
// NewJourney so it runs even on require.* abort.
func (j *Journey) Summary() {
status := "✔"
if j.failed {
status = "✗"
}
j.t.Logf("%s [%s] %s — %d/%d steps executed", status, j.id, j.title, j.ran, j.total)
}