Skip to content

Commit 4927510

Browse files
authored
feat(containers): add E-suffix variants for TestMain usage (#7)
Add error-returning variants of all container constructors for use in TestMain where *testing.T is not available. E variants return (*Container, error) instead of using require.NoError internally. - Add NewPostgresTestContainerE and NewPostgresTestContainerWithDBE - Add NewMySQLTestContainerE and NewMySQLTestContainerWithDBE - Add NewMongoTestContainerE with env restoration on connect failure - Add NewSSHTestContainerE and NewSSHTestContainerWithUserE - Add NewFTPTestContainerE - Add NewLocalstackTestContainerE - Ensure container termination on all error paths - Update README with TestMain example and E-suffix variants table
1 parent a784721 commit 4927510

File tree

7 files changed

+228
-77
lines changed

7 files changed

+228
-77
lines changed

README.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,33 +249,76 @@ func TestWithFTP(t *testing.T) {
249249
ctx := context.Background()
250250
ftpContainer := containers.NewFTPTestContainer(ctx, t)
251251
defer ftpContainer.Close(ctx)
252-
252+
253253
// Connection details
254254
ftpHost := ftpContainer.GetIP() // Container host
255255
ftpPort := ftpContainer.GetPort() // Container port (default: 2121)
256256
ftpUser := ftpContainer.GetUser() // Default: "ftpuser"
257257
ftpPassword := ftpContainer.GetPassword() // Default: "ftppass"
258-
258+
259259
// Upload a file
260-
localFile := "/path/to/local/file.txt"
260+
localFile := "/path/to/local/file.txt"
261261
remotePath := "file.txt"
262262
err := ftpContainer.SaveFile(ctx, localFile, remotePath)
263263
require.NoError(t, err)
264-
264+
265265
// Download a file
266266
downloadPath := "/path/to/download/location.txt"
267267
err = ftpContainer.GetFile(ctx, remotePath, downloadPath)
268268
require.NoError(t, err)
269-
269+
270270
// List files
271271
entries, err := ftpContainer.ListFiles(ctx, "/")
272272
require.NoError(t, err)
273273
for _, entry := range entries {
274274
fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory
275275
}
276-
276+
277277
// Delete a file
278278
err = ftpContainer.DeleteFile(ctx, remotePath)
279279
require.NoError(t, err)
280280
}
281-
```
281+
282+
// Using containers in TestMain for shared container across all tests
283+
// All containers have E-suffix variants that return errors instead of using require.NoError
284+
var pgContainer *containers.PostgresTestContainer
285+
286+
func TestMain(m *testing.M) {
287+
ctx := context.Background()
288+
289+
var err error
290+
pgContainer, err = containers.NewPostgresTestContainerE(ctx)
291+
if err != nil {
292+
log.Fatalf("failed to start postgres container: %v", err)
293+
}
294+
295+
code := m.Run()
296+
297+
pgContainer.Close(ctx)
298+
os.Exit(code)
299+
}
300+
301+
func TestWithSharedContainer(t *testing.T) {
302+
// use pgContainer.ConnectionString() to connect
303+
db, err := sql.Open("postgres", pgContainer.ConnectionString())
304+
require.NoError(t, err)
305+
defer db.Close()
306+
// ...
307+
}
308+
```
309+
310+
### Error-Returning Container Variants (E-suffix)
311+
312+
All container constructors have E-suffix variants that return `(*Container, error)` instead of using `require.NoError`. This is useful for `TestMain` where `*testing.T` is not available:
313+
314+
| Standard | Error-returning |
315+
|----------|-----------------|
316+
| `NewPostgresTestContainer` | `NewPostgresTestContainerE` |
317+
| `NewPostgresTestContainerWithDB` | `NewPostgresTestContainerWithDBE` |
318+
| `NewMySQLTestContainer` | `NewMySQLTestContainerE` |
319+
| `NewMySQLTestContainerWithDB` | `NewMySQLTestContainerWithDBE` |
320+
| `NewMongoTestContainer` | `NewMongoTestContainerE` |
321+
| `NewSSHTestContainer` | `NewSSHTestContainerE` |
322+
| `NewSSHTestContainerWithUser` | `NewSSHTestContainerWithUserE` |
323+
| `NewFTPTestContainer` | `NewFTPTestContainerE` |
324+
| `NewLocalstackTestContainer` | `NewLocalstackTestContainerE` |

containers/ftp.go

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ type FTPTestContainer struct {
3030

3131
// NewFTPTestContainer uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax.
3232
func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
33+
fc, err := NewFTPTestContainerE(ctx)
34+
require.NoError(t, err)
35+
return fc
36+
}
37+
38+
// NewFTPTestContainerE uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax.
39+
// Returns error instead of using require.NoError, suitable for TestMain usage.
40+
func NewFTPTestContainerE(ctx context.Context) (*FTPTestContainer, error) {
3341
const (
3442
defaultUser = "ftpuser"
3543
defaultPassword = "ftppass"
@@ -38,9 +46,6 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
3846
fixedHostControlPort = "2121"
3947
)
4048

41-
// set up logging for testcontainers if the appropriate API is available
42-
t.Logf("Setting up FTP test container")
43-
4449
pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort)
4550
pasvPortRangeHost := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) // map 1:1
4651
exposedPortsWithBinding := []string{
@@ -49,7 +54,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
4954
}
5055

