Skip to content

Commit 03c0c8b

Browse files
mdelapenyaclaude
andauthored
chore(modulegen): use Run function when generating modules (#3445)
* chore(modulegen): use Run function when generating modules * docs: improve module development best practices Enhance the module development documentation with comprehensive best practices learned from migrating all 49 modules to use the Run function: - Container struct design: enforce Container naming (not ModuleContainer), use private fields for state management - Run function pattern: detailed 5-step implementation guide with code examples - Option types: clarify when to use built-in options vs CustomizeRequestOption vs custom Option types, emphasize returning struct types not interfaces - Error handling patterns in options with practical examples - Option ordering: explain defaults → user options → post-processing - Container state inspection: best practices using strings.CutPrefix with early exit optimization - All examples follow patterns from successfully migrated modules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * test: update assertions for improved template comments * docs: fix ordered list * docs: explicit usage of options as a private field in the container struct * docs: clarity * docs: clarity --------- Co-authored-by: Claude <[email protected]>
1 parent d0b6154 commit 03c0c8b

File tree

3 files changed

+291
-65
lines changed

3 files changed

+291
-65
lines changed

docs/modules/index.md

Lines changed: 274 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,86 @@ We are going to propose a set of steps to follow when adding types and methods t
112112
!!!warning
113113
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.
114114

115-
- 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.
116-
- 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.
117-
- Define container options for the module leveraging the `testcontainers.ContainerCustomizer` interface, that has one single method: `Customize(req *GenericContainerRequest) error`.
115+
#### Container struct design
116+
117+
- **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.
118+
- **Use the name `Container`**, not a module-specific name like `PostgresContainer` or `RedisContainer`. This keeps the API consistent across modules.
119+
120+
```golang
121+
// Container represents the container type used in the module
122+
type Container struct {
123+
testcontainers.Container
124+
// private fields, maybe obtained from the settings struct (i.e. settings.dbName, settings.user, settings.password, etc.)
125+
dbName string
126+
user string
127+
password string
128+
...
129+
// Or you can directly store all the options
130+
settings options // keep processed Option state
131+
}
132+
```
133+
134+
!!!info
135+
Some existing modules still use module-specific container names (e.g., `PostgresContainer`). These will eventually be changed to follow the `Container` naming convention.
136+
137+
- **Use private fields** for storing container state (e.g., connection details, credentials, settings). This prevents external mutation and keeps the API clean.
138+
- **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.
139+
140+
#### The Run function
141+
142+
- **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)`
143+
- The function should:
144+
1. Process custom module options first (if using an intermediate config struct)
145+
2. Build `moduleOpts` slice with default container configuration
146+
3. Append user-provided options to `moduleOpts`
147+
4. Call `testcontainers.Run(ctx, img, moduleOpts...)`
148+
5. Return the module-specific container with proper error wrapping
149+
150+
```golang
151+
func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
152+
// 1. Process custom options to extract settings (if needed)
153+
var settings options
154+
for _, opt := range opts {
155+
if opt, ok := opt.(Option); ok {
156+
if err := opt(&settings); err != nil {
157+
return nil, err
158+
}
159+
}
160+
}
161+
162+
// 2. Build moduleOpts with defaults
163+
moduleOpts := []testcontainers.ContainerCustomizer{
164+
testcontainers.WithExposedPorts("6379/tcp"),
165+
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")),
166+
}
167+
168+
// Add conditional options based on settings
169+
if settings.tlsEnabled {
170+
moduleOpts = append(moduleOpts, /* TLS configuration */)
171+
}
172+
173+
// 3. Append user options
174+
moduleOpts = append(moduleOpts, opts...)
175+
176+
// 4. Call testcontainers.Run
177+
ctr, err := testcontainers.Run(ctx, img, moduleOpts...)
178+
var c *Container
179+
if ctr != nil {
180+
c = &Container{Container: ctr, settings: settings}
181+
}
182+
183+
// 5. Return with proper error wrapping
184+
if err != nil {
185+
return c, fmt.Errorf("run redis: %w", err)
186+
}
187+
188+
return c, nil
189+
}
190+
```
191+
192+
#### Container options
193+
194+
Define container options for the module leveraging the `testcontainers.ContainerCustomizer` interface, that has one single method: `Customize(req *GenericContainerRequest) error`.
118195

119196
!!!warning
120197
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
130207
Customize(req *GenericContainerRequest) error
131208
```
132209

