Testing Guide
Test Organization
Section titled “Test Organization”portainer-mcp-enhanced/├── internal/mcp/│ ├── *_test.go # Unit tests for each handler domain│ ├── mocks_test.go # Shared mock PortainerClient│ ├── metatool_test.go # Meta-tool routing tests│ ├── schema_test.go # Tool definition validation│ └── server_test.go # Server initialization tests├── internal/k8sutil/│ └── stripper_test.go # K8s metadata stripping tests├── internal/tooldef/│ └── tooldef_test.go # Embedded YAML loading tests├── pkg/toolgen/│ ├── yaml_test.go # YAML parsing tests│ └── param_test.go # Parameter extraction tests├── pkg/portainer/models/│ └── *_test.go # Model conversion tests└── tests/integration/ ├── helpers/test_env.go # Test environment setup └── *_test.go # Integration testsRunning Tests
Section titled “Running Tests”make test-allRuns both unit and integration tests. Integration tests require Docker.
make test# orgo test -v $(go list ./... | grep -v /tests/integration)make test-integration# orgo test -v ./tests/...# Single test functiongo test -v ./internal/mcp/ -run TestHandleGetEnvironments
# All tests in a packagego test -v ./pkg/portainer/models/
# Tests matching a patterngo test -v ./internal/mcp/ -run "TestHandle.*Stack"make test-coveragego tool cover -html=coverage.out -o coverage.html# Open coverage.html in your browserUnit Test Pattern
Section titled “Unit Test Pattern”All unit tests follow the table-driven pattern:
func TestHandleCreateUser(t *testing.T) { tests := []struct { name string args map[string]any mockSetup func(*MockPortainerClient) wantErr bool checkResult func(t *testing.T, result *mcp.CallToolResult) }{ { name: "creates user successfully", args: map[string]any{ "username": "alice", "password": "securePass123", "role": "user", }, mockSetup: func(m *MockPortainerClient) { m.On("CreateUser", "alice", "securePass123", "user").Return(1, nil) }, wantErr: false, }, { name: "fails with missing username", args: map[string]any{"password": "x", "role": "user"}, wantErr: true, }, { name: "fails when API returns error", args: map[string]any{ "username": "alice", "password": "x", "role": "user", }, mockSetup: func(m *MockPortainerClient) { m.On("CreateUser", "alice", "x", "user").Return(0, fmt.Errorf("user already exists")) }, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := &MockPortainerClient{} if tt.mockSetup != nil { tt.mockSetup(mockClient) } s := &PortainerMCPServer{cli: mockClient}
result, err := s.HandleCreateUser()(context.Background(), CreateMCPRequest(tt.args))
if tt.wantErr { assert.True(t, result.IsError) return } assert.NoError(t, err) assert.NotNil(t, result) if tt.checkResult != nil { tt.checkResult(t, result) } mockClient.AssertExpectations(t) }) }}Test Case Categories
Section titled “Test Case Categories”Every handler test should include at minimum:
| Category | What to test |
|---|---|
| Happy path | Valid params → expected result |
| Missing required params | Each required param missing → error |
| Invalid param types | Wrong types → error |
| Client errors | API returns error → properly wrapped error |
| Edge cases | Empty strings, zero IDs, boundary values |
Mock Client
Section titled “Mock Client”The shared mock lives in internal/mcp/mocks_test.go and uses testify/mock:
type MockPortainerClient struct { mock.Mock}
func (m *MockPortainerClient) GetEnvironments() ([]models.Environment, error) { args := m.Called() if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]models.Environment), args.Error(1)}
func (m *MockPortainerClient) CreateUser(username, password, role string) (int, error) { args := m.Called(username, password, role) return args.Int(0), args.Error(1)}Model Conversion Tests
Section titled “Model Conversion Tests”Model tests verify the raw → local conversion functions:
func TestConvertEnvironment(t *testing.T) { raw := &apimodels.PortainerEndpoint{ ID: 1, Name: "production", Type: 1, }
result := models.ConvertEnvironment(raw)
assert.Equal(t, 1, result.ID) assert.Equal(t, "production", result.Name)}
func TestConvertEnvironment_NilInput(t *testing.T) { result := models.ConvertEnvironment(nil) assert.Equal(t, models.Environment{}, result)}Coverage target: All model conversion functions should have tests for:
- Valid input with all fields populated
- Nil input (should return zero value, not panic)
- Partial input (missing optional fields)
- Nested struct nil checks
Integration Tests
Section titled “Integration Tests”Test Environment
Section titled “Test Environment”tests/integration/helpers/test_env.go provides:
type TestEnv struct { MCPServer *mcp.Server // MCP server connected to real Portainer RawClient *portainer.APIClient // Raw SDK client for ground-truth comparison Cleanup func() // Stops containers, removes resources}
func SetupTestEnv(t *testing.T) *TestEnv { // 1. Start Portainer container via testcontainers-go // 2. Wait for API to be ready // 3. Create admin user and get API token // 4. Initialize both raw client and MCP server // 5. Return TestEnv with cleanup function}Writing Integration Tests
Section titled “Writing Integration Tests”func TestIntegrationStackCRUD(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") }
env := helpers.SetupTestEnv(t) defer env.Cleanup()
// Create via MCP createResult, err := env.MCPServer.HandleCreateStack(map[string]interface{}{ "name": "test-stack", "file": "services:\n web:\n image: nginx", "environmentId": float64(1), }) require.NoError(t, err)
// Verify via raw client stacks, err := env.RawClient.Stacks.StackList(nil, nil) require.NoError(t, err) assert.Len(t, stacks.Payload, 1) assert.Equal(t, "test-stack", stacks.Payload[0].Name)}Validation Tests
Section titled “Validation Tests”For tools with input validation (compose YAML, cron expressions, URLs):
func TestHandleCreateEdgeJob_InvalidCron(t *testing.T) { params := map[string]interface{}{ "name": "test-job", "fileContent": "echo hello", "cronExpression": "not a valid cron", }
server := NewTestServer(&MockPortainerClient{}) _, err := server.HandleCreateEdgeJob(params)
assert.Error(t, err) assert.Contains(t, err.Error(), "invalid cron expression")}Test Utilities
Section titled “Test Utilities”testify Assertion Patterns
Section titled “testify Assertion Patterns”// Basic assertionsassert.Equal(t, expected, actual)assert.NotNil(t, result)assert.Error(t, err)assert.NoError(t, err)assert.Contains(t, str, "substring")assert.Len(t, slice, 3)
// Fatal assertions (stop test on failure)require.NoError(t, err) // Use for setup stepsrequire.NotNil(t, result) // Use when subsequent assertions depend on non-nilJSON Result Checking
Section titled “JSON Result Checking”func assertResultContains(t *testing.T, result *mcp.CallToolResult, key string) { t.Helper() assert.NotNil(t, result) content := result.Content[0].(mcp.TextContent) assert.Contains(t, content.Text, key)}