5156
imageName := "delfer/alpine-ftp-server:latest"
52-
t.Logf("Using FTP server image: %s", imageName)
57+
fmt.Printf("Creating FTP container using %s (fixed host port %s)...\n", imageName, fixedHostControlPort)
5358

5459
req := testcontainers.ContainerRequest{
5560
Image: imageName,
@@ -60,57 +65,58 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
6065
WaitingFor: wait.ForListeningPort(nat.Port("21/tcp")).WithStartupTimeout(2 * time.Minute),
6166
}
6267

63-
t.Logf("creating FTP container using %s (minimal env vars, fixed host port %s)...", imageName, fixedHostControlPort)
6468
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
6569
ContainerRequest: req,
6670
Started: true,
6771
})
68-
// create the container instance to use its methods
69-
ftpContainer := &FTPTestContainer{}
70-
71-
// error handling with detailed logging for container startup issues
7272
if err != nil {
73-
ftpContainer.logContainerError(ctx, t, container, err, imageName)
73+
logContainerLogs(ctx, container)
74+
return nil, fmt.Errorf("failed to create ftp container: %w", err)
7475
}
75-
t.Logf("FTP container created and started (ID: %s)", container.GetContainerID())
76+
fmt.Printf("FTP container created and started (ID: %s)\n", container.GetContainerID())
7677

7778
host, err := container.Host(ctx)
78-
require.NoError(t, err, "Failed to get container host")
79+
if err != nil {
80+
_ = container.Terminate(ctx)
81+
return nil, fmt.Errorf("failed to get container host: %w", err)
82+
}
7983

8084
// since we requested a fixed port, construct the nat.Port struct directly
8185
// we still call MappedPort just to ensure the container is properly exposing *something* for port 21
82-
_, err = container.MappedPort(ctx, "21")
83-
require.NoError(t, err, "Failed to get mapped port info for container port 21/tcp (even though fixed)")
86+
if _, err = container.MappedPort(ctx, "21"); err != nil {
87+
_ = container.Terminate(ctx)
88+
return nil, fmt.Errorf("failed to get mapped port: %w", err)
89+
}
8490

8591
// construct the Port struct based on our fixed request
8692
fixedHostNatPort, err := nat.NewPort("tcp", fixedHostControlPort)
87-
require.NoError(t, err, "Failed to create nat.Port for fixed host port")
88-
89-
t.Logf("FTP container should be accessible at: %s:%s (Control Plane)", host, fixedHostControlPort)
90-
t.Logf("FTP server using default config, passive ports %s mapped to host %s", pasvPortRangeContainer, pasvPortRangeHost)
93+
if err != nil {
94+
_ = container.Terminate(ctx)
95+
return nil, fmt.Errorf("failed to create nat.Port for fixed host port: %w", err)
96+
}
9197

9298
time.Sleep(1 * time.Second)
9399

