Skip to content

Commit 51dc5cf

Browse files
authored
feat(testkit): add test harness and kernel provider overrides (#218)
## Type - [x] `feat` — New feature - [ ] `fix` — Bug fix - [ ] `refactor` — Code restructure (no behavior change) - [x] `docs` — Documentation only - [x] `test` — Test coverage - [ ] `chore` — Build, CI, tooling - [ ] `perf` — Performance improvement ## Summary Add a first-class `modkit/testkit` package for module-level tests and extend kernel bootstrap with explicit provider override options. This reduces test boilerplate while preserving visibility, deterministic bootstrap behavior, and cleanup lifecycle guarantees. ## Changes - Add `kernel.BootstrapWithOptions` and `WithProviderOverrides` with strict override validation and typed errors - Introduce `modkit/testkit` (`Harness`, override helpers, typed `Get`/`Controller` helpers, close lifecycle + error types) - Add kernel and testkit coverage for parity, override semantics, duplicate/unknown/visibility checks, nil-option/build guards, and close cancellation retry behavior - Update testing guide and API reference for TestKit usage and override-vs-test-module guidance - Add focused TestKit usage in `examples/hello-simple` and realistic override usage in `examples/hello-mysql` auth tests - Update README package table to include `modkit/testkit` ## Breaking Changes None ## Validation ```bash make fmt && make lint && make vuln && make test && make test-coverage make cli-smoke-build && make cli-smoke-scaffold ``` ## Checklist - [x] Code follows project style (`make fmt` passes) - [x] Linter passes (`make lint`) - [x] Vulnerability scan passes (`make vuln`) - [x] Tests pass (`make test`) - [x] Coverage tests pass (`make test-coverage`) - [x] CLI smoke checks pass (`make cli-smoke-build && make cli-smoke-scaffold`) - [x] Tests added/updated for new functionality - [x] Documentation updated (if applicable) - [x] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) ## Notes No issue hierarchy is linked for this change set. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * TestKit: a test harness for bootstrapping apps with provider overrides, lifecycle/cleanup management, and typed accessors for dependencies and controllers. * Bootstrap customization: option-driven provider overrides during initialization. * HTTP router API exposed for request handling. * **Documentation** * New testing guide with TestKit examples and best practices; API reference and package listing updated to include TestKit and bootstrap/router docs; README updated. * **Tests** * Extensive tests for TestKit, bootstrap options, overrides, error cases, visibility rules, and cleanup behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent fb9a554 commit 51dc5cf

16 files changed

Lines changed: 1467 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ The CLI automatically registers providers and controllers in your module's `Defi
169169
| `modkit/kernel` | Graph builder, visibility enforcer, bootstrap |
170170
| `modkit/http` | HTTP adapter for chi router |
171171
| `modkit/logging` | Logging interface with slog adapter |
172+
| `modkit/testkit` | Test harness for bootstrap, overrides, and typed test helpers |
172173

173174
## Architecture
174175

docs/guides/testing.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,34 @@ func TestIntegration(t *testing.T) {
166166
}
167167
```
168168

169+
## Using TestKit
170+
171+
Use `modkit/testkit` when you want less boilerplate for bootstrap, typed retrieval, and provider overrides.
172+
173+
```go
174+
func TestAuthModule_WithTestKit(t *testing.T) {
175+
h := testkit.New(t,
176+
auth.NewModule(auth.Options{}),
177+
testkit.WithOverrides(
178+
testkit.OverrideValue(configmodule.TokenAuthUsername, "demo"),
179+
testkit.OverrideValue(configmodule.TokenAuthPassword, "demo"),
180+
),
181+
)
182+
183+
handler := testkit.Get[*auth.Handler](t, h, auth.TokenHandler)
184+
if handler == nil {
185+
t.Fatal("expected handler")
186+
}
187+
}
188+
```
189+
190+
Decision guidance:
191+
192+
- Use test module replacement when changing module wiring semantics.
193+
- Use TestKit override when isolating dependency behavior without changing graph shape.
194+
195+
`testkit.New` registers cleanup with `t.Cleanup` by default. Use `testkit.WithoutAutoClose()` only when you need explicit close timing.
196+
169197
## Smoke Tests with Testcontainers
170198

171199
For full integration tests, use testcontainers:

docs/reference/api.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This is a quick reference for modkit's core types. For full documentation, see [
1111
| `kernel` | `github.com/go-modkit/modkit/modkit/kernel` | Graph builder, bootstrap |
1212
| `http` | `github.com/go-modkit/modkit/modkit/http` | HTTP adapter |
1313
| `logging` | `github.com/go-modkit/modkit/modkit/logging` | Logging interface |
14+
| `testkit` | `github.com/go-modkit/modkit/modkit/testkit` | Testing harness and overrides |
1415

1516
---
1617

@@ -116,6 +117,24 @@ func (a *App) Resolver() Resolver
116117

117118
Returns a root-scoped resolver that enforces module visibility.
118119

120+
### BootstrapWithOptions
121+
122+
```go
123+
func BootstrapWithOptions(root module.Module, opts ...BootstrapOption) (*App, error)
124+
```
125+
126+
Bootstraps with explicit options. Use `WithProviderOverrides` to replace provider implementations, typically for testing.
127+
128+
```go
129+
type ProviderOverride struct {
130+
Token module.Token
131+
Build func(module.Resolver) (any, error)
132+
Cleanup func(context.Context) error // Optional; called when harness is closed
133+
}
134+
135+
func WithProviderOverrides(overrides ...ProviderOverride) BootstrapOption
136+
```
137+
119138
### Errors
120139

121140
| Type | When |
@@ -129,6 +148,10 @@ Returns a root-scoped resolver that enforces module visibility.
129148
| `ProviderCycleError` | Provider depends on itself |
130149
| `ProviderBuildError` | Provider's `Build` function failed |
131150
| `ControllerBuildError` | Controller's `Build` function failed |
151+
| `DuplicateOverrideTokenError` | Override list contains duplicate token |
152+
| `OverrideTokenNotFoundError` | Override targets missing provider token |
153+
| `OverrideTokenNotVisibleFromRootError` | Override token not visible from root |
154+
| `BootstrapOptionConflictError` | Multiple options mutate same token |
132155

133156
---
134157

@@ -188,6 +211,59 @@ Starts an HTTP server with graceful shutdown on SIGINT/SIGTERM.
188211

189212
---
190213

214+
## testkit
215+
216+
### New / NewE
217+
218+
```go
219+
func New(tb testkit.TB, root module.Module, opts ...testkit.Option) *testkit.Harness
220+
func NewE(tb testkit.TB, root module.Module, opts ...testkit.Option) (*testkit.Harness, error)
221+
```
222+
223+
Bootstraps a test harness. `New` fails the test on bootstrap error. `NewE` returns the error.
224+
225+
### Harness lifecycle
226+
227+
```go
228+
func (h *Harness) App() *kernel.App
229+
func (h *Harness) Close() error
230+
func (h *Harness) CloseContext(ctx context.Context) error
231+
```
232+
233+
`Close` runs provider cleanup hooks first and then app closers.
234+
235+
### Overrides
236+
237+
```go
238+
func WithOverrides(overrides ...Override) Option
239+
func OverrideValue(token module.Token, value any) Override
240+
func OverrideBuild(token module.Token, build func(module.Resolver) (any, error)) Override
241+
func WithoutAutoClose() Option
242+
```
243+
244+
Applies token-level provider overrides while preserving graph and visibility semantics.
245+
246+
### Typed Helpers
247+
248+
```go
249+
func Get[T any](tb testkit.TB, h *testkit.Harness, token module.Token) T
250+
func GetE[T any](h *testkit.Harness, token module.Token) (T, error)
251+
func Controller[T any](tb testkit.TB, h *testkit.Harness, moduleName, controllerName string) T
252+
func ControllerE[T any](h *testkit.Harness, moduleName, controllerName string) (T, error)
253+
```
254+
255+
Typed wrappers for provider and controller retrieval in tests.
256+
257+
### Key errors
258+
259+
| Type | When |
260+
|------|------|
261+
| `ControllerNotFoundError` | Controller key was not found in harness app |
262+
| `TypeAssertionError` | Typed helper could not assert expected type |
263+
| `HarnessCloseError` | Hook close and/or app close returned errors |
264+
265+
---
266+
191267
## logging
192268

193269
### Logger Interface

examples/hello-mysql/internal/modules/auth/module_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"errors"
55
"testing"
66

7+
configmodule "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/config"
78
"github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/database"
89
"github.com/go-modkit/modkit/modkit/kernel"
910
"github.com/go-modkit/modkit/modkit/module"
11+
"github.com/go-modkit/modkit/modkit/testkit"
1012
)
1113

1214
func TestModule_Bootstrap(t *testing.T) {
@@ -73,3 +75,21 @@ func TestAuthAndDatabase_DefaultConfigComposition(t *testing.T) {
7375
t.Fatalf("bootstrap failed: %v", err)
7476
}
7577
}
78+
79+
func TestModule_TestKitOverrideConfigTokens(t *testing.T) {
80+
h := testkit.New(t,
81+
NewModule(Options{}),
82+
testkit.WithOverrides(
83+
testkit.OverrideValue(configmodule.TokenAuthUsername, "override-user"),
84+
testkit.OverrideValue(configmodule.TokenAuthPassword, "override-pass"),
85+
),
86+
)
87+
88+
handler := testkit.Get[*Handler](t, h, TokenHandler)
89+
if handler.cfg.Username != "override-user" {
90+
t.Fatalf("username = %q", handler.cfg.Username)
91+
}
92+
if handler.cfg.Password != "override-pass" {
93+
t.Fatalf("password = %q", handler.cfg.Password)
94+
}
95+
}

examples/hello-simple/main_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/go-modkit/modkit/modkit/module"
9+
"github.com/go-modkit/modkit/modkit/testkit"
910
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/mock"
1112
)
@@ -95,3 +96,13 @@ func TestAppModule_Providers(t *testing.T) {
9596
assert.IsType(t, &Counter{}, val)
9697
})
9798
}
99+
100+
func TestAppModule_TestKitOverrideGreeting(t *testing.T) {
101+
h := testkit.New(t,
102+
NewAppModule("real"),
103+
testkit.WithOverrides(testkit.OverrideValue(TokenGreeting, "fake")),
104+
)
105+
106+
controller := testkit.Controller[*GreetingController](t, h, "app", "GreetingController")
107+
assert.Equal(t, "fake", controller.message)
108+
}

modkit/kernel/bootstrap.go

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,68 @@ import (
55
"context"
66
"errors"
77
"io"
8+
"slices"
9+
"strconv"
810
"sync/atomic"
911

1012
"github.com/go-modkit/modkit/modkit/module"
1113
)
1214

15+
type bootstrapConfig struct {
16+
providerOverrides []ProviderOverride
17+
firstOptionByTok map[module.Token]int
18+
optionNames map[module.Token][]string
19+
currentOptionIdx int
20+
err error
21+
}
22+
23+
func newBootstrapConfig() bootstrapConfig {
24+
return bootstrapConfig{
25+
providerOverrides: make([]ProviderOverride, 0),
26+
firstOptionByTok: make(map[module.Token]int),
27+
optionNames: make(map[module.Token][]string),
28+
}
29+
}
30+
31+
func (c *bootstrapConfig) setCurrentOption(index int) {
32+
c.currentOptionIdx = index
33+
}
34+
35+
func (c *bootstrapConfig) addProviderOverrides(overrides []ProviderOverride) {
36+
if c.err != nil {
37+
return
38+
}
39+
40+
seenInThisOption := make(map[module.Token]bool)
41+
optionName := c.providerOverrideOptionName(c.currentOptionIdx)
42+
for _, override := range overrides {
43+
if override.Build == nil {
44+
c.err = &OverrideBuildNilError{Token: override.Token}
45+
return
46+
}
47+
48+
if seenInThisOption[override.Token] {
49+
c.err = &DuplicateOverrideTokenError{Token: override.Token}
50+
return
51+
}
52+
seenInThisOption[override.Token] = true
53+
54+
if firstIdx, ok := c.firstOptionByTok[override.Token]; ok && firstIdx != c.currentOptionIdx {
55+
names := append(slices.Clone(c.optionNames[override.Token]), optionName)
56+
c.err = &BootstrapOptionConflictError{Token: override.Token, Options: names}
57+
return
58+
}
59+
60+
c.firstOptionByTok[override.Token] = c.currentOptionIdx
61+
c.optionNames[override.Token] = append(c.optionNames[override.Token], optionName)
62+
c.providerOverrides = append(c.providerOverrides, override)
63+
}
64+
}
65+
66+
func (c *bootstrapConfig) providerOverrideOptionName(index int) string {
67+
return "WithProviderOverrides#" + strconv.Itoa(index+1)
68+
}
69+
1370
// App represents a bootstrapped modkit application with its dependency graph,
1471
// container, and instantiated controllers.
1572
type App struct {
@@ -28,6 +85,11 @@ func controllerKey(moduleName, controllerName string) string {
2885
// It builds the module graph, validates dependencies, creates the DI container,
2986
// and instantiates all controllers.
3087
func Bootstrap(root module.Module) (*App, error) {
88+
return BootstrapWithOptions(root)
89+
}
90+
91+
// BootstrapWithOptions constructs a modkit application from a root module and explicit bootstrap options.
92+
func BootstrapWithOptions(root module.Module, opts ...BootstrapOption) (*App, error) {
3193
graph, err := BuildGraph(root)
3294
if err != nil {
3395
return nil, err
@@ -38,11 +100,38 @@ func Bootstrap(root module.Module) (*App, error) {
38100
return nil, err
39101
}
40102

41-
container, err := newContainer(graph, visibility)
103+
cfg := newBootstrapConfig()
104+
for idx, opt := range opts {
105+
if opt == nil {
106+
return nil, &NilBootstrapOptionError{Index: idx}
107+
}
108+
cfg.setCurrentOption(idx)
109+
opt.apply(&cfg)
110+
if cfg.err != nil {
111+
return nil, cfg.err
112+
}
113+
}
114+
115+
providers, err := providerEntriesFromGraph(graph)
42116
if err != nil {
43117
return nil, err
44118
}
45119

120+
for _, override := range cfg.providerOverrides {
121+
entry, ok := providers[override.Token]
122+
if !ok {
123+
return nil, &OverrideTokenNotFoundError{Token: override.Token}
124+
}
125+
if !visibility[graph.Root][override.Token] {
126+
return nil, &OverrideTokenNotVisibleFromRootError{Root: graph.Root, Token: override.Token}
127+
}
128+
entry.build = override.Build
129+
entry.cleanup = override.Cleanup
130+
providers[override.Token] = entry
131+
}
132+
133+
container := newContainerWithProviders(providers, visibility)
134+
46135
controllers := make(map[string]any)
47136
perModule := make(map[string]map[string]bool)
48137
for i := range graph.Modules {

modkit/kernel/bootstrap_options.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package kernel
2+
3+
import (
4+
"context"
5+
6+
"github.com/go-modkit/modkit/modkit/module"
7+
)
8+
9+
// ProviderOverride replaces provider build/cleanup behavior for a token at bootstrap time.
10+
type ProviderOverride struct {
11+
Token module.Token
12+
Build func(module.Resolver) (any, error)
13+
Cleanup func(context.Context) error
14+
}
15+
16+
// BootstrapOption configures advanced bootstrap behavior.
17+
type BootstrapOption interface {
18+
apply(*bootstrapConfig)
19+
}
20+
21+
type providerOverridesOption struct {
22+
overrides []ProviderOverride
23+
}
24+
25+
func (o providerOverridesOption) apply(cfg *bootstrapConfig) {
26+
cfg.addProviderOverrides(o.overrides)
27+
}
28+
29+
// WithProviderOverrides applies token-level provider overrides for bootstrap.
30+
func WithProviderOverrides(overrides ...ProviderOverride) BootstrapOption {
31+
cloned := make([]ProviderOverride, len(overrides))
32+
copy(cloned, overrides)
33+
return providerOverridesOption{overrides: cloned}
34+
}

0 commit comments

Comments
 (0)