xfy 1be6480f5c feat(e2e): 添加重试机制和测试覆盖率支持
- 新增 testutil/retry.go 提供重试工具函数
  - WaitForCondition: 等待条件满足
  - WaitForNoError: 等待操作无错误
  - WaitForHealthy: 等待服务健康
  - Poll: 简化轮询接口
- 新增 testutil/retry_test.go 单元测试
- container.go 添加 Logs() 方法用于诊断
- Makefile 新增 test-e2e-cover 和 test-e2e-short 目标
- 重构 healthcheck 和 loadbalance 测试使用重试机制

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 16:07:17 +08:00

231 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build e2e
// Package testutil 提供 E2E 测试的工具函数。
//
// 包含重试和等待工具,提高测试稳定性。
//
// 作者xfy
package testutil
import (
"context"
"fmt"
"time"
)
// RetryConfig 重试配置。
type RetryConfig struct {
// Interval 重试间隔
Interval time.Duration
// Timeout 总超时时间
Timeout time.Duration
// MaxRetries 最大重试次数0 表示无限制)
MaxRetries int
}
// DefaultRetryConfig 默认重试配置。
var DefaultRetryConfig = RetryConfig{
Interval: 500 * time.Millisecond,
Timeout: 30 * time.Second,
MaxRetries: 0, // 无限制
}
// WaitForCondition 等待条件满足。
//
// 定期检查条件函数,直到返回 true 或超时。
// 使用默认配置,可通过 opts 覆盖。
//
// 使用示例:
//
// err := testutil.WaitForCondition(ctx, testutil.RetryConfig{
// Interval: 1 * time.Second,
// Timeout: 30 * time.Second,
// }, func() bool {
// resp, err := client.Get(url)
// if err != nil {
// return false
// }
// defer resp.Body.Close()
// return resp.StatusCode == 200
// })
func WaitForCondition(ctx context.Context, cfg RetryConfig, condition func() bool) error {
if cfg.Interval <= 0 {
cfg.Interval = DefaultRetryConfig.Interval
}
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultRetryConfig.Timeout
}
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
retries := 0
for {
select {
case <-ctx.Done():
return fmt.Errorf("condition not met after %v: %w", cfg.Timeout, ctx.Err())
case <-ticker.C:
if condition() {
return nil
}
retries++
if cfg.MaxRetries > 0 && retries >= cfg.MaxRetries {
return fmt.Errorf("condition not met after %d retries", retries)
}
}
}
}
// WaitForNoError 等待操作无错误。
//
// 定期执行函数,直到返回 nil 或超时。
// 适用于需要等待某个操作成功的场景。
//
// 使用示例:
//
// err := testutil.WaitForNoError(ctx, testutil.RetryConfig{
// Interval: 2 * time.Second,
// Timeout: 60 * time.Second,
// }, func() error {
// resp, err := client.Get(url)
// if err != nil {
// return err
// }
// defer resp.Body.Close()
// if resp.StatusCode != 200 {
// return fmt.Errorf("unexpected status: %d", resp.StatusCode)
// }
// return nil
// })
func WaitForNoError(ctx context.Context, cfg RetryConfig, fn func() error) error {
if cfg.Interval <= 0 {
cfg.Interval = DefaultRetryConfig.Interval
}
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultRetryConfig.Timeout
}
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
ticker := time.NewTicker(cfg.Interval)
defer ticker.Stop()
retries := 0
var lastErr error
for {
select {
case <-ctx.Done():
if lastErr != nil {
return fmt.Errorf("operation failed after %v: %w (last error: %v)", cfg.Timeout, ctx.Err(), lastErr)
}
return fmt.Errorf("operation failed after %v: %w", cfg.Timeout, ctx.Err())
case <-ticker.C:
if err := fn(); err == nil {
return nil
} else {
lastErr = err
}
retries++
if cfg.MaxRetries > 0 && retries >= cfg.MaxRetries {
if lastErr != nil {
return fmt.Errorf("operation failed after %d retries: %w", retries, lastErr)
}
return fmt.Errorf("operation failed after %d retries", retries)
}
}
}
}
// Retry 重试操作直到成功或超时。
//
// 与 WaitForNoError 类似,但返回最后一次错误。
// 适用于需要知道具体失败原因的场景。
func Retry(ctx context.Context, cfg RetryConfig, fn func() error) error {
return WaitForNoError(ctx, cfg, fn)
}
// WaitForHealthy 等待服务健康。
//
// 便捷函数,等待 HTTP 服务返回 200 或预期状态码。
//
// 使用示例:
//
// err := testutil.WaitForHealthy(ctx, lolly.HTTPBaseURL(), 30*time.Second, 200, 404)
func WaitForHealthy(ctx context.Context, url string, timeout time.Duration, expectedCodes ...int) error {
cfg := RetryConfig{
Interval: 500 * time.Millisecond,
Timeout: timeout,
}
if len(expectedCodes) == 0 {
expectedCodes = []int{200}
}
return WaitForNoError(ctx, cfg, func() error {
client := CreateDefaultHTTPClient()
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
for _, code := range expectedCodes {
if resp.StatusCode == code {
return nil
}
}
return fmt.Errorf("unexpected status code: %d (expected one of %v)", resp.StatusCode, expectedCodes)
})
}
// WaitForBackendHealthy 等待后端服务健康。
//
// 用于等待后端池中的服务就绪。
func WaitForBackendHealthy(ctx context.Context, urls []string, timeout time.Duration) error {
cfg := RetryConfig{
Interval: 500 * time.Millisecond,
Timeout: timeout,
}
return WaitForNoError(ctx, cfg, func() error {
client := CreateDefaultHTTPClient()
for _, url := range urls {
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("backend %s not reachable: %w", url, err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("backend %s returned status %d", url, resp.StatusCode)
}
}
return nil
})
}
// Poll 定期执行函数直到返回 true。
//
// 简化的轮询接口,适用于简单场景。
func Poll(ctx context.Context, interval, timeout time.Duration, fn func() (bool, error)) error {
cfg := RetryConfig{
Interval: interval,
Timeout: timeout,
}
return WaitForNoError(ctx, cfg, func() error {
done, err := fn()
if err != nil {
return err
}
if !done {
return fmt.Errorf("poll condition not met")
}
return nil
})
}