100+
fmt.Printf("FTP container accessible at: %s:%s (passive ports %s)\n", host, fixedHostControlPort, pasvPortRangeHost)
101+
94102
return &FTPTestContainer{
95103
Container: container,
96104
Host: host,
97105
Port: fixedHostNatPort, // use the manually constructed nat.Port for the fixed host port
98106
User: defaultUser,
99107
Password: defaultPassword,
100-
}
108+
}, nil
101109
}
102110

103-
// connect function (Use default EPSV enabled)
111+
// connect establishes an FTP connection and logs in
104112
func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error) {
105113
opts := []ftp.DialOption{
106114
ftp.DialWithTimeout(30 * time.Second),
107115
ftp.DialWithContext(ctx),
108-
ftp.DialWithDebugOutput(os.Stdout), // keep for debugging
109-
// *** Use default (EPSV enabled) ***
110-
// ftp.DialWithDisabledEPSV(true),
116+
ftp.DialWithDebugOutput(os.Stdout),
111117
}
112118

113-
connStr := fc.ConnectionString() // will use the fixed host port (e.g., 2121)
119+
connStr := fc.ConnectionString()
114120
fmt.Printf("Attempting FTP connection to: %s (User: %s)\n", connStr, fc.User)
115121

116122
c, err := ftp.Dial(connStr, opts...)
@@ -123,9 +129,7 @@ func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error
123129
fmt.Printf("Attempting FTP login with user: %s\n", fc.User)
124130
if err := c.Login(fc.User, fc.Password); err != nil {
125131
fmt.Printf("FTP Login Error for user %s: %v\n", fc.User, err)
126-
if quitErr := c.Quit(); quitErr != nil {
127-
fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr)
128-
}
132+
_ = c.Quit()
129133
return nil, fmt.Errorf("failed to login to FTP server with user %s: %w", fc.User, err)
130134
}
131135
fmt.Printf("FTP Login successful for user %s\n", fc.User)
@@ -378,32 +382,25 @@ func splitPath(path string) []string {
378382
return strings.Split(cleanPath, "/")
379383
}
380384

