Skip to content

Commit 4a62cc2

Browse files
committed
feat(containers): add E-suffix variants for TestMain usage
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 4a62cc2

7 files changed

Lines changed: 207 additions & 88 deletions

File tree

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: 26 additions & 57 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,6 @@ 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)
5357

5458
req := testcontainers.ContainerRequest{
5559
Image: imageName,
@@ -60,34 +64,33 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
6064
WaitingFor: wait.ForListeningPort(nat.Port("21/tcp")).WithStartupTimeout(2 * time.Minute),
6165
}
6266

63-
t.Logf("creating FTP container using %s (minimal env vars, fixed host port %s)...", imageName, fixedHostControlPort)
6467
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
6568
ContainerRequest: req,
6669
Started: true,
6770
})
68-
// create the container instance to use its methods
69-
ftpContainer := &FTPTestContainer{}
70-
71-
// error handling with detailed logging for container startup issues
7271
if err != nil {
73-
ftpContainer.logContainerError(ctx, t, container, err, imageName)
72+
return nil, fmt.Errorf("failed to create ftp container: %w", err)
7473
}
75-
t.Logf("FTP container created and started (ID: %s)", container.GetContainerID())
7674

7775
host, err := container.Host(ctx)
78-
require.NoError(t, err, "Failed to get container host")
76+
if err != nil {
77+
_ = container.Terminate(ctx)
78+
return nil, fmt.Errorf("failed to get container host: %w", err)
79+
}
7980

8081
// since we requested a fixed port, construct the nat.Port struct directly
8182
// 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)")
83+
if _, err = container.MappedPort(ctx, "21"); err != nil {
84+
_ = container.Terminate(ctx)
85+
return nil, fmt.Errorf("failed to get mapped port: %w", err)
86+
}
8487

8588
// construct the Port struct based on our fixed request
8689
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)
90+
if err != nil {
91+
_ = container.Terminate(ctx)
92+
return nil, fmt.Errorf("failed to create nat.Port for fixed host port: %w", err)
93+
}
9194

9295
time.Sleep(1 * time.Second)
9396

@@ -97,20 +100,18 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer {
97100
Port: fixedHostNatPort, // use the manually constructed nat.Port for the fixed host port
98101
User: defaultUser,
99102
Password: defaultPassword,
100-
}
103+
}, nil
101104
}
102105

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

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

116117
c, err := ftp.Dial(connStr, opts...)
@@ -123,9 +124,7 @@ func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error
123124
fmt.Printf("Attempting FTP login with user: %s\n", fc.User)
124125
if err := c.Login(fc.User, fc.Password); err != nil {
125126
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-
}
127+
_ = c.Quit()
129128
return nil, fmt.Errorf("failed to login to FTP server with user %s: %w", fc.User, err)
130129
}
131130
fmt.Printf("FTP Login successful for user %s\n", fc.User)
@@ -377,33 +376,3 @@ func splitPath(path string) []string {
377376
}
378377
return strings.Split(cleanPath, "/")
379378
}
380-
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) {
392-
if container == nil {
393-
t.Logf("Container object was nil after GenericContainer failure.")
394-
return
395-
}
396-
397-
logs, logErr := container.Logs(ctx)
398-
if logErr != nil {
399-
t.Logf("Could not retrieve container logs after startup failure: %v", logErr)
400-
return
401-
}
402-
403-
logBytes, _ := io.ReadAll(logs)
404-
if closeErr := logs.Close(); closeErr != nil {
405-
t.Logf("warning: failed to close logs reader: %v", closeErr)
406-
}
407-
408-
t.Logf("Container logs on startup failure:\n%s", string(logBytes))
409-
}

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: 30 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,41 @@ 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+
_ = os.Setenv("MONGO_TEST", origURL) // restore original env value
71+
_ = container.Terminate(ctx)
72+
return nil, fmt.Errorf("failed to connect to mongo: %w", err)
73+
}
5174

5275
return &MongoTestContainer{
5376
Container: container,
5477
URI: uri,
5578
Client: client,
5679
origURL: origURL,
57-
}
80+
}, nil
5881
}
5982

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

0 commit comments

Comments
 (0)