133-
- 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.
134-
- 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.
135-
- 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.
210+
##### When to use built-in options vs custom options
211+
212+
**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:
136213

137214
```golang
138-
// Config type represents an intermediate struct for transferring state from the options to the container
139-
type Config struct {
140-
data string
215+
// ✅ Good: Use built-in options for simple env var settings
216+
// Returns testcontainers.CustomizeRequestOption (struct, not interface)
217+
func WithDatabase(dbName string) testcontainers.CustomizeRequestOption {
218+
return testcontainers.WithEnv(map[string]string{"POSTGRES_DB": dbName})
141219
}
142220

143-
// Run function is the entrypoint to the module
144-
func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
145-
cfg := Config{}
221+
func WithPassword(password string) testcontainers.CustomizeRequestOption {
222+
return testcontainers.WithEnv(map[string]string{"POSTGRES_PASSWORD": password})
223+
}
224+
```
146225

147-
req := testcontainers.ContainerRequest{
148-
Image: img,
149-
...
226+
**Use `testcontainers.CustomizeRequestOption`** for complex logic requiring multiple operations. Always return the struct type, not the interface:
227+
228+
```golang
229+
// ✅ Good: Return testcontainers.CustomizeRequestOption (struct type)
230+
func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption {
231+
return func(req *testcontainers.GenericContainerRequest) error {
232+
cfgFile := testcontainers.ContainerFile{
233+
HostFilePath: cfg,
234+
ContainerFilePath: "/etc/postgresql.conf",
235+
FileMode: 0o755,
236+
}
237+
238+
if err := testcontainers.WithFiles(cfgFile)(req); err != nil {
239+
return err
240+
}
241+
242+
return testcontainers.WithCmdArgs("-c", "config_file=/etc/postgresql.conf")(req)
150243
}
151-
genericContainerReq := testcontainers.GenericContainerRequest{
152-
ContainerRequest: req,
153-
Started: true,
244+
}
245+
```
246+
247+
**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:
248+
249+
```golang
250+
// Options struct for transferring state
251+
type options struct {
252+
tlsEnabled bool
253+
tlsConfig *tls.Config
254+
}
255+
256+
// Option function type for custom module options
257+
type Option func(*options) error
258+
259+
// ✅ Good: Return Option struct type, not testcontainers.ContainerCustomizer interface
260+
func WithTLS() Option {
261+
return func(opts *options) error {
262+
opts.tlsEnabled = true
263+
return nil
154264
}
155-
...
265+
}
266+
267+
// Implement ContainerCustomizer interface to satisfy testcontainers.ContainerCustomizer
268+
func (o Option) Customize(req *testcontainers.GenericContainerRequest) error {
269+
// This method can be empty if the option only sets internal state
270+
return nil
271+
}
272+
273+
// In Run function, process these before building moduleOpts
274+
func Run(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
275+
var settings options
156276
for _, opt := range opts {
157-
if err := opt.Customize(&genericContainerReq); err != nil {
158-
return nil, fmt.Errorf("customise: %w", err)
277+
if opt, ok := opt.(Option); ok {
278+
if err := opt(&settings); err != nil {
279+
return nil, err
280+
}
159281
}
282+
}
283+
// Now use settings.tlsEnabled to conditionally add options
284+
...
285+
}
286+
```
287+
288+
##### Option patterns and best practices
289+
290+
**Always return struct types, not interfaces:**
291+
292+
```golang
293+
// ✅ Correct: Return concrete struct type
294+
func WithUsername(user string) testcontainers.CustomizeRequestOption {
295+
return testcontainers.WithEnv(map[string]string{"USER": user})
296+
}
297+
298+
// ❌ Wrong: Don't return interface type
299+
func WithUsername(user string) testcontainers.ContainerCustomizer {
300+
return testcontainers.WithEnv(map[string]string{"USER": user})
301+
}
302+
```
303+
304+
**Error handling in options:**
305+
- Simple options returning `testcontainers.CustomizeRequestOption` should propagate errors from built-in options
306+
- Custom `Option` types should return errors for validation failures
307+
308+
```golang
309+
// CustomizeRequestOption - propagate errors
310+
func WithInitScripts(scripts ...string) testcontainers.CustomizeRequestOption {
311+
return func(req *testcontainers.GenericContainerRequest) error {
312+
containerFiles := []testcontainers.ContainerFile{/* ... */}
313+
return testcontainers.WithFiles(containerFiles...)(req)
314+
}
315+
}
316+
317+
// Custom Option type - return validation errors
318+
type Option func(*Options) error
160319

161-
// If you need to transfer some state from the options to the container, you can do it here
162-
if myCustomizer, ok := opt.(MyCustomizer); ok {
163-
config.data = customizer.data
320+
func WithListener(hostPort string) Option {
321+
return func(opts *Options) error {
322+
host, port, err := net.SplitHostPort(hostPort)
323+
if err != nil {
324+
return fmt.Errorf("invalid host:port format: %w", err)
164325
}
326+
opts.Listeners = append(opts.Listeners, Listener{Host: host, Port: port})
327+
return nil
165328
}
166-
...
167-
container, err := testcontainers.GenericContainer(ctx, genericContainerReq)
168-
...
169-
moduleContainer := &Container{Container: container}
170-
moduleContainer.initializeState(ctx, cfg)
171-
...
172-
return moduleContainer, nil
173329
}
330+
```
331+
332+
**Calling built-in options from custom options:**
174333

175-
// MyCustomizer type represents a container customizer for transferring state from the options to the container
176-
type MyCustomizer struct {
177-
data string
334+
```golang
335+
// ✅ Correct: Call built-in options directly
336+
func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption {
337+
return func(req *testcontainers.GenericContainerRequest) error {
338+
return testcontainers.WithFiles(cfgFile)(req)
339+
}
178340
}
179-
// Customize method implementation
180-
func (c MyCustomizer) Customize(req *testcontainers.GenericContainerRequest) testcontainers.ContainerRequest {
181-
req.ExposedPorts = append(req.ExposedPorts, "1234/tcp")
182-
return req.ContainerRequest
341+
342+
// ❌ Wrong: Don't use .Customize() method
343+
func WithConfigFile(cfg string) testcontainers.CustomizeRequestOption {
344+
return func(req *testcontainers.GenericContainerRequest) error {
345+
return testcontainers.WithFiles(cfgFile).Customize(req) // Wrong!: adds unnecessary indirection
346+
}
183347
}
184-
// WithMy function option to use the customizer
185-
func WithMy(data string) testcontainers.ContainerCustomizer {
186-
return MyCustomizer{data: data}
348+
```
349+
350+
##### Option ordering
351+
352+
The order in which options are applied matters:
353+
354+
1. **Module defaults** (in `moduleOpts` slice) - base configuration
355+
2. **User-provided options** (via `opts...`) - user customizations
356+
3. **Post-processing options** (appended last) - validation, network config, etc.
357+
358+
```golang
359+
moduleOpts := []testcontainers.ContainerCustomizer{
360+
// 1. Defaults
361+
testcontainers.WithEnv(map[string]string{"DB": "default"}),
187362
}
363+
364+
// 2. User options
365+
moduleOpts = append(moduleOpts, opts...)
366+
367+
// 3. Post-processing (after user options, if needed)
368+
moduleOpts = append(moduleOpts, validateConfiguration)
188369
```
189370

371+
##### Inspecting container state after Run
372+
373+
When you need to read environment variables or other container state after creation:
374+
375+
```golang
376+
ctr, err := testcontainers.Run(ctx, img, moduleOpts...)
377+
if err != nil {
378+
return nil, fmt.Errorf("run postgres: %w", err)
379+
}
380+
381+
// Retrieve actual env vars set on the container
382+
inspect, err := ctr.Inspect(ctx)
383+
if err != nil {
384+
return nil, fmt.Errorf("inspect postgres: %w", err)
385+
}
386+
387+
var foundDB, foundUser, foundPass bool
388+
for _, env := range inspect.Config.Env {
389+
if v, ok := strings.CutPrefix(env, "POSTGRES_DB="); ok {
390+
c.dbName, foundDB = v, true
391+
}
392+
if v, ok := strings.CutPrefix(env, "POSTGRES_USER="); ok {
393+
c.user, foundUser = v, true
394+
}
395+
if v, ok := strings.CutPrefix(env, "POSTGRES_PASSWORD="); ok {
396+
c.password, foundPass = v, true
397+
}
398+
399+
// Early exit optimization
400+
if foundDB && foundUser && foundPass {
401+
break
402+
}
403+
}
404+
```
405+
406+
**Best practices:**
407+
- Use `strings.CutPrefix` (standard library) instead of manual string manipulation
408+
- Set defaults when creating the container struct, not in the loop
409+
- Use individual `found` flags and check all together for early exit
410+
- Break early once all required values are found
411+
412+
#### Container methods
413+
190414
- 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:
191415

192416
```golang
193-
func (c *Container) ConnectionString(ctx context.Context) (string, error) {...}
417+
func (c *Container) ConnectionString(ctx context.Context, args ...string) (string, error) {
418+
endpoint, err := c.PortEndpoint(ctx, "5432/tcp", "")
419+
if err != nil {
420+
return "", err
421+
}
422+
return fmt.Sprintf("postgres://%s:%s@%s/%s", c.user, c.password, endpoint, c.dbName), nil
423+
}
194424
```
195425

426+
#### Documentation
427+
196428
- Document the public API with Go comments.
197429
- 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.
198430

modulegen/_template/module.go.tmpl

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,20 @@ type Container struct {
1414

1515
// {{ $entrypoint }} creates an instance of the {{ $title }} container type
1616
func {{ $entrypoint }}(ctx context.Context, img string, opts ...testcontainers.ContainerCustomizer) (*Container, error) {
17-
req := testcontainers.ContainerRequest{
18-
Image: img,
19-
}
20-
21-
genericContainerReq := testcontainers.GenericContainerRequest{
22-
ContainerRequest: req,
23-
Started: true,
24-
}
17+
// Initialize with module defaults
18+
moduleOpts := []testcontainers.ContainerCustomizer{}
2519

26-
for _, opt := range opts {
27-
if err := opt.Customize(&genericContainerReq); err != nil {
28-
return nil, fmt.Errorf("customize: %w", err)
29-
}
30-
}
20+
// Add user-provided options
21+
moduleOpts = append(moduleOpts, opts...)
3122

32-
container, err := testcontainers.GenericContainer(ctx, genericContainerReq)
23+
ctr, err := testcontainers.Run(ctx, img, moduleOpts...)
3324
var c *Container
34-
if container != nil {
35-
c = &Container{Container: container}
25+
if ctr != nil {
26+
c = &Container{Container: ctr}
3627
}
3728

3829
if err != nil {
39-
return c, fmt.Errorf("generic container: %w", err)
30+
return c, fmt.Errorf("run {{ $lower }}: %w", err)
4031
}
4132

4233
return c, nil

0 commit comments

Comments
 (0)