From 5d38d9ab447c0e363e2a0d39f4eff19128a6f297 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Apr 2026 09:35:19 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E6=B7=BB=E5=8A=A0=20E2E=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B7=A5=E5=85=B7=E5=8C=85=E5=92=8C=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 testutil 容器管理工具(lolly/nginx) - 添加测试配置文件(basic/proxy/ssl) - 添加测试用 SSL 证书 Co-Authored-By: Claude Opus 4.7 --- internal/e2e/certs/server.crt | 19 ++ internal/e2e/certs/server.key | 28 +++ internal/e2e/configs/basic.yaml | 7 + internal/e2e/configs/proxy.yaml | 10 + internal/e2e/configs/ssl.yaml | 12 ++ internal/e2e/testutil/certs.go | 132 ++++++++++++ internal/e2e/testutil/concurrent.go | 100 ++++++++++ internal/e2e/testutil/container.go | 255 ++++++++++++++++++++++++ internal/e2e/testutil/container_test.go | 60 ++++++ 9 files changed, 623 insertions(+) create mode 100644 internal/e2e/certs/server.crt create mode 100644 internal/e2e/certs/server.key create mode 100644 internal/e2e/configs/basic.yaml create mode 100644 internal/e2e/configs/proxy.yaml create mode 100644 internal/e2e/configs/ssl.yaml create mode 100644 internal/e2e/testutil/certs.go create mode 100644 internal/e2e/testutil/concurrent.go create mode 100644 internal/e2e/testutil/container.go create mode 100644 internal/e2e/testutil/container_test.go diff --git a/internal/e2e/certs/server.crt b/internal/e2e/certs/server.crt new file mode 100644 index 0000000..8ab04e9 --- /dev/null +++ b/internal/e2e/certs/server.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUAJotgH8D4lGjfdPM5lLNvMPrwRowDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyMjEwNDQ1MloXDTI3MDQy +MjEwNDQ1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAy/raK3YKoM7dOrQqV11ADOVnhh4j214RO6N5wWty7JAI +b+Yr464ZiOBTwinyCMFo47Obq5+RRCZmHS8gkrcLMv4yiMLU87ZGK29w5qlDhUVL +75w26qCGnCoxCS4zcicOpI+zh/siDMEVh/DG8SAzhdEZkgFyFsCigIwCGtdL//RF +iIyp/WYHmsqmyTS+03MBOmzHT+jDS5Ilgt/651VaLArIjnMEGQVhQEG7IMfdq3yO +SZZBsJtB4EECA5TuPIgBbCUuG8dZCnhJAQ3C9cKt9HJ0UnRhpJmWWrO1WmTA7WFb +izfE28VA6gsei+aAKFJ/ti/O0h8leq00p1unJ6vCswIDAQABo1MwUTAdBgNVHQ4E +FgQUynDeX1x5o5rZK4wvNAqZRVaBIe4wHwYDVR0jBBgwFoAUynDeX1x5o5rZK4wv +NAqZRVaBIe4wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAk/zR +fBVCpJUVcLeXqHlBaiWPkvWk2UG5zHjUOQy9oq9JyCnbRTXYr1eWGYc6Ycc66n8i ++gAj9TURBhjqwxy+bseSr9XRlyVDzExx1K57ETXE00OakLlFhga9RkOSQeOoVZfo +pkMTOpYOeFW2S6Oz6yYo+vOxnFwAajuio1AD1rkVCKGFDj3LJry1frz1WURYsMvA +AwnAHva4GL4cd1xbCedpZiLRO03RjluU3peGi6zhZDWNLWoBS3fxRFZBkG+1D2pD +lPn43V2sysV25/8LYbxWN9CUiAlTDvFaykiOVO2RSAz84cxyNGb1JsE2059XqXXQ +sMajslnTT8QJASm+cg== +-----END CERTIFICATE----- diff --git a/internal/e2e/certs/server.key b/internal/e2e/certs/server.key new file mode 100644 index 0000000..b19096a --- /dev/null +++ b/internal/e2e/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDL+tordgqgzt06 +tCpXXUAM5WeGHiPbXhE7o3nBa3LskAhv5ivjrhmI4FPCKfIIwWjjs5urn5FEJmYd +LyCStwsy/jKIwtTztkYrb3DmqUOFRUvvnDbqoIacKjEJLjNyJw6kj7OH+yIMwRWH +8MbxIDOF0RmSAXIWwKKAjAIa10v/9EWIjKn9ZgeayqbJNL7TcwE6bMdP6MNLkiWC +3/rnVVosCsiOcwQZBWFAQbsgx92rfI5JlkGwm0HgQQIDlO48iAFsJS4bx1kKeEkB +DcL1wq30cnRSdGGkmZZas7VaZMDtYVuLN8TbxUDqCx6L5oAoUn+2L87SHyV6rTSn +W6cnq8KzAgMBAAECggEABeYJ/hIVSkOBJpaZgi+U+mp93Q/Xu0W+m39li3WB0XEe +1Vbjc7DlVc3K45qr6vCQ3xjNX8qm/ECyA5ZedkUAeBKIFT1bxB/EFV3NIYEejebh +mcW7hCXCjsHc63uLCvox1F9qx9Fi0YSZ4fWmrcgKcAxfwBb2R3h9moMHTThm9GP8 +4Ur6weq+iZ4WqQtVVpifUkLKfkFNTWcOym06N8ysoP3VDXBUAJgQIFnQ6Tua8V/y +VTpgp0jJp7MLo9TaaAVAsFALvufAzSTdb7rmZsZxN6X/4ftHkvN+IchnOZdPnqEo +roQnRpD42eDSkWA3KLHjqFpBxMpWv5QE2tpxn/WYgQKBgQDx08RB4SWbT0qyFNDf +sPDkpAy+jmXtqIr1Q8TY095HUXrGTJSMuyI27UwIOPEOuVgR5RLLdm981w98U723 +qgCQSLgWCDhManY117elPBGPdh9eTIhVPGGeJVfPdZJv86E9xgJMEH2T7rQX9XrD +HZeP5M9vJzDopnWYiBtUDGjwgQKBgQDX7z8ityqLz1JC2nBK2TW8HFZXdOFtmOS3 +glnhhsVXBkQtfdDahr2JxSIPVs41AHnZ6YoTAuLP2BZNfM2/eJJ3v51x6VimycsE +yPqDh6Slzy+Zxi/rPHdPosT3kVB/fw9Mh5EhKim+ePD6zwyAg/VbQ6TAyL8RGLwF +L9krz6dZMwKBgQDrZFgITJFfsXojCDIdW83QsYMaReAfy0yf2zyUVv9p6n0Y41hB ++LIgRzIYWt0CmRWKLbHyzTFOksSnH5hvXBK6X56ZC51JfOPeG0HOnD73p2J3VwXp ++iGHYBAuh+JoCP1t9P0tb2vdgKBbJf8+R+LNFUt5heuMuuOC+xGm6YGIgQKBgQCg +PeECaB7gpbeL+QI/6TThJb5FJG1toXqJW+hLXnHvhKzvfWCR6k+Zf3spGufnmTTT +q2VVelzKlLboReeBYX5/8uhp8hphm3pXULXnCa/7g+b1HIfO744ywL2eYm30T1wR +FMF0dQ+JU8KwvRmv/T0EvwvyLIbtrwdIahulUXIE1QKBgQCwS5N/CZ3Hj+885s6C +bRPEJg7ErgTUGu5fwXU8v+bKNtZuiEc9YwthRogmsbcIq64JLy1vkHSoNz23Gvfk +xn3u3+FM/elxkYjGEUYEKWcqx4i2C/xBgyCGn+lkUm3J9tQoZyf4X3x5ENS3t2bE +Z666WXF0W31ecOklmvu8fXMKUQ== +-----END PRIVATE KEY----- diff --git a/internal/e2e/configs/basic.yaml b/internal/e2e/configs/basic.yaml new file mode 100644 index 0000000..20cbd1b --- /dev/null +++ b/internal/e2e/configs/basic.yaml @@ -0,0 +1,7 @@ +# E2E 测试配置 - 基础 HTTP 服务器 +server: + listen: ":8080" + +static: + root: "/var/www/html" + index: "index.html" diff --git a/internal/e2e/configs/proxy.yaml b/internal/e2e/configs/proxy.yaml new file mode 100644 index 0000000..36a29f7 --- /dev/null +++ b/internal/e2e/configs/proxy.yaml @@ -0,0 +1,10 @@ +# E2E 测试配置 - 反向代理 +server: + listen: ":8080" + +proxy: + path: "/" + targets: + - url: "http://backend1:80" + - url: "http://backend2:80" + load_balance: "round_robin" diff --git a/internal/e2e/configs/ssl.yaml b/internal/e2e/configs/ssl.yaml new file mode 100644 index 0000000..460435d --- /dev/null +++ b/internal/e2e/configs/ssl.yaml @@ -0,0 +1,12 @@ +# E2E 测试配置 - SSL/TLS 服务器 +server: + listen: ":8443" + +ssl: + enabled: true + cert: "/etc/lolly/certs/server.crt" + key: "/etc/lolly/certs/server.key" + +static: + root: "/var/www/html" + index: "index.html" diff --git a/internal/e2e/testutil/certs.go b/internal/e2e/testutil/certs.go new file mode 100644 index 0000000..267ff5f --- /dev/null +++ b/internal/e2e/testutil/certs.go @@ -0,0 +1,132 @@ +//go:build e2e + +// Package testutil 提供 E2E 测试的工具函数。 +package testutil + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +// GenerateSelfSignedCert 生成自签名证书和私钥。 +// +// 返回证书路径、私钥路径、清理函数和错误。 +// 清理函数会删除生成的文件。 +func GenerateSelfSignedCert(tmpDir string) (certPath, keyPath string, cleanup func(), err error) { + // 生成 ECDSA 私钥 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", nil, fmt.Errorf("failed to generate private key: %w", err) + } + + // 生成序列号 + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", nil, fmt.Errorf("failed to generate serial number: %w", err) + } + + // 创建证书模板 + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Lolly E2E Test"}, + CommonName: "localhost", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: []string{"localhost"}, + IPAddresses: nil, + } + + // 生成证书 + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return "", "", nil, fmt.Errorf("failed to create certificate: %w", err) + } + + // 确保目录存在 + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return "", "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + // 写入证书文件 + certPath = filepath.Join(tmpDir, "cert.pem") + certFile, err := os.Create(certPath) + if err != nil { + return "", "", nil, fmt.Errorf("failed to create cert file: %w", err) + } + if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + certFile.Close() + return "", "", nil, fmt.Errorf("failed to write cert: %w", err) + } + certFile.Close() + + // 写入私钥文件 + keyPath = filepath.Join(tmpDir, "key.pem") + keyFile, err := os.Create(keyPath) + if err != nil { + return "", "", nil, fmt.Errorf("failed to create key file: %w", err) + } + privBytes, err := x509.MarshalECPrivateKey(privateKey) + if err != nil { + keyFile.Close() + return "", "", nil, fmt.Errorf("failed to marshal private key: %w", err) + } + if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes}); err != nil { + keyFile.Close() + return "", "", nil, fmt.Errorf("failed to write key: %w", err) + } + keyFile.Close() + + // 清理函数 + cleanup = func() { + os.Remove(certPath) + os.Remove(keyPath) + } + + return certPath, keyPath, cleanup, nil +} + +// GenerateCertPool 从证书文件创建 x509.CertPool。 +func GenerateCertPool(certPath string) (*x509.CertPool, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, fmt.Errorf("failed to read cert file: %w", err) + } + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certPEM) { + return nil, fmt.Errorf("failed to append cert to pool") + } + + return certPool, nil +} + +// GenerateTLSConfig 生成客户端 TLS 配置,信任指定的证书。 +func GenerateTLSConfig(certPath string) (*TLSConfig, error) { + certPool, err := GenerateCertPool(certPath) + if err != nil { + return nil, err + } + + return &TLSConfig{ + RootCAs: certPool, + }, nil +} + +// TLSConfig 简化的 TLS 配置。 +type TLSConfig struct { + RootCAs *x509.CertPool +} diff --git a/internal/e2e/testutil/concurrent.go b/internal/e2e/testutil/concurrent.go new file mode 100644 index 0000000..88ab442 --- /dev/null +++ b/internal/e2e/testutil/concurrent.go @@ -0,0 +1,100 @@ +//go:build e2e + +// Package testutil 提供 E2E 测试的工具函数。 +package testutil + +import ( + "fmt" + "io" + "net/http" + "sync" + "testing" + "time" +) + +// ConcurrentRequestConfig 并发请求配置。 +type ConcurrentRequestConfig struct { + URL string + Count int + Timeout time.Duration + ExpectCode int +} + +// ConcurrentRequestResult 并发请求结果。 +type ConcurrentRequestResult struct { + Index int + StatusCode int + Error error +} + +// RunConcurrentRequests 运行并发请求并返回结果。 +// +// 使用 sync.WaitGroup 实现真正的并发。 +// 返回所有请求的结果,包括状态码和错误。 +func RunConcurrentRequests(cfg ConcurrentRequestConfig) []ConcurrentRequestResult { + results := make([]ConcurrentRequestResult, cfg.Count) + var wg sync.WaitGroup + + client := &http.Client{ + Timeout: cfg.Timeout, + } + + for i := 0; i < cfg.Count; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + + result := ConcurrentRequestResult{Index: index} + + resp, err := client.Get(cfg.URL) + if err != nil { + result.Error = fmt.Errorf("request %d failed: %w", index, err) + results[index] = result + return + } + + // 读取并丢弃响应体,确保连接可复用 + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + result.StatusCode = resp.StatusCode + results[index] = result + }(i) + } + + wg.Wait() + return results +} + +// VerifyConcurrentResults 验证并发请求结果。 +// +// 检查所有请求是否返回预期状态码。 +// 返回失败的请求列表。 +func VerifyConcurrentResults(t *testing.T, results []ConcurrentRequestResult, expectCode int) []ConcurrentRequestResult { + var failures []ConcurrentRequestResult + + for _, r := range results { + if r.Error != nil { + t.Logf("Request %d error: %v", r.Index, r.Error) + failures = append(failures, r) + continue + } + + if r.StatusCode != expectCode { + t.Logf("Request %d: expected %d, got %d", r.Index, expectCode, r.StatusCode) + failures = append(failures, r) + } + } + + return failures +} + +// RunAndVerifyConcurrentRequests 运行并发请求并验证结果。 +// +// 组合 RunConcurrentRequests 和 VerifyConcurrentResults。 +// 如果有任何失败,返回错误列表。 +func RunAndVerifyConcurrentRequests(t *testing.T, cfg ConcurrentRequestConfig) []ConcurrentRequestResult { + results := RunConcurrentRequests(cfg) + failures := VerifyConcurrentResults(t, results, cfg.ExpectCode) + return failures +} diff --git a/internal/e2e/testutil/container.go b/internal/e2e/testutil/container.go new file mode 100644 index 0000000..8119d66 --- /dev/null +++ b/internal/e2e/testutil/container.go @@ -0,0 +1,255 @@ +//go:build e2e + +// Package testutil 提供 E2E 测试的工具函数。 +// +// 包含容器管理、测试配置、等待工具等。 +// +// 作者:xfy +package testutil + +import ( + "context" + "fmt" + "net/http" + "strings" + "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" +` + +// 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) { + req := testcontainers.ContainerRequest{ + Image: "lolly:latest", + ExposedPorts: []string{"8080/tcp", "8443/tcp"}, + WaitingFor: wait.ForLog("HTTP 服务器启动中").WithStartupTimeout(30 * time.Second), + } + + // 配置文件挂载 + if configPath != "" { + req.Mounts = []testcontainers.ContainerMount{ + { + Source: testcontainers.GenericBindMountSource{ + HostPath: configPath, + }, + Target: "/etc/lolly/lolly.yaml", + }, + } + } else { + // 使用内嵌默认配置 + req.Files = []testcontainers.ContainerFile{ + { + Reader: strings.NewReader(defaultLollyConfig), + ContainerFilePath: "/etc/lolly/lolly.yaml", + FileMode: 0o644, + }, + } + } + + 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 == 200 || resp.StatusCode == 404 { + // 200 或 404 都表示服务器正在运行 + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("service not healthy after %v", timeout) +} + +// 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{"echo", "test"}, + 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 镜像是否可用。 +func LollyImageAvailable(ctx context.Context) bool { + req := testcontainers.ContainerRequest{ + Image: "lolly:latest", + Cmd: []string{"echo", "test"}, + AutoRemove: true, + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return false + } + container.Terminate(ctx) + return true +} + +// StartNginxContainer 启动 nginx 容器,返回容器和访问地址。 +func StartNginxContainer(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 nginx container: %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 +} diff --git a/internal/e2e/testutil/container_test.go b/internal/e2e/testutil/container_test.go new file mode 100644 index 0000000..b310019 --- /dev/null +++ b/internal/e2e/testutil/container_test.go @@ -0,0 +1,60 @@ +//go:build e2e + +// Package testutil 提供 E2E 测试的工具函数。 +// +// 包含容器管理、测试配置、等待工具等。 +// +// 作者:xfy +package testutil + +import ( + "context" + "testing" + "time" +) + +// TestContainerSetup 测试容器基础设施。 +func TestContainerSetup(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // 检查 Docker 是否可用 + if !DockerAvailable(ctx) { + t.Skip("Docker not available, skipping E2E tests") + } + + t.Log("Docker is available for E2E tests") +} + +// TestDockerAvailable 测试 Docker 可用性检查。 +func TestDockerAvailable(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + available := DockerAvailable(ctx) + t.Logf("Docker available: %v", available) + + // 不强制要求 Docker 可用,只是报告状态 +} + +// TestMockBackendContainer 测试模拟后端容器启动。 +func TestMockBackendContainer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if !DockerAvailable(ctx) { + t.Skip("Docker not available") + } + + container, addr, err := MockBackendContainer(ctx, 80) + if err != nil { + t.Fatalf("Failed to start mock backend: %v", err) + } + defer container.Terminate(ctx) + + t.Logf("Mock backend started at: %s", addr) + + if addr == "" { + t.Error("Expected non-empty address") + } +}