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:
parent
8f79fb6797
commit
5d38d9ab44
19
internal/e2e/certs/server.crt
Normal file
19
internal/e2e/certs/server.crt
Normal 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-----
|
||||||
28
internal/e2e/certs/server.key
Normal file
28
internal/e2e/certs/server.key
Normal 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-----
|
||||||
7
internal/e2e/configs/basic.yaml
Normal file
7
internal/e2e/configs/basic.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# E2E 测试配置 - 基础 HTTP 服务器
|
||||||
|
server:
|
||||||
|
listen: ":8080"
|
||||||
|
|
||||||
|
static:
|
||||||
|
root: "/var/www/html"
|
||||||
|
index: "index.html"
|
||||||
10
internal/e2e/configs/proxy.yaml
Normal file
10
internal/e2e/configs/proxy.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# E2E 测试配置 - 反向代理
|
||||||
|
server:
|
||||||
|
listen: ":8080"
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
path: "/"
|
||||||
|
targets:
|
||||||
|
- url: "http://backend1:80"
|
||||||
|
- url: "http://backend2:80"
|
||||||
|
load_balance: "round_robin"
|
||||||
12
internal/e2e/configs/ssl.yaml
Normal file
12
internal/e2e/configs/ssl.yaml
Normal 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"
|
||||||
132
internal/e2e/testutil/certs.go
Normal file
132
internal/e2e/testutil/certs.go
Normal 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
|
||||||
|
}
|
||||||
100
internal/e2e/testutil/concurrent.go
Normal file
100
internal/e2e/testutil/concurrent.go
Normal 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
|
||||||
|
}
|
||||||
255
internal/e2e/testutil/container.go
Normal file
255
internal/e2e/testutil/container.go
Normal 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
|
||||||
|
}
|
||||||
60
internal/e2e/testutil/container_test.go
Normal file
60
internal/e2e/testutil/container_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user