diff --git a/docs/modules/index.md b/docs/modules/index.md index 3991e8c0cd..5fa7e25193 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -112,9 +112,86 @@ We are going to propose a set of steps to follow when adding types and methods t !!!warning The `StartContainer` function will be eventually deprecated and replaced with `Run`. We are keeping it in certain modules for backwards compatibility, but they will be removed in the future. -- Make sure a public `Container` type exists for the module. This type has to use composition to embed the `testcontainers.Container` type, promoting all the methods from it. -- Make sure a `Run` function exists and is public. This function is the entrypoint to the module and will define the initial values for a `testcontainers.GenericContainerRequest` struct, including the image in the function signature, the default exposed ports, wait strategies, etc. Therefore, the function must initialise the container request with the default values. -- Define container options for the module leveraging the `testcontainers.ContainerCustomizer` interface, that has one single method: `Customize(req *GenericContainerRequest) error`. +#### Container struct design + +- **Make sure a public `Container` type exists for the module**. This type has to use composition to embed the `testcontainers.Container` type, promoting all the methods from it. +- **Use the name `Container`**, not a module-specific name like `PostgresContainer` or `RedisContainer`. This keeps the API consistent across modules. + +```golang +// Container represents the container type used in the module +type Container struct { + testcontainers.Container + // private fields, maybe obtained from the settings struct (i.e. settings.dbName, settings.user, settings.password, etc.) + dbName string + user string + password string + ... + // Or you can directly store all the options + settings options // keep processed Option state +} +``` + +!!!info + Some existing modules still use module-specific container names (e.g., `PostgresContainer`). These will eventually be changed to follow the `Container` naming convention. + +- **Use private fields** for storing container state (e.g., connection details, credentials, settings). This prevents external mutation and keeps the API clean. +- **Public fields are acceptable** when they are part of the public API and users need direct access, but prefer private fields with public accessor methods when possible. + +#### The Run function + +- **Make sure a `Run` function exists and is public**. This function is the entrypoint to the module with signature: `func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error)` +- The function should: +1. Process custom module options first (if using an intermediate config struct) +2. Build `moduleOpts` slice with default container configuration +3. Append user-provided options to `moduleOpts` +4. Call `testcontainers.Run(ctx, img, moduleOpts...)` +5. Return the module-specific container with proper error wrapping + +```golang +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { + // 1. Process custom options to extract settings (if needed) + var settings options + for _, opt := range opts { + if opt, ok := opt.(Option); ok { + if err := opt(&settings); err != nil { + return nil, err + } + } + } + + // 2. Build moduleOpts with defaults + moduleOpts := []testcontainers.ContainerCustomizer{ + testcontainers.WithExposedPorts("6379/tcp"), + testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")), + } + + // Add conditional options based on settings + if settings.tlsEnabled { + moduleOpts = append(moduleOpts, /* TLS configuration */) + } + + // 3. Append user options + moduleOpts = append(moduleOpts, opts...) + + // 4. Call testcontainers.Run + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) + var c *Container + if ctr != nil { + c = &Container{Container: ctr, settings: settings} + } + + // 5. Return with proper error wrapping + if err != nil { + return c, fmt.Errorf("run redis: %w", err) + } + + return c, nil +} +``` + +#### Container options + +Define container options for the module leveraging the `testcontainers.ContainerCustomizer` interface, that has one single method: `Customize(req *GenericContainerRequest) error`. !!!warning The interface definition for `ContainerCustomizer` was changed to allow errors to be correctly processed. @@ -130,69 +207,224 @@ We are going to propose a set of steps to follow when adding types and methods t Customize(req *GenericContainerRequest) error ``` -- We consider that a best practice for the options is to define a function using the `With` prefix, that returns a function returning a modified `testcontainers.GenericContainerRequest` type. For that, the library already provides a `testcontainers.CustomizeRequestOption` type implementing the `ContainerCustomizer` interface, and we encourage you to use this type for creating your own customizer functions. -- At the same time, you could need to create your own container customizers for your module. Make sure they implement the `testcontainers.ContainerCustomizer` interface. Defining your own customizer functions is useful when you need to transfer a certain state that is not present at the `ContainerRequest` for the container, possibly using an intermediate Config struct. -- The options will be passed to the `Run` function as variadic arguments after the Go context, and they will be processed right after defining the initial `testcontainers.GenericContainerRequest` struct using a for loop. +##### When to use built-in options vs custom options + +**Prefer built-in options** (`testcontainers.With*`) for simple configuration. These options return `testcontainers.CustomizeRequestOption`, which is a concrete function type (not an interface) that implements the `testcontainers.ContainerCustomizer` interface: ```golang -// Config type represents an intermediate struct for transferring state from the options to the container -type Config struct { - data string +// ✅ Good: Use built-in options for simple env var settings +// Returns testcontainers.CustomizeRequestOption (struct, not interface) +func WithDatabase(dbName string) testcontainers.CustomizeRequestOption { + return testcontainers.WithEnv(map[string]string{"POSTGRES_DB": dbName}) } -// Run function is the entrypoint to the module -func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - cfg := Config{} +func WithPassword(password string) testcontainers.CustomizeRequestOption { + return testcontainers.WithEnv(map[string]string{"POSTGRES_PASSWORD": password}) +} +``` - req := testcontainers.ContainerRequest{ - Image: img, - ... +**Use `testcontainers.CustomizeRequestOption`** for complex logic requiring multiple operations. Always return the struct type, not the interface: + +```golang +// ✅ Good: Return testcontainers.CustomizeRequestOption (struct type) +func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + cfgFile := testcontainers.ContainerFile{ + HostFilePath: cfg, + ContainerFilePath: "/etc/postgresql.conf", + FileMode: 0o755, + } + + if err := testcontainers.WithFiles(cfgFile)(req); err != nil { + return err + } + + return testcontainers.WithCmdArgs("-c", "config_file=/etc/postgresql.conf")(req) } - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, +} +``` + +**Create your own `Option` type** when you need to transfer state that isn't part of the container request. Return the struct type, not an interface: + +```golang +// Options struct for transferring state +type options struct { + tlsEnabled bool + tlsConfig *tls.Config +} + +// Option function type for custom module options +type Option func(*options) error + +// ✅ Good: Return Option struct type, not testcontainers.ContainerCustomizer interface +func WithTLS() Option { + return func(opts *options) error { + opts.tlsEnabled = true + return nil } - ... +} + +// Implement ContainerCustomizer interface to satisfy testcontainers.ContainerCustomizer +func (o Option) Customize(req *testcontainers.GenericContainerRequest) error { + // This method can be empty if the option only sets internal state + return nil +} + +// In Run function, process these before building moduleOpts +func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { + var settings options for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customise: %w", err) + if opt, ok := opt.(Option); ok { + if err := opt(&settings); err != nil { + return nil, err + } } + } + // Now use settings.tlsEnabled to conditionally add options + ... +} +``` + +##### Option patterns and best practices + +**Always return struct types, not interfaces:** + +```golang +// ✅ Correct: Return concrete struct type +func WithUsername(user string) testcontainers.CustomizeRequestOption { + return testcontainers.WithEnv(map[string]string{"USER": user}) +} + +// ❌ Wrong: Don't return interface type +func WithUsername(user string) testcontainers.ContainerCustomizer { + return testcontainers.WithEnv(map[string]string{"USER": user}) +} +``` + +**Error handling in options:** +- Simple options returning `testcontainers.CustomizeRequestOption` should propagate errors from built-in options +- Custom `Option` types should return errors for validation failures + +```golang +// CustomizeRequestOption - propagate errors +func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + containerFiles := []testcontainers.ContainerFile{/* ... */} + return testcontainers.WithFiles(containerFiles...)(req) + } +} + +// Custom Option type - return validation errors +type Option func(*Options) error - // If you need to transfer some state from the options to the container, you can do it here - if myCustomizer, ok := opt.(MyCustomizer); ok { - config.data = customizer.data +func WithListener(hostPort string) Option { + return func(opts *Options) error { + host, port, err := net.SplitHostPort(hostPort) + if err != nil { + return fmt.Errorf("invalid host:port format: %w", err) } + opts.Listeners = append(opts.Listeners, Listener{Host: host, Port: port}) + return nil } - ... - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) - ... - moduleContainer := &Container{Container: container} - moduleContainer.initializeState(ctx, cfg) - ... - return moduleContainer, nil } +``` + +**Calling built-in options from custom options:** -// MyCustomizer type represents a container customizer for transferring state from the options to the container -type MyCustomizer struct { - data string +```golang +// ✅ Correct: Call built-in options directly +func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + return testcontainers.WithFiles(cfgFile)(req) + } } -// Customize method implementation -func (c MyCustomizer) Customize(req *testcontainers.GenericContainerRequest) testcontainers.ContainerRequest { - req.ExposedPorts = append(req.ExposedPorts, "1234/tcp") - return req.ContainerRequest + +// ❌ Wrong: Don't use .Customize() method +func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption { + return func(req *testcontainers.GenericContainerRequest) error { + return testcontainers.WithFiles(cfgFile).Customize(req) // Wrong!: adds unnecessary indirection + } } -// WithMy function option to use the customizer -func WithMy(data string) testcontainers.ContainerCustomizer { - return MyCustomizer{data: data} +``` + +##### Option ordering + +The order in which options are applied matters: + +1. **Module defaults** (in `moduleOpts` slice) - base configuration +2. **User-provided options** (via `opts...`) - user customizations +3. **Post-processing options** (appended last) - validation, network config, etc. + +```golang +moduleOpts := []testcontainers.ContainerCustomizer{ + // 1. Defaults + testcontainers.WithEnv(map[string]string{"DB": "default"}), } + +// 2. User options +moduleOpts = append(moduleOpts, opts...) + +// 3. Post-processing (after user options, if needed) +moduleOpts = append(moduleOpts, validateConfiguration) ``` +##### Inspecting container state after Run + +When you need to read environment variables or other container state after creation: + +```golang +ctr, err := testcontainers.Run(ctx, img, moduleOpts...) +if err != nil { + return nil, fmt.Errorf("run postgres: %w", err) +} + +// Retrieve actual env vars set on the container +inspect, err := ctr.Inspect(ctx) +if err != nil { + return nil, fmt.Errorf("inspect postgres: %w", err) +} + +var foundDB, foundUser, foundPass bool +for _, env := range inspect.Config.Env { + if v, ok := strings.CutPrefix(env, "POSTGRES_DB="); ok { + c.dbName, foundDB = v, true + } + if v, ok := strings.CutPrefix(env, "POSTGRES_USER="); ok { + c.user, foundUser = v, true + } + if v, ok := strings.CutPrefix(env, "POSTGRES_PASSWORD="); ok { + c.password, foundPass = v, true + } + + // Early exit optimization + if foundDB && foundUser && foundPass { + break + } +} +``` + +**Best practices:** +- Use `strings.CutPrefix` (standard library) instead of manual string manipulation +- Set defaults when creating the container struct, not in the loop +- Use individual `found` flags and check all together for early exit +- Break early once all required values are found + +#### Container methods + - If needed, define public methods to extract information from the running container, using the `Container` type as receiver. E.g. a connection string to access a database: ```golang -func (c *Container) ConnectionString(ctx context.Context) (string, error) {...} +func (c *Container) ConnectionString(ctx context.Context, args ...string) (string, error) { + endpoint, err := c.PortEndpoint(ctx, "5432/tcp", "") + if err != nil { + return "", err + } + return fmt.Sprintf("postgres://%s:%s@%s/%s", c.user, c.password, endpoint, c.dbName), nil +} ``` +#### Documentation + - Document the public API with Go comments. - Extend the docs to describe the new API of the module. We usually define a parent `Module reference` section, including a `Container options` and a `Container methods` subsections; within each subsection, we define a nested subsection for each option and method, respectively. diff --git a/modulegen/_template/module.go.tmpl b/modulegen/_template/module.go.tmpl index 585e853fba..4a14cd5cdd 100644 --- a/modulegen/_template/module.go.tmpl +++ b/modulegen/_template/module.go.tmpl @@ -14,29 +14,20 @@ type Container struct { // {{ $entrypoint }} creates an instance of the {{ $title }} container type func {{ $entrypoint }}(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) { - req := testcontainers.ContainerRequest{ - Image: img, - } - - genericContainerReq := testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - } + // Initialize with module defaults + moduleOpts := []testcontainers.ContainerCustomizer{} - for _, opt := range opts { - if err := opt.Customize(&genericContainerReq); err != nil { - return nil, fmt.Errorf("customize: %w", err) - } - } + // Add user-provided options + moduleOpts = append(moduleOpts, opts...) - container, err := testcontainers.GenericContainer(ctx, genericContainerReq) + ctr, err := testcontainers.Run(ctx, img, moduleOpts...) var c *Container - if container != nil { - c = &Container{Container: container} + if ctr != nil { + c = &Container{Container: ctr} } if err != nil { - return c, fmt.Errorf("generic container: %w", err) + return c, fmt.Errorf("run {{ $lower }}: %w", err) } return c, nil diff --git a/modulegen/main_test.go b/modulegen/main_test.go index 49b75497ba..6ac17d8b0d 100644 --- a/modulegen/main_test.go +++ b/modulegen/main_test.go @@ -426,12 +426,15 @@ func assertModuleContent(t *testing.T, module context.TestcontainersModule, exam require.Equal(t, "type "+containerName+" struct {", data[10]) require.Equal(t, "// "+entrypoint+" creates an instance of the "+exampleName+" container type", data[14]) require.Equal(t, "func "+entrypoint+"(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*"+containerName+", error) {", data[15]) - require.Equal(t, "\t\tImage: img,", data[17]) - require.Equal(t, "\t\tif err := opt.Customize(&genericContainerReq); err != nil {", data[26]) - require.Equal(t, "\t\t\treturn nil, fmt.Errorf(\"customize: %w\", err)", data[27]) - require.Equal(t, "\tvar c *"+containerName, data[32]) - require.Equal(t, "\t\tc = &"+containerName+"{Container: container}", data[34]) - require.Equal(t, "\treturn c, nil", data[41]) + require.Equal(t, "\t// Initialize with module defaults", data[16]) + require.Equal(t, "\tmoduleOpts := []testcontainers.ContainerCustomizer{}", data[17]) + require.Equal(t, "\t// Add user-provided options", data[19]) + require.Equal(t, "\tmoduleOpts = append(moduleOpts, opts...)", data[20]) + require.Equal(t, "\tctr, err := testcontainers.Run(ctx, img, moduleOpts...)", data[22]) + require.Equal(t, "\tvar c *"+containerName, data[23]) + require.Equal(t, "\t\tc = &"+containerName+"{Container: ctr}", data[25]) + require.Equal(t, "\t\treturn c, fmt.Errorf(\"run "+lower+": %w\", err)", data[29]) + require.Equal(t, "\treturn c, nil", data[32]) } // assert content go.mod