lolly/internal/e2e/testutil/container.go
xfy 2cb10eb749 perf(e2e): 并行化 E2E 测试,从 ~2h 降至 ~102s
- testutil: 用 sync.Once 缓存 LollyImageAvailable 结果
- testutil: 原子计数器替代时间戳避免容器名竞态
- testutil: SetupProxyTest 接受 suffix 参数生成独立 Docker 网络
- testutil: CleanupProxyTest 显式调用 network.Remove() 清理
- testutil: 移除死代码 SetupProxyTestEnv/ProxyTestEnv
- testutil: HealthCheckWaitTimeout 30s→15s, DefaultTestTimeout 180s→120s
- e2e: 所有 107 个测试函数添加 t.Parallel()
- e2e: 替换 65 处硬编码 30*time.Second 为常量
- make: test-all 三类测试并行运行,显式 PID wait 收集退出码
- make: test-e2e 添加 -parallel 4 flag

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 13:19:46 +08:00

712 lines
19 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"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// defaultLollyConfig 是 lolly 容器的默认配置。
const defaultLollyConfig = `
servers:
- listen: ":8080"
static:
- path: "/"
root: "/var/www/html"
index:
- "index.html"
`
// LollyContainerOption 容器启动选项。
type LollyContainerOption func(*lollyContainerConfig)
// lollyContainerConfig 容器配置。
type lollyContainerConfig struct {
configPath string
configYAML string
network string
certPath string
keyPath string
extraMounts []testcontainers.ContainerMount
env map[string]string
exposedPorts []string
waitFor wait.Strategy
}
// WithConfigFile 使用配置文件路径。
func WithConfigFile(path string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.configPath = path
}
}
// WithConfigYAML 使用 YAML 字符串配置。
func WithConfigYAML(yaml string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.configYAML = yaml
}
}
// WithNetwork 加入指定网络。
func WithNetwork(name string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.network = name
}
}
// WithCert 挂载证书文件。
func WithCert(certPath, keyPath string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.certPath = certPath
c.keyPath = keyPath
}
}
// WithExtraMount 添加额外挂载。
func WithExtraMount(hostPath, containerPath string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.extraMounts = append(c.extraMounts, testcontainers.ContainerMount{
Source: testcontainers.GenericBindMountSource{
HostPath: hostPath,
},
Target: testcontainers.ContainerMountTarget(containerPath),
})
}
}
// WithEnv 设置环境变量。
func WithEnv(env map[string]string) LollyContainerOption {
return func(c *lollyContainerConfig) {
if c.env == nil {
c.env = make(map[string]string)
}
for k, v := range env {
c.env[k] = v
}
}
}
// WithExposedPorts 设置暴露端口。
func WithExposedPorts(ports ...string) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.exposedPorts = ports
}
}
// WithWaitStrategy 设置等待策略。
func WithWaitStrategy(strategy wait.Strategy) LollyContainerOption {
return func(c *lollyContainerConfig) {
c.waitFor = strategy
}
}
// LollyContainer 封装 lolly 服务器容器。
type LollyContainer struct {
Container testcontainers.Container
Host string
HTTPPort int
HTTPSPort int
}
// StartLollyContainer 启动 lolly 服务器容器。
//
// 使用预构建的 lolly 镜像。如果 configPath 为空,使用默认配置。
// 支持通过选项函数自定义配置。
func StartLollyContainer(ctx context.Context, configPath string) (*LollyContainer, error) {
return StartLolly(ctx, WithConfigFile(configPath))
}
// StartLolly 启动 lolly 容器(增强版)。
//
// 支持多种配置方式和自定义选项。
//
// 使用示例:
//
// // 使用默认配置
// lolly, err := StartLolly(ctx)
//
// // 使用配置文件
// lolly, err := StartLolly(ctx, WithConfigFile("/path/to/config.yaml"))
//
// // 使用动态配置
// cfg := NewConfigBuilder().WithProxy("/api/", targets).Build()
// lolly, err := StartLolly(ctx, WithConfigYAML(cfg))
//
// // 使用 SSL
// lolly, err := StartLolly(ctx, WithConfigBuilder(cfg), WithCert(certPath, keyPath))
func StartLolly(ctx context.Context, opts ...LollyContainerOption) (*LollyContainer, error) {
cfg := &lollyContainerConfig{
exposedPorts: []string{"8080/tcp", "8443/tcp"},
waitFor: wait.ForLog("HTTP 服务器启动中").WithStartupTimeout(30 * time.Second),
}
for _, opt := range opts {
opt(cfg)
}
req := testcontainers.ContainerRequest{
Image: "lolly:latest",
ExposedPorts: cfg.exposedPorts,
WaitingFor: cfg.waitFor,
}
// 设置环境变量
if len(cfg.env) > 0 {
req.Env = cfg.env
}
// 配置网络
if cfg.network != "" {
req.Networks = []string{cfg.network}
}
// 处理配置文件
if cfg.configPath != "" {
req.Mounts = append(req.Mounts, testcontainers.ContainerMount{
Source: testcontainers.GenericBindMountSource{
HostPath: cfg.configPath,
},
Target: "/etc/lolly/lolly.yaml",
})
} else if cfg.configYAML != "" {
req.Files = append(req.Files, testcontainers.ContainerFile{
Reader: strings.NewReader(cfg.configYAML),
ContainerFilePath: "/etc/lolly/lolly.yaml",
FileMode: 0o644,
})
} else {
// 使用内嵌默认配置
req.Files = append(req.Files, testcontainers.ContainerFile{
Reader: strings.NewReader(defaultLollyConfig),
ContainerFilePath: "/etc/lolly/lolly.yaml",
FileMode: 0o644,
})
}
// 挂载证书
if cfg.certPath != "" && cfg.keyPath != "" {
req.Mounts = append(req.Mounts,
testcontainers.ContainerMount{
Source: testcontainers.GenericBindMountSource{
HostPath: cfg.certPath,
},
Target: "/etc/lolly/ssl/server.crt",
},
testcontainers.ContainerMount{
Source: testcontainers.GenericBindMountSource{
HostPath: cfg.keyPath,
},
Target: "/etc/lolly/ssl/server.key",
},
)
}
// 添加额外挂载
req.Mounts = append(req.Mounts, cfg.extraMounts...)
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
}
host, err := container.Host(ctx)
if err != nil {
container.Terminate(ctx)
return nil, fmt.Errorf("failed to get host: %w", err)
}
httpPort, err := container.MappedPort(ctx, "8080/tcp")
if err != nil {
container.Terminate(ctx)
return nil, fmt.Errorf("failed to get HTTP port: %w", err)
}
httpsPort, err := container.MappedPort(ctx, "8443/tcp")
if err != nil {
container.Terminate(ctx)
return nil, fmt.Errorf("failed to get HTTPS port: %w", err)
}
// 解析端口数字
httpPortInt := parsePort(httpPort.Port())
httpsPortInt := parsePort(httpsPort.Port())
return &LollyContainer{
Container: container,
Host: host,
HTTPPort: httpPortInt,
HTTPSPort: httpsPortInt,
}, nil
}
// parsePort 解析端口字符串为整数。
func parsePort(portStr string) int {
var port int
fmt.Sscanf(portStr, "%d", &port)
return port
}
// HTTPBaseURL 返回 HTTP 基础 URL。
func (c *LollyContainer) HTTPBaseURL() string {
return fmt.Sprintf("http://%s:%d", c.Host, c.HTTPPort)
}
// HTTPSBaseURL 返回 HTTPS 基础 URL。
func (c *LollyContainer) HTTPSBaseURL() string {
return fmt.Sprintf("https://%s:%d", c.Host, c.HTTPSPort)
}
// Terminate 终止容器。
func (c *LollyContainer) Terminate(ctx context.Context) error {
if c.Container != nil {
return c.Container.Terminate(ctx)
}
return nil
}
// WaitForHealthy 等待服务健康。
func (c *LollyContainer) WaitForHealthy(ctx context.Context, timeout time.Duration) error {
client := &http.Client{Timeout: 5 * time.Second}
url := c.HTTPBaseURL()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode < 500 {
// 任何非 5xx 响应都表示服务器正在运行
return nil
}
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("service not healthy after %v", timeout)
}
// Logs 获取容器日志。
//
// 用于诊断测试失败原因。
func (c *LollyContainer) Logs(ctx context.Context) (string, error) {
if c.Container == nil {
return "", fmt.Errorf("container is nil")
}
reader, err := c.Container.Logs(ctx)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("failed to read container logs: %w", err)
}
return string(data), nil
}
// MockBackendContainer 启动一个模拟后端服务器容器。
func MockBackendContainer(ctx context.Context, port int) (testcontainers.Container, string, error) {
req := testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{fmt.Sprintf("%d/tcp", port)},
WaitingFor: wait.ForHTTP("/").WithPort(fmt.Sprintf("%d/tcp", port)).WithStartupTimeout(30 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, "", fmt.Errorf("failed to start mock backend: %w", err)
}
host, err := container.Host(ctx)
if err != nil {
container.Terminate(ctx)
return nil, "", fmt.Errorf("failed to get host: %w", err)
}
mappedPort, err := container.MappedPort(ctx, fmt.Sprintf("%d/tcp", port))
if err != nil {
container.Terminate(ctx)
return nil, "", fmt.Errorf("failed to get port: %w", err)
}
addr := fmt.Sprintf("http://%s:%s", host, mappedPort.Port())
return container, addr, nil
}
// DockerAvailable 检查 Docker 是否可用。
func DockerAvailable(ctx context.Context) bool {
req := testcontainers.ContainerRequest{
Image: "alpine:latest",
Cmd: []string{"/bin/true"},
AutoRemove: true,
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return false
}
container.Terminate(ctx)
return true
}
// LollyImageAvailable 检查 lolly:latest 镜像是否可用。
// 结果被 sync.Once 缓存,避免重复检查。
func LollyImageAvailable(ctx context.Context) bool {
lollyImageCheckOnce.Do(func() {
lollyImageAvailable = checkLollyImage(ctx)
})
return lollyImageAvailable
}
var (
lollyImageCheckOnce sync.Once
lollyImageAvailable bool
)
// checkLollyImage 实际执行镜像检查。
func checkLollyImage(ctx context.Context) bool {
req := testcontainers.ContainerRequest{
Image: "lolly:latest",
Cmd: []string{"/lolly", "-v"},
AutoRemove: true,
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return false
}
container.Terminate(ctx)
return true
}
// StartMockBackend 启动模拟后端容器(用于代理测试)。
//
// 使用 nginx 作为模拟后端,返回容器和访问地址。
// 注意:此函数仅用于代理测试的后端模拟,不应作为被测系统。
//
// 返回值:
// - container: 容器实例
// - hostPort: 宿主机访问地址(用于测试代码访问)
// - internalAddr: 容器内部访问地址(用于 lolly 配置)
func StartMockBackend(ctx context.Context) (testcontainers.Container, string, error) {
req := testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
WaitingFor: wait.ForHTTP("/").WithStartupTimeout(30 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, "", fmt.Errorf("failed to start mock backend: %w", err)
}
host, err := container.Host(ctx)
if err != nil {
container.Terminate(ctx)
return nil, "", fmt.Errorf("failed to get host: %w", err)
}
port, err := container.MappedPort(ctx, "80/tcp")
if err != nil {
container.Terminate(ctx)
return nil, "", fmt.Errorf("failed to get port: %w", err)
}
// 返回宿主机地址
addr := fmt.Sprintf("http://%s:%s", host, port.Port())
return container, addr, nil
}
// BackendPool 后端池管理。
//
// 管理多个后端容器,用于负载均衡测试。
// 支持网络模式:当 network 不为空时,容器加入指定网络,
// 并提供内部地址供 lolly 容器访问。
type BackendPool struct {
containers []testcontainers.Container
addresses []string // 宿主机访问地址
internal []string // 容器网络内部地址
network string // Docker 网络名称
}
// StartBackendPool 启动多个后端容器。
//
// 参数:
// - count: 后端数量
//
// 返回后端池和地址列表(宿主机访问地址)。
func StartBackendPool(ctx context.Context, count int) (*BackendPool, error) {
return StartBackendPoolWithNetwork(ctx, count, "")
}
// StartBackendPoolWithNetwork 启动多个后端容器并加入网络。
//
// 参数:
// - count: 后端数量
// - network: Docker 网络名称(可选,为空则不加入网络)
//
// 当 network 不为空时,容器会加入该网络,并提供内部地址。
func StartBackendPoolWithNetwork(ctx context.Context, count int, network string) (*BackendPool, error) {
pool := &BackendPool{
containers: make([]testcontainers.Container, count),
addresses: make([]string, count),
internal: make([]string, count),
network: network,
}
for i := 0; i < count; i++ {
container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, network, i)
if err != nil {
// 清理已启动的容器
pool.Terminate(ctx)
return nil, fmt.Errorf("failed to start backend %d: %w", i, err)
}
pool.containers[i] = container
pool.addresses[i] = addr
pool.internal[i] = internalAddr
}
return pool, nil
}
// startMockBackendWithNetwork 启动单个后端容器。
func startMockBackendWithNetwork(ctx context.Context, network string, index int) (testcontainers.Container, string, string, error) {
// 生成容器名称(用于网络通信),使用原子计数器避免并行竞态
id := atomic.AddInt64(&backendCounter, 1)
containerName := fmt.Sprintf("backend-%d-%d", id, index)
req := testcontainers.ContainerRequest{
Image: "nginx:alpine",
ExposedPorts: []string{"80/tcp"},
WaitingFor: wait.ForHTTP("/").WithStartupTimeout(30 * time.Second),
Name: containerName,
}
if network != "" {
req.Networks = []string{network}
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, "", "", fmt.Errorf("failed to start mock backend: %w", err)
}
host, err := container.Host(ctx)
if err != nil {
container.Terminate(ctx)
return nil, "", "", fmt.Errorf("failed to get host: %w", err)
}
port, err := container.MappedPort(ctx, "80/tcp")
if err != nil {
container.Terminate(ctx)
return nil, "", "", fmt.Errorf("failed to get port: %w", err)
}
// 宿主机访问地址
hostAddr := fmt.Sprintf("http://%s:%s", host, port.Port())
// 容器网络内部地址(使用容器名称)
internalAddr := fmt.Sprintf("http://%s:80", containerName)
return container, hostAddr, internalAddr, nil
}
// Addresses 返回后端地址列表(宿主机访问地址)。
func (p *BackendPool) Addresses() []string {
return p.addresses
}
// InternalAddresses 返回容器网络内部地址列表。
//
// 当 lolly 和后端在同一 Docker 网络时,应使用此地址。
func (p *BackendPool) InternalAddresses() []string {
return p.internal
}
// Containers 返回容器列表。
func (p *BackendPool) Containers() []testcontainers.Container {
return p.containers
}
// Count 返回后端数量。
func (p *BackendPool) Count() int {
return len(p.containers)
}
// Terminate 终止所有容器。
func (p *BackendPool) Terminate(ctx context.Context) {
for _, container := range p.containers {
if container != nil {
container.Terminate(ctx)
}
}
}
// TerminateOne 终止指定索引的容器。
func (p *BackendPool) TerminateOne(ctx context.Context, index int) error {
if index < 0 || index >= len(p.containers) {
return fmt.Errorf("invalid index %d", index)
}
if p.containers[index] != nil {
err := p.containers[index].Terminate(ctx)
p.containers[index] = nil
p.addresses[index] = ""
p.internal[index] = ""
return err
}
return nil
}
// RestartOne 重启指定索引的容器。
func (p *BackendPool) RestartOne(ctx context.Context, index int) error {
if index < 0 || index >= len(p.containers) {
return fmt.Errorf("invalid index %d", index)
}
// 先终止旧容器
if p.containers[index] != nil {
p.containers[index].Terminate(ctx)
}
// 启动新容器
container, addr, internalAddr, err := startMockBackendWithNetwork(ctx, p.network, index)
if err != nil {
return err
}
p.containers[index] = container
p.addresses[index] = addr
p.internal[index] = internalAddr
return nil
}
// CreateNetwork 创建 Docker 网络。
//
// 用于容器间通信。
func CreateNetwork(ctx context.Context, name string) (testcontainers.Network, error) {
network, err := testcontainers.GenericNetwork(ctx, testcontainers.GenericNetworkRequest{
NetworkRequest: testcontainers.NetworkRequest{
Name: name,
},
})
if err != nil {
return nil, fmt.Errorf("failed to create network: %w", err)
}
return network, nil
}
// 原子计数器,用于生成唯一的容器名和网络名。
var (
backendCounter int64
networkCounter int64
)
// SetupProxyTest 设置代理测试环境。
//
// 创建独立网络、启动后端池,返回网络对象、网络名称和后端池。
// suffix 用于生成唯一网络名(通常传 t.Name()),配合原子计数器确保 -count N 安全。
// lolly 容器应使用 InternalAddresses() 作为代理目标。
//
// 使用示例:
//
// netObj, networkName, pool, err := testutil.SetupProxyTest(ctx, 2, t.Name())
// if err != nil {
// t.Fatal(err)
// }
// defer testutil.CleanupProxyTest(ctx, netObj, networkName, pool)
//
// lolly, err := testutil.StartLolly(ctx,
// testutil.WithConfigYAML(configYAML),
// testutil.WithNetwork(networkName),
// )
func SetupProxyTest(ctx context.Context, backendCount int, suffix string) (testcontainers.Network, string, *BackendPool, error) {
id := atomic.AddInt64(&networkCounter, 1)
// 防御性处理:子测试的 t.Name() 含 '/'Docker 网络名不支持
suffix = strings.ReplaceAll(suffix, "/", "-")
networkName := fmt.Sprintf("lolly-e2e-%s-%d", suffix, id)
network, err := CreateNetwork(ctx, networkName)
if err != nil && !isNetworkExistsError(err) {
return nil, "", nil, fmt.Errorf("failed to create network: %w", err)
}
// 启动后端池并加入网络
pool, err := StartBackendPoolWithNetwork(ctx, backendCount, networkName)
if err != nil {
if network != nil {
network.Remove(ctx)
}
return nil, "", nil, fmt.Errorf("failed to start backend pool: %w", err)
}
return network, networkName, pool, nil
}
// isNetworkExistsError 检查是否是网络已存在错误。
func isNetworkExistsError(err error) bool {
return err != nil && (containsString(err.Error(), "already exists") ||
containsString(err.Error(), "network with name") ||
containsString(err.Error(), "failed to create network"))
}
// containsString 检查字符串是否包含子串。
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// CleanupProxyTest 清理代理测试环境。
func CleanupProxyTest(ctx context.Context, network testcontainers.Network, networkName string, pool *BackendPool) {
if pool != nil {
pool.Terminate(ctx)
}
if network != nil {
network.Remove(ctx)
}
}