Skip to content

Latest commit

 

History

History
274 lines (209 loc) · 7.04 KB

File metadata and controls

274 lines (209 loc) · 7.04 KB

Testing

modkit uses standard Go testing. This guide covers patterns for testing modules, providers, and controllers.

Testing Principles

  1. Test at the right level — Unit test business logic, integration test module wiring
  2. Use the kernel — Bootstrap real modules in tests to verify wiring
  3. Mock at boundaries — Replace external dependencies (DB, HTTP) with test doubles

Unit Testing Providers

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)
    }
}

Testing Module Wiring

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))
    }
}

Testing Visibility

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)
    }
}

Table-Driven Tests

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
        })
    }
}

Testing Controllers

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)
    }
}

Integration Testing with Test Modules

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)
    }
}

Using TestKit

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.

Smoke Tests with Testcontainers

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.

Example Test Files

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

Running Tests

Run all tests:

go test ./...

Run library tests only:

go test ./modkit/...

Run a specific test:

go test ./modkit/kernel -run TestBuildGraph

Skip integration tests:

go test -short ./...

Tips

  • 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