Skip to content

Testing Guide

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 tests
Terminal window
make test-all

Runs both unit and integration tests. Integration tests require Docker.

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

Every handler test should include at minimum:

CategoryWhat to test
Happy pathValid params → expected result
Missing required paramsEach required param missing → error
Invalid param typesWrong types → error
Client errorsAPI returns error → properly wrapped error
Edge casesEmpty strings, zero IDs, boundary values

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

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

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")
}
// Basic assertions
assert.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 steps
require.NotNil(t, result) // Use when subsequent assertions depend on non-nil
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)
}