test(e2e): 添加 E2E 测试工具包和配置

- 添加 testutil 容器管理工具(lolly/nginx)
- 添加测试配置文件(basic/proxy/ssl)
- 添加测试用 SSL 证书

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-23 09:35:19 +08:00
parent 8f79fb6797
commit 5d38d9ab44
9 changed files with 623 additions and 0 deletions

View File

@ -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-----

View File

@ -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-----

View File

@ -0,0 +1,7 @@
# E2E 测试配置 - 基础 HTTP 服务器
server:
listen: ":8080"
static:
root: "/var/www/html"
index: "index.html"

View File

@ -0,0 +1,10 @@
# E2E 测试配置 - 反向代理
server:
listen: ":8080"
proxy:
path: "/"
targets:
- url: "http://backend1:80"
- url: "http://backend2:80"
load_balance: "round_robin"

View File

@ -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"

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}
}