modkit uses standard Go testing. This guide covers patterns for testing modules, providers, and controllers.
- Test at the right level — Unit test business logic, integration test module wiring
- Use the kernel — Bootstrap real modules in tests to verify wiring
- Mock at boundaries — Replace external dependencies (DB, HTTP) with test doubles
Test provider logic in isolation:
func TestUsersService_Create(t *testing.T) {
// Arrange: mock repository
repo := &mockRepository{
createFn: func(ctx context.Context, user User) error {
return nil
},
}
svc := NewUsersService(repo)
// Act
err := svc.Create(context.Background(), User{Name: "Ada"})
// Assert
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}Bootstrap modules to verify providers resolve correctly:
func TestUsersModuleBootstrap(t *testing.T) {
// Arrange: create module with mock DB
db := &DatabaseModule{db: mockDB}
users := NewUsersModule(db)
// Act
app, err := kernel.Bootstrap(users)
// Assert
if err != nil {
t.Fatalf("bootstrap failed: %v", err)
}
if len(app.Controllers) != 1 {
t.Fatalf("expected 1 controller, got %d", len(app.Controllers))
}
}Verify modules can only access exported tokens:
func TestVisibilityEnforcement(t *testing.T) {
internal := &InternalModule{} // has provider but doesn't export it
consumer := &ConsumerModule{imports: internal}
_, err := kernel.Bootstrap(consumer)
var visErr *kernel.TokenNotVisibleError
if !errors.As(err, &visErr) {
t.Fatalf("expected TokenNotVisibleError, got %v", err)
}
}Use table-driven tests for graph validation:
func TestBuildGraphErrors(t *testing.T) {
tests := []struct {
name string
root module.Module
wantErr error
}{
{
name: "nil root",
root: nil,
wantErr: &kernel.RootModuleNilError{},
},
{
name: "duplicate module name",
root: moduleWithDuplicateImport(),
wantErr: &kernel.DuplicateModuleNameError{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := kernel.BuildGraph(tt.root)
if err == nil {
t.Fatal("expected error")
}
// Check error type matches
})
}
}Test controllers as HTTP handlers:
func TestUsersController_List(t *testing.T) {
// Arrange
svc := &mockUsersService{
listFn: func(ctx context.Context) ([]User, error) {
return []User{{ID: 1, Name: "Ada"}}, nil
},
}
controller := NewUsersController(svc)
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
// Act
controller.List(rec, req)
// Assert
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}Create test-specific modules that swap real dependencies:
func TestIntegration(t *testing.T) {
// Test database module with in-memory DB
testDB := &TestDatabaseModule{db: setupTestDB(t)}
// Real users module with test DB
users := NewUsersModule(testDB)
app, err := kernel.Bootstrap(users)
if err != nil {
t.Fatal(err)
}
router := mkhttp.NewRouter()
mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers)
// Test via HTTP
req := httptest.NewRequest(http.MethodGet, "/users", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
}Use modkit/testkit when you want less boilerplate for bootstrap, typed retrieval, and provider overrides.
func TestAuthModule_WithTestKit(t *testing.T) {
h := testkit.New(t,
auth.NewModule(auth.Options{}),
testkit.WithOverrides(
testkit.OverrideValue(configmodule.TokenAuthUsername, "demo"),
testkit.OverrideValue(configmodule.TokenAuthPassword, "demo"),
),
)
handler := testkit.Get[*auth.Handler](t, h, auth.TokenHandler)
if handler == nil {
t.Fatal("expected handler")
}
}Decision guidance:
- Use test module replacement when changing module wiring semantics.
- Use TestKit override when isolating dependency behavior without changing graph shape.
testkit.New registers cleanup with t.Cleanup by default. Use testkit.WithoutAutoClose() only when you need explicit close timing.
For full integration tests, use testcontainers:
func TestSmoke(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
// Start MySQL container
mysql, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "mysql:8",
ExposedPorts: []string{"3306/tcp"},
// ...
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer mysql.Terminate(ctx)
// Get connection string and bootstrap
dsn := getContainerDSN(mysql)
app := bootstrapApp(dsn)
// Run tests against real stack
}See examples/hello-mysql/internal/smoke/smoke_test.go for a complete example.
The examples/hello-mysql app includes current patterns for auth, validation, middleware, lifecycle, and routing. Keep this guide aligned with the following tests:
- Auth:
examples/hello-mysql/internal/modules/auth/handler_test.go,examples/hello-mysql/internal/modules/auth/middleware_test.go,examples/hello-mysql/internal/modules/auth/integration_test.go - Validation:
examples/hello-mysql/internal/validation/validation_test.go,examples/hello-mysql/internal/modules/users/validation_test.go - Middleware:
examples/hello-mysql/internal/middleware/middleware_test.go,examples/hello-mysql/internal/modules/app/app_test.go - Lifecycle:
examples/hello-mysql/internal/lifecycle/lifecycle_test.go,examples/hello-mysql/cmd/api/main_test.go - Routing:
examples/hello-mysql/internal/httpserver/server_test.go,examples/hello-mysql/internal/modules/app/controller_test.go,examples/hello-mysql/internal/modules/users/controller_test.go
Run all tests:
go test ./...Run library tests only:
go test ./modkit/...Run a specific test:
go test ./modkit/kernel -run TestBuildGraphSkip integration tests:
go test -short ./...- Keep unit tests fast; use mocks for external dependencies
- Use
t.Parallel()for independent tests - Test error cases, not just happy paths
- Integration tests should clean up after themselves