diff --git a/README.md b/README.md index bc5d1e7..16e88c0 100644 --- a/README.md +++ b/README.md @@ -249,33 +249,76 @@ func TestWithFTP(t *testing.T) { ctx := context.Background() ftpContainer := containers.NewFTPTestContainer(ctx, t) defer ftpContainer.Close(ctx) - + // Connection details ftpHost := ftpContainer.GetIP() // Container host ftpPort := ftpContainer.GetPort() // Container port (default: 2121) ftpUser := ftpContainer.GetUser() // Default: "ftpuser" ftpPassword := ftpContainer.GetPassword() // Default: "ftppass" - + // Upload a file - localFile := "/path/to/local/file.txt" + localFile := "/path/to/local/file.txt" remotePath := "file.txt" err := ftpContainer.SaveFile(ctx, localFile, remotePath) require.NoError(t, err) - + // Download a file downloadPath := "/path/to/download/location.txt" err = ftpContainer.GetFile(ctx, remotePath, downloadPath) require.NoError(t, err) - + // List files entries, err := ftpContainer.ListFiles(ctx, "/") require.NoError(t, err) for _, entry := range entries { fmt.Println(entry.Name, entry.Type) // Type: 0 for file, 1 for directory } - + // Delete a file err = ftpContainer.DeleteFile(ctx, remotePath) require.NoError(t, err) } -``` \ No newline at end of file + +// Using containers in TestMain for shared container across all tests +// All containers have E-suffix variants that return errors instead of using require.NoError +var pgContainer *containers.PostgresTestContainer + +func TestMain(m *testing.M) { + ctx := context.Background() + + var err error + pgContainer, err = containers.NewPostgresTestContainerE(ctx) + if err != nil { + log.Fatalf("failed to start postgres container: %v", err) + } + + code := m.Run() + + pgContainer.Close(ctx) + os.Exit(code) +} + +func TestWithSharedContainer(t *testing.T) { + // use pgContainer.ConnectionString() to connect + db, err := sql.Open("postgres", pgContainer.ConnectionString()) + require.NoError(t, err) + defer db.Close() + // ... +} +``` + +### Error-Returning Container Variants (E-suffix) + +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: + +| Standard | Error-returning | +|----------|-----------------| +| `NewPostgresTestContainer` | `NewPostgresTestContainerE` | +| `NewPostgresTestContainerWithDB` | `NewPostgresTestContainerWithDBE` | +| `NewMySQLTestContainer` | `NewMySQLTestContainerE` | +| `NewMySQLTestContainerWithDB` | `NewMySQLTestContainerWithDBE` | +| `NewMongoTestContainer` | `NewMongoTestContainerE` | +| `NewSSHTestContainer` | `NewSSHTestContainerE` | +| `NewSSHTestContainerWithUser` | `NewSSHTestContainerWithUserE` | +| `NewFTPTestContainer` | `NewFTPTestContainerE` | +| `NewLocalstackTestContainer` | `NewLocalstackTestContainerE` | diff --git a/containers/ftp.go b/containers/ftp.go index 9e0d919..564abc3 100644 --- a/containers/ftp.go +++ b/containers/ftp.go @@ -30,6 +30,14 @@ type FTPTestContainer struct { // NewFTPTestContainer uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax. func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer { + fc, err := NewFTPTestContainerE(ctx) + require.NoError(t, err) + return fc +} + +// NewFTPTestContainerE uses delfer/alpine-ftp-server, minimal env vars, fixed host port mapping syntax. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewFTPTestContainerE(ctx context.Context) (*FTPTestContainer, error) { const ( defaultUser = "ftpuser" defaultPassword = "ftppass" @@ -38,9 +46,6 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer { fixedHostControlPort = "2121" ) - // set up logging for testcontainers if the appropriate API is available - t.Logf("Setting up FTP test container") - pasvPortRangeContainer := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) pasvPortRangeHost := fmt.Sprintf("%s-%s", pasvMinPort, pasvMaxPort) // map 1:1 exposedPortsWithBinding := []string{ @@ -49,7 +54,7 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer { } imageName := "delfer/alpine-ftp-server:latest" - t.Logf("Using FTP server image: %s", imageName) + fmt.Printf("Creating FTP container using %s (fixed host port %s)...\n", imageName, fixedHostControlPort) req := testcontainers.ContainerRequest{ Image: imageName, @@ -60,57 +65,58 @@ func NewFTPTestContainer(ctx context.Context, t *testing.T) *FTPTestContainer { WaitingFor: wait.ForListeningPort(nat.Port("21/tcp")).WithStartupTimeout(2 * time.Minute), } - t.Logf("creating FTP container using %s (minimal env vars, fixed host port %s)...", imageName, fixedHostControlPort) container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) - // create the container instance to use its methods - ftpContainer := &FTPTestContainer{} - - // error handling with detailed logging for container startup issues if err != nil { - ftpContainer.logContainerError(ctx, t, container, err, imageName) + logContainerLogs(ctx, container) + return nil, fmt.Errorf("failed to create ftp container: %w", err) } - t.Logf("FTP container created and started (ID: %s)", container.GetContainerID()) + fmt.Printf("FTP container created and started (ID: %s)\n", container.GetContainerID()) host, err := container.Host(ctx) - require.NoError(t, err, "Failed to get container host") + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } // since we requested a fixed port, construct the nat.Port struct directly // we still call MappedPort just to ensure the container is properly exposing *something* for port 21 - _, err = container.MappedPort(ctx, "21") - require.NoError(t, err, "Failed to get mapped port info for container port 21/tcp (even though fixed)") + if _, err = container.MappedPort(ctx, "21"); err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } // construct the Port struct based on our fixed request fixedHostNatPort, err := nat.NewPort("tcp", fixedHostControlPort) - require.NoError(t, err, "Failed to create nat.Port for fixed host port") - - t.Logf("FTP container should be accessible at: %s:%s (Control Plane)", host, fixedHostControlPort) - t.Logf("FTP server using default config, passive ports %s mapped to host %s", pasvPortRangeContainer, pasvPortRangeHost) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to create nat.Port for fixed host port: %w", err) + } time.Sleep(1 * time.Second) + fmt.Printf("FTP container accessible at: %s:%s (passive ports %s)\n", host, fixedHostControlPort, pasvPortRangeHost) + return &FTPTestContainer{ Container: container, Host: host, Port: fixedHostNatPort, // use the manually constructed nat.Port for the fixed host port User: defaultUser, Password: defaultPassword, - } + }, nil } -// connect function (Use default EPSV enabled) +// connect establishes an FTP connection and logs in func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error) { opts := []ftp.DialOption{ ftp.DialWithTimeout(30 * time.Second), ftp.DialWithContext(ctx), - ftp.DialWithDebugOutput(os.Stdout), // keep for debugging - // *** Use default (EPSV enabled) *** - // ftp.DialWithDisabledEPSV(true), + ftp.DialWithDebugOutput(os.Stdout), } - connStr := fc.ConnectionString() // will use the fixed host port (e.g., 2121) + connStr := fc.ConnectionString() fmt.Printf("Attempting FTP connection to: %s (User: %s)\n", connStr, fc.User) c, err := ftp.Dial(connStr, opts...) @@ -123,9 +129,7 @@ func (fc *FTPTestContainer) connect(ctx context.Context) (*ftp.ServerConn, error fmt.Printf("Attempting FTP login with user: %s\n", fc.User) if err := c.Login(fc.User, fc.Password); err != nil { fmt.Printf("FTP Login Error for user %s: %v\n", fc.User, err) - if quitErr := c.Quit(); quitErr != nil { - fmt.Printf("Warning: error closing FTP connection: %v\n", quitErr) - } + _ = c.Quit() return nil, fmt.Errorf("failed to login to FTP server with user %s: %w", fc.User, err) } fmt.Printf("FTP Login successful for user %s\n", fc.User) @@ -378,32 +382,25 @@ func splitPath(path string) []string { return strings.Split(cleanPath, "/") } -// logContainerError handles container startup errors with detailed logging -func (fc *FTPTestContainer) logContainerError(_ context.Context, t *testing.T, container testcontainers.Container, err error, imageName string) { - logCtx, logCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer logCancel() - - fc.logContainerLogs(logCtx, t, container) - require.NoError(t, err, "Failed to create or start FTP container %s", imageName) -} - -// logContainerLogs attempts to fetch and log container logs -func (fc *FTPTestContainer) logContainerLogs(ctx context.Context, t *testing.T, container testcontainers.Container) { +// logContainerLogs attempts to fetch and print container logs for debugging startup failures +func logContainerLogs(ctx context.Context, container testcontainers.Container) { if container == nil { - t.Logf("Container object was nil after GenericContainer failure.") + fmt.Printf("Container object was nil after GenericContainer failure.\n") return } - logs, logErr := container.Logs(ctx) - if logErr != nil { - t.Logf("Could not retrieve container logs after startup failure: %v", logErr) + logCtx, logCancel := context.WithTimeout(ctx, 10*time.Second) + defer logCancel() + + logs, err := container.Logs(logCtx) + if err != nil { + fmt.Printf("Could not retrieve container logs after startup failure: %v\n", err) return } + defer logs.Close() logBytes, _ := io.ReadAll(logs) - if closeErr := logs.Close(); closeErr != nil { - t.Logf("warning: failed to close logs reader: %v", closeErr) + if len(logBytes) > 0 { + fmt.Printf("Container logs:\n%s\n", string(logBytes)) } - - t.Logf("Container logs on startup failure:\n%s", string(logBytes)) } diff --git a/containers/localstack.go b/containers/localstack.go index d2353c2..f325bce 100644 --- a/containers/localstack.go +++ b/containers/localstack.go @@ -31,6 +31,14 @@ type LocalstackTestContainer struct { // NewLocalstackTestContainer creates a new Localstack test container and returns a LocalstackTestContainer instance func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTestContainer { + lc, err := NewLocalstackTestContainerE(ctx) + require.NoError(t, err) + return lc +} + +// NewLocalstackTestContainerE creates a new Localstack test container and returns a LocalstackTestContainer instance. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewLocalstackTestContainerE(ctx context.Context) (*LocalstackTestContainer, error) { req := testcontainers.ContainerRequest{ Image: "localstack/localstack:3.0.0", ExposedPorts: []string{"4566/tcp"}, @@ -50,19 +58,27 @@ func NewLocalstackTestContainer(ctx context.Context, t *testing.T) *LocalstackTe ContainerRequest: req, Started: true, }) - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("failed to create localstack container: %w", err) + } host, err := container.Host(ctx) - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } port, err := container.MappedPort(ctx, "4566") - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } endpoint := fmt.Sprintf("http://%s:%s", host, port.Port()) return &LocalstackTestContainer{ Container: container, Endpoint: endpoint, - } + }, nil } // MakeS3Connection creates a new S3 connection using the test container endpoint and returns the connection and a bucket name diff --git a/containers/mongo.go b/containers/mongo.go index 0ff55e3..287c094 100644 --- a/containers/mongo.go +++ b/containers/mongo.go @@ -24,6 +24,14 @@ type MongoTestContainer struct { // NewMongoTestContainer creates a new MongoDB test container func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int) *MongoTestContainer { + mc, err := NewMongoTestContainerE(ctx, mongoVersion) + require.NoError(t, err) + return mc +} + +// NewMongoTestContainerE creates a new MongoDB test container. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewMongoTestContainerE(ctx context.Context, mongoVersion int) (*MongoTestContainer, error) { origURL := os.Getenv("MONGO_TEST") req := testcontainers.ContainerRequest{ Image: fmt.Sprintf("mongo:%d", mongoVersion), @@ -35,26 +43,45 @@ func NewMongoTestContainer(ctx context.Context, t *testing.T, mongoVersion int) ContainerRequest: req, Started: true, }) - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("failed to create mongo container: %w", err) + } host, err := container.Host(ctx) - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } + port, err := container.MappedPort(ctx, "27017") - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } uri := fmt.Sprintf("mongodb://%s:%s", host, port.Port()) - err = os.Setenv("MONGO_TEST", uri) - require.NoError(t, err) + if err = os.Setenv("MONGO_TEST", uri); err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to set MONGO_TEST env: %w", err) + } client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) - require.NoError(t, err) + if err != nil { + if origURL != "" { + _ = os.Setenv("MONGO_TEST", origURL) + } else { + _ = os.Unsetenv("MONGO_TEST") + } + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to connect to mongo: %w", err) + } return &MongoTestContainer{ Container: container, URI: uri, Client: client, origURL: origURL, - } + }, nil } // Collection returns a new collection with unique name for tests diff --git a/containers/mysql.go b/containers/mysql.go index 41f8090..d4aafe0 100644 --- a/containers/mysql.go +++ b/containers/mysql.go @@ -27,8 +27,22 @@ func NewMySQLTestContainer(ctx context.Context, t *testing.T) *MySQLTestContaine return NewMySQLTestContainerWithDB(ctx, t, "test") } +// NewMySQLTestContainerE creates a new MySQL test container with default settings. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewMySQLTestContainerE(ctx context.Context) (*MySQLTestContainer, error) { + return NewMySQLTestContainerWithDBE(ctx, "test") +} + // NewMySQLTestContainerWithDB creates a new MySQL test container with a specific database name func NewMySQLTestContainerWithDB(ctx context.Context, t *testing.T, dbName string) *MySQLTestContainer { + mc, err := NewMySQLTestContainerWithDBE(ctx, dbName) + require.NoError(t, err) + return mc +} + +// NewMySQLTestContainerWithDBE creates a new MySQL test container with a specific database name. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewMySQLTestContainerWithDBE(ctx context.Context, dbName string) (*MySQLTestContainer, error) { const ( defaultUser = "root" defaultPassword = "secret" @@ -51,13 +65,21 @@ func NewMySQLTestContainerWithDB(ctx context.Context, t *testing.T, dbName strin ContainerRequest: req, Started: true, }) - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("failed to create mysql container: %w", err) + } host, err := container.Host(ctx) - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } port, err := container.MappedPort(ctx, "3306") - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } return &MySQLTestContainer{ Container: container, @@ -66,7 +88,7 @@ func NewMySQLTestContainerWithDB(ctx context.Context, t *testing.T, dbName strin User: defaultUser, Password: defaultPassword, Database: dbName, - } + }, nil } // ConnectionString returns the MySQL connection string for this container diff --git a/containers/psql.go b/containers/psql.go index 3638c35..24db163 100644 --- a/containers/psql.go +++ b/containers/psql.go @@ -27,8 +27,22 @@ func NewPostgresTestContainer(ctx context.Context, t *testing.T) *PostgresTestCo return NewPostgresTestContainerWithDB(ctx, t, "test") } +// NewPostgresTestContainerE creates a new PostgreSQL test container with default settings. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewPostgresTestContainerE(ctx context.Context) (*PostgresTestContainer, error) { + return NewPostgresTestContainerWithDBE(ctx, "test") +} + // NewPostgresTestContainerWithDB creates a new PostgreSQL test container with a specific database name func NewPostgresTestContainerWithDB(ctx context.Context, t *testing.T, dbName string) *PostgresTestContainer { + pc, err := NewPostgresTestContainerWithDBE(ctx, dbName) + require.NoError(t, err) + return pc +} + +// NewPostgresTestContainerWithDBE creates a new PostgreSQL test container with a specific database name. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewPostgresTestContainerWithDBE(ctx context.Context, dbName string) (*PostgresTestContainer, error) { const ( defaultUser = "postgres" defaultPassword = "secret" @@ -51,13 +65,21 @@ func NewPostgresTestContainerWithDB(ctx context.Context, t *testing.T, dbName st ContainerRequest: req, Started: true, }) - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("failed to create postgres container: %w", err) + } host, err := container.Host(ctx) - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } port, err := container.MappedPort(ctx, "5432") - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } return &PostgresTestContainer{ Container: container, @@ -66,7 +88,7 @@ func NewPostgresTestContainerWithDB(ctx context.Context, t *testing.T, dbName st User: defaultUser, Password: defaultPassword, Database: dbName, - } + }, nil } // ConnectionString returns the PostgreSQL connection string for this container diff --git a/containers/ssh.go b/containers/ssh.go index 8d50e3b..2c3df3e 100644 --- a/containers/ssh.go +++ b/containers/ssh.go @@ -31,10 +31,26 @@ func NewSSHTestContainer(ctx context.Context, t *testing.T) *SSHTestContainer { return NewSSHTestContainerWithUser(ctx, t, "test") } +// NewSSHTestContainerE creates a new SSH test container and returns an SSHTestContainer instance. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewSSHTestContainerE(ctx context.Context) (*SSHTestContainer, error) { + return NewSSHTestContainerWithUserE(ctx, "test") +} + // NewSSHTestContainerWithUser creates a new SSH test container with a specific user func NewSSHTestContainerWithUser(ctx context.Context, t *testing.T, user string) *SSHTestContainer { - pubKey, err := os.ReadFile("testdata/test_ssh_key.pub") + sc, err := NewSSHTestContainerWithUserE(ctx, user) require.NoError(t, err) + return sc +} + +// NewSSHTestContainerWithUserE creates a new SSH test container with a specific user. +// Returns error instead of using require.NoError, suitable for TestMain usage. +func NewSSHTestContainerWithUserE(ctx context.Context, user string) (*SSHTestContainer, error) { + pubKey, err := os.ReadFile("testdata/test_ssh_key.pub") + if err != nil { + return nil, fmt.Errorf("failed to read SSH public key: %w", err) + } req := testcontainers.ContainerRequest{ Image: "lscr.io/linuxserver/openssh-server:latest", @@ -55,20 +71,28 @@ func NewSSHTestContainerWithUser(ctx context.Context, t *testing.T, user string) ContainerRequest: req, Started: true, }) - require.NoError(t, err) + if err != nil { + return nil, fmt.Errorf("failed to create ssh container: %w", err) + } host, err := container.Host(ctx) - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get container host: %w", err) + } port, err := container.MappedPort(ctx, "2222") - require.NoError(t, err) + if err != nil { + _ = container.Terminate(ctx) + return nil, fmt.Errorf("failed to get mapped port: %w", err) + } return &SSHTestContainer{ Container: container, Host: host, Port: port, User: user, - } + }, nil } // Address returns the SSH server address in host:port format