381-
// logContainerError handles container startup errors with detailed logging
382-
func (fc *FTPTestContainer) logContainerError(_ context.Context, t *testing.T, container testcontainers.Container, err error, imageName string) {
383-
logCtx, logCancel := context.WithTimeout(context.Background(), 10*time.Second)
384-
defer logCancel()
385-
386-
fc.logContainerLogs(logCtx, t, container)
387-
require.NoError(t, err, "Failed to create or start FTP container %s", imageName)
388-
}
389-
390-
// logContainerLogs attempts to fetch and log container logs
391-
func (fc *FTPTestContainer) logContainerLogs(ctx context.Context, t *testing.T, container testcontainers.Container) {
385+
// logContainerLogs attempts to fetch and print container logs for debugging startup failures
386+
func logContainerLogs(ctx context.Context, container testcontainers.Container) {
392387
if container == nil {
393-
t.Logf("Container object was nil after GenericContainer failure.")
388+
fmt.Printf("Container object was nil after GenericContainer failure.\n")
394389
return
395390
}
396391

397-
logs, logErr := container.Logs(ctx)
398-
if logErr != nil {
399-
t.Logf("Could not retrieve container logs after startup failure: %v", logErr)
392+
logCtx, logCancel := context.WithTimeout(ctx, 10*time.Second)
393+
defer logCancel()
394+
395+
logs, err := container.Logs(logCtx)
396+
if err != nil {
397+
fmt.Printf("Could not retrieve container logs after startup failure: %v\n", err)
400398
return
401399
}
400+
defer logs.Close()
402401

403402
logBytes, _ := io.ReadAll(logs)
404-
if closeErr := logs.Close(); closeErr != nil {
405-
t.Logf("warning: failed to close logs reader: %v", closeErr)
403+
if len(logBytes) > 0 {
404+
fmt.Printf("Container logs:\n%s\n", string(logBytes))
406405
}
407-
408-
t.Logf("Container logs on startup failure:\n%s", string(logBytes))
409406
}

containers/localstack.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ type LocalstackTestContainer struct {
3131

3232
// NewLocalstackTestContainer creates a new Localstack test container and returns a LocalstackTestContainer instance
3333
func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTestContainer {
34+
lc, err := NewLocalstackTestContainerE(ctx)
35+
require.NoError(t, err)
36+
return lc
37+
}
38+
39+
// NewLocalstackTestContainerE creates a new Localstack test container and returns a LocalstackTestContainer instance.
40+
// Returns error instead of using require.NoError, suitable for TestMain usage.
41+
func NewLocalstackTestContainerE(ctx context.Context) (*LocalstackTestContainer, error) {
3442
req := testcontainers.ContainerRequest{
3543
Image: "localstack/localstack:3.0.0",
3644
ExposedPorts: []string{"4566/tcp"},
@@ -50,19 +58,27 @@ func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTe
5058
ContainerRequest: req,
5159
Started: true,
5260
})
53-
require.NoError(t, err)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create localstack container: %w", err)
63+
}
5464

5565
host, err := container.Host(ctx)
56-
require.NoError(t, err)
66+
if err != nil {
67+
_ = container.Terminate(ctx)
68+
return nil, fmt.Errorf("failed to get container host: %w", err)
69+
}
5770

5871
port, err := container.MappedPort(ctx, "4566")
59-
require.NoError(t, err)
72+
if err != nil {
73+
_ = container.Terminate(ctx)
74+
return nil, fmt.Errorf("failed to get mapped port: %w", err)
75+
}
6076

6177
endpoint := fmt.Sprintf("http://%s:%s", host, port.Port())
6278
return &LocalstackTestContainer{
6379
Container: container,
6480
Endpoint: endpoint,
65-
}
81+
}, nil
6682
}
6783

6884
// MakeS3Connection creates a new S3 connection using the test container endpoint and returns the connection and a bucket name

containers/mongo.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ type MongoTestContainer struct {
2424

2525
// NewMongoTestContainer creates a new MongoDB test container
2626
func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int) *MongoTestContainer {
27+
mc, err := NewMongoTestContainerE(ctx, mongoVersion)
28+
require.NoError(t, err)
29+
return mc
30+
}
31+
32+
// NewMongoTestContainerE creates a new MongoDB test container.
33+
// Returns error instead of using require.NoError, suitable for TestMain usage.
34+
func NewMongoTestContainerE(ctx context.Context, mongoVersion int) (*MongoTestContainer, error) {
2735
origURL := os.Getenv("MONGO_TEST")
2836
req := testcontainers.ContainerRequest{
2937
Image: fmt.Sprintf("mongo:%d", mongoVersion),
@@ -35,26 +43,45 @@ func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int)
3543
ContainerRequest: req,
3644
Started: true,
3745
})
38-
require.NoError(t, err)
46+
if err != nil {
47+
return nil, fmt.Errorf("failed to create mongo container: %w", err)
48+
}
3949

4050
host, err := container.Host(ctx)
41-
require.NoError(t, err)
51+
if err != nil {
52+
_ = container.Terminate(ctx)
53+
return nil, fmt.Errorf("failed to get container host: %w", err)
54+
}
55+
4256
port, err := container.MappedPort(ctx, "27017")
43-
require.NoError(t, err)
57+
if err != nil {
58+
_ = container.Terminate(ctx)
59+
return nil, fmt.Errorf("failed to get mapped port: %w", err)
60+
}
4461

4562
uri := fmt.Sprintf("mongodb://%s:%s", host, port.Port())
46-
err = os.Setenv("MONGO_TEST", uri)
47-
require.NoError(t, err)
63+
if err = os.Setenv("MONGO_TEST", uri); err != nil {
64+
_ = container.Terminate(ctx)
65+
return nil, fmt.Errorf("failed to set MONGO_TEST env: %w", err)
66+
}
4867

4968
client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
50-
require.NoError(t, err)
69+
if err != nil {
70+
if origURL != "" {
71+
_ = os.Setenv("MONGO_TEST", origURL)
72+
} else {
73+
_ = os.Unsetenv("MONGO_TEST")
74+
}
75+
_ = container.Terminate(ctx)
76+
return nil, fmt.Errorf("failed to connect to mongo: %w", err)
77+
}
5178

5279
return &MongoTestContainer{
5380
Container: container,
5481
URI: uri,
5582
Client: client,
5683
origURL: origURL,
57-
}
84+
}, nil
5885
}
5986

6087
// Collection returns a new collection with unique name for tests

0 commit comments

Comments
 (0)