Go Testing Guide¶
Testing Strategies for Go MCP Servers¶
Comprehensive testing ensures your Go MCP server is reliable, maintainable, and performs well under various conditions.
Testing Stack¶
Core Testing Dependencies¶
// go.mod
module github.com/your-org/mcp-server-go
go 1.21
require (
github.com/modelcontextprotocol/go-sdk v0.1.0
github.com/stretchr/testify v1.8.4
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/jarcoal/httpmock v1.3.1
github.com/golang/mock v1.6.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Test Configuration¶
// internal/testutil/config.go
package testutil
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/your-org/mcp-server-go/internal/config"
)
// NewTestConfig creates a configuration suitable for testing
func NewTestConfig(t *testing.T) *config.Config {
tmpDir := t.TempDir()
return &config.Config{
ServerName: "test-server",
Version: "test",
Transport: "stdio",
HTTPAddr: ":0", // Random available port
LogLevel: "debug",
LogFormat: "text",
Database: config.DatabaseConfig{
URL: "sqlite://file:test.db?mode=memory&cache=shared",
MaxConns: 1,
MaxIdle: 1,
ConnTimeout: 5,
},
}
}
// SetupTestDir creates a temporary directory for testing
func SetupTestDir(t *testing.T, files map[string]string) string {
tmpDir := t.TempDir()
for filename, content := range files {
filePath := filepath.Join(tmpDir, filename)
require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0755))
require.NoError(t, os.WriteFile(filePath, []byte(content), 0644))
}
return tmpDir
}
Unit Testing¶
Testing Tool Implementations¶
// internal/tools/echo_test.go
package tools
import (
"context"
"testing"
"github.com/modelcontextprotocol/go-sdk/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEchoTool_Name(t *testing.T) {
tool := &EchoTool{}
assert.Equal(t, "echo", tool.Name())
}
func TestEchoTool_Description(t *testing.T) {
tool := &EchoTool{}
assert.NotEmpty(t, tool.Description())
}
func TestEchoTool_InputSchema(t *testing.T) {
tool := &EchoTool{}
schema := tool.InputSchema()
assert.Equal(t, "object", schema["type"])
properties, ok := schema["properties"].(map[string]interface{})
require.True(t, ok)
textProp, ok := properties["text"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "string", textProp["type"])
required, ok := schema["required"].([]string)
require.True(t, ok)
assert.Contains(t, required, "text")
}
func TestEchoTool_Execute(t *testing.T) {
tests := []struct {
name string
arguments map[string]interface{}
wantErr bool
wantText string
}{
{
name: "valid text",
arguments: map[string]interface{}{"text": "Hello, World!"},
wantErr: false,
wantText: "Echo: Hello, World!",
},
{
name: "empty text",
arguments: map[string]interface{}{"text": ""},
wantErr: false,
wantText: "Echo: ",
},
{
name: "missing text argument",
arguments: map[string]interface{}{},
wantErr: true,
},
{
name: "invalid text type",
arguments: map[string]interface{}{"text": 123},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tool := &EchoTool{}
ctx := context.Background()
result, err := tool.Execute(ctx, tt.arguments)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "text", result[0].Type)
assert.Equal(t, tt.wantText, result[0].Text)
}
})
}
}
func TestEchoTool_ExecuteWithContext(t *testing.T) {
tool := &EchoTool{}
// Test context cancellation
ctx, cancel := context.WithCancel(context.Background())
cancel()
arguments := map[string]interface{}{"text": "test"}
result, err := tool.Execute(ctx, arguments)
// Echo tool doesn't check context, so it should still work
assert.NoError(t, err)
assert.Len(t, result, 1)
}
Testing Database Tools with Mocks¶
// internal/tools/database_test.go
package tools
import (
"context"
"database/sql"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDatabaseTool_Execute(t *testing.T) {
// Create mock database
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
tool := &DatabaseTool{db: db}
tests := []struct {
name string
arguments map[string]interface{}
setupMock func(sqlmock.Sqlmock)
wantErr bool
wantResults int
}{
{
name: "valid select query",
arguments: map[string]interface{}{"query": "SELECT id, name FROM users"},
setupMock: func(m sqlmock.Sqlmock) {
rows := sqlmock.NewRows([]string{"id", "name"}).
AddRow(1, "Alice").
AddRow(2, "Bob")
m.ExpectQuery("SELECT id, name FROM users LIMIT 100").WillReturnRows(rows)
},
wantErr: false,
wantResults: 1,
},
{
name: "invalid query - not SELECT",
arguments: map[string]interface{}{"query": "DELETE FROM users"},
setupMock: func(m sqlmock.Sqlmock) {
// No mock setup needed as validation should fail first
},
wantErr: true,
},
{
name: "database error",
arguments: map[string]interface{}{"query": "SELECT * FROM nonexistent"},
setupMock: func(m sqlmock.Sqlmock) {
m.ExpectQuery("SELECT \\* FROM nonexistent LIMIT 100").
WillReturnError(sql.ErrNoRows)
},
wantErr: true,
},
{
name: "query with custom limit",
arguments: map[string]interface{}{"query": "SELECT * FROM users", "limit": float64(50)},
setupMock: func(m sqlmock.Sqlmock) {
rows := sqlmock.NewRows([]string{"id"}).AddRow(1)
m.ExpectQuery("SELECT \\* FROM users LIMIT 50").WillReturnRows(rows)
},
wantErr: false,
wantResults: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setupMock(mock)
ctx := context.Background()
result, err := tool.Execute(ctx, tt.arguments)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Len(t, result, tt.wantResults)
}
// Verify all expectations were met
assert.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestDatabaseTool_validateQuery(t *testing.T) {
tool := &DatabaseTool{}
tests := []struct {
name string
query string
wantErr bool
}{
{
name: "valid select",
query: "SELECT * FROM users",
wantErr: false,
},
{
name: "valid select with whitespace",
query: " SELECT id FROM users ",
wantErr: false,
},
{
name: "invalid - delete",
query: "DELETE FROM users",
wantErr: true,
},
{
name: "invalid - insert",
query: "INSERT INTO users VALUES (1, 'test')",
wantErr: true,
},
{
name: "invalid - drop hidden in select",
query: "SELECT * FROM users; DROP TABLE users;",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tool.validateQuery(tt.query)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
Testing HTTP Tools¶
// internal/tools/http_test.go
package tools
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/jarcoal/httpmock"
"github.com/modelcontextprotocol/go-sdk/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPTool_Execute(t *testing.T) {
// Setup HTTP mock
httpmock.Activate()
defer httpmock.DeactivateAndReset()
tool := NewHTTPTool()
tests := []struct {
name string
arguments map[string]interface{}
setupMock func()
wantErr bool
wantStatus int
}{
{
name: "successful GET request",
arguments: map[string]interface{}{"url": "http://example.com/api/users"},
setupMock: func() {
httpmock.RegisterResponder("GET", "http://example.com/api/users",
httpmock.NewStringResponder(200, `{"users": ["alice", "bob"]}`))
},
wantErr: false,
wantStatus: 200,
},
{
name: "successful POST request",
arguments: map[string]interface{}{
"method": "POST",
"url": "http://example.com/api/users",
"body": `{"name": "charlie"}`,
"headers": map[string]interface{}{
"Content-Type": "application/json",
},
},
setupMock: func() {
httpmock.RegisterResponder("POST", "http://example.com/api/users",
httpmock.NewStringResponder(201, `{"id": 123, "name": "charlie"}`))
},
wantErr: false,
wantStatus: 201,
},
{
name: "404 error",
arguments: map[string]interface{}{"url": "http://example.com/api/notfound"},
setupMock: func() {
httpmock.RegisterResponder("GET", "http://example.com/api/notfound",
httpmock.NewStringResponder(404, `{"error": "Not found"}`))
},
wantErr: false,
wantStatus: 404,
},
{
name: "invalid URL",
arguments: map[string]interface{}{"url": "not-a-url"},
setupMock: func() {},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpmock.Reset()
tt.setupMock()
ctx := context.Background()
result, err := tool.Execute(ctx, tt.arguments)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
require.Len(t, result, 1)
var response map[string]interface{}
err := json.Unmarshal([]byte(result[0].Text), &response)
require.NoError(t, err)
statusCode, ok := response["status_code"].(float64)
require.True(t, ok)
assert.Equal(t, float64(tt.wantStatus), statusCode)
}
})
}
}
func TestHTTPTool_ExecuteWithTimeout(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
// Setup slow response
httpmock.RegisterResponder("GET", "http://example.com/slow",
httpmock.NewStringResponder(200, "slow response").Delay(100))
tool := NewHTTPTool()
ctx := context.Background()
arguments := map[string]interface{}{
"url": "http://example.com/slow",
"timeout": float64(1), // 1 second timeout
}
result, err := tool.Execute(ctx, arguments)
// Should succeed as 100ms delay is within 1 second timeout
assert.NoError(t, err)
assert.Len(t, result, 1)
}
Integration Testing¶
Testing Complete Server¶
// test/integration/server_test.go
package integration
import (
"context"
"encoding/json"
"testing"
"github.com/modelcontextprotocol/go-sdk/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/your-org/mcp-server-go/internal/config"
mcpserver "github.com/your-org/mcp-server-go/internal/server"
"github.com/your-org/mcp-server-go/internal/testutil"
)
func TestServer_Integration(t *testing.T) {
// Setup test configuration
cfg := testutil.NewTestConfig(t)
// Create server
srv, err := mcpserver.New(cfg)
require.NoError(t, err)
ctx := context.Background()
// Test tool listing
toolsResponse, err := srv.HandleListTools(ctx)
require.NoError(t, err)
assert.NotEmpty(t, toolsResponse.Tools)
// Test tool execution
callRequest := &server.CallToolRequest{
Params: server.CallToolParams{
Name: "echo",
Arguments: map[string]interface{}{
"text": "integration test",
},
},
}
callResponse, err := srv.HandleCallTool(ctx, callRequest)
require.NoError(t, err)
require.Len(t, callResponse.Content, 1)
assert.Contains(t, callResponse.Content[0].Text, "integration test")
}
func TestServer_ErrorHandling(t *testing.T) {
cfg := testutil.NewTestConfig(t)
srv, err := mcpserver.New(cfg)
require.NoError(t, err)
ctx := context.Background()
// Test unknown tool
callRequest := &server.CallToolRequest{
Params: server.CallToolParams{
Name: "nonexistent_tool",
Arguments: map[string]interface{}{},
},
}
_, err = srv.HandleCallTool(ctx, callRequest)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown tool")
}
func TestServer_ResourceHandling(t *testing.T) {
cfg := testutil.NewTestConfig(t)
srv, err := mcpserver.New(cfg)
require.NoError(t, err)
ctx := context.Background()
// Test resource listing
resourcesResponse, err := srv.HandleListResources(ctx)
require.NoError(t, err)
if len(resourcesResponse.Resources) > 0 {
// Test reading first resource
readRequest := &server.ReadResourceRequest{
Params: server.ReadResourceParams{
URI: resourcesResponse.Resources[0].URI,
},
}
readResponse, err := srv.HandleReadResource(ctx, readRequest)
require.NoError(t, err)
assert.NotEmpty(t, readResponse.Contents)
}
}
Testing with External Services¶
// test/integration/external_test.go
package integration
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/your-org/mcp-server-go/internal/tools"
)
func TestHTTPTool_RealServer(t *testing.T) {
// Create test HTTP server
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "test response"}`))
}))
defer testServer.Close()
// Test HTTP tool against real server
tool := tools.NewHTTPTool()
ctx := context.Background()
arguments := map[string]interface{}{
"url": testServer.URL,
}
result, err := tool.Execute(ctx, arguments)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Contains(t, result[0].Text, "test response")
}
Benchmark Tests¶
Performance Testing¶
// internal/tools/echo_bench_test.go
package tools
import (
"context"
"testing"
)
func BenchmarkEchoTool_Execute(b *testing.B) {
tool := &EchoTool{}
ctx := context.Background()
arguments := map[string]interface{}{
"text": "benchmark test message",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := tool.Execute(ctx, arguments)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkEchoTool_ExecuteParallel(b *testing.B) {
tool := &EchoTool{}
arguments := map[string]interface{}{
"text": "benchmark test message",
}
b.RunParallel(func(pb *testing.PB) {
ctx := context.Background()
for pb.Next() {
_, err := tool.Execute(ctx, arguments)
if err != nil {
b.Fatal(err)
}
}
})
}
func BenchmarkToolRegistry_CallTool(b *testing.B) {
registry := NewRegistry()
registry.Register(&EchoTool{})
ctx := context.Background()
arguments := map[string]interface{}{
"text": "benchmark test",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := registry.CallTool(ctx, "echo", arguments)
if err != nil {
b.Fatal(err)
}
}
}
Test Utilities and Helpers¶
Mock Interfaces¶
// internal/testutil/mocks.go
//go:generate mockgen -source=../tools/tools.go -destination=mocks.go
package testutil
import (
"context"
"github.com/golang/mock/gomock"
"github.com/modelcontextprotocol/go-sdk/server"
)
// MockTool is a mock implementation of the Tool interface
type MockTool struct {
ctrl *gomock.Controller
recorder *MockToolMockRecorder
}
type MockToolMockRecorder struct {
mock *MockTool
}
func NewMockTool(ctrl *gomock.Controller) *MockTool {
mock := &MockTool{ctrl: ctrl}
mock.recorder = &MockToolMockRecorder{mock}
return mock
}
func (m *MockTool) EXPECT() *MockToolMockRecorder {
return m.recorder
}
func (m *MockTool) Name() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Name")
ret0, _ := ret[0].(string)
return ret0
}
func (mr *MockToolMockRecorder) Name() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockTool)(nil).Name))
}
func (m *MockTool) Description() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Description")
ret0, _ := ret[0].(string)
return ret0
}
func (mr *MockToolMockRecorder) Description() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Description", reflect.TypeOf((*MockTool)(nil).Description))
}
func (m *MockTool) InputSchema() map[string]interface{} {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InputSchema")
ret0, _ := ret[0].(map[string]interface{})
return ret0
}
func (mr *MockToolMockRecorder) InputSchema() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InputSchema", reflect.TypeOf((*MockTool)(nil).InputSchema))
}
func (m *MockTool) Execute(ctx context.Context, arguments map[string]interface{}) ([]server.Content, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Execute", ctx, arguments)
ret0, _ := ret[0].([]server.Content)
ret1, _ := ret[1].(error)
return ret0, ret1
}
func (mr *MockToolMockRecorder) Execute(ctx, arguments interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockTool)(nil).Execute), ctx, arguments)
}
Test Configuration¶
Makefile Test Targets¶
# Test targets
.PHONY: test test-unit test-integration test-bench test-race test-coverage
# Run all tests
test: test-unit test-integration
# Run unit tests only
test-unit:
$(GOTEST) -short ./...
# Run integration tests
test-integration:
$(GOTEST) -tags=integration ./test/integration/...
# Run benchmark tests
test-bench:
$(GOTEST) -bench=. -benchmem ./...
# Run tests with race detection
test-race:
$(GOTEST) -race ./...
# Generate test coverage report
test-coverage:
$(GOTEST) -race -coverprofile=coverage.out ./...
$(GOCMD) tool cover -html=coverage.out -o coverage.html
$(GOCMD) tool cover -func=coverage.out
# Run tests with verbose output
test-verbose:
$(GOTEST) -v ./...
# Run tests for specific package
test-pkg:
$(GOTEST) -v ./$(PKG)/...
Best Practices¶
Test Organization¶
- Package-level tests: Place tests in the same package as the code being tested
- Integration tests: Separate integration tests in their own package
- Test data: Use
testdata/
directories for test fixtures - Parallel tests: Use
t.Parallel()
for independent tests
Assertions and Validation¶
- Use testify: Leverage testify for cleaner assertions
- Descriptive names: Write clear, descriptive test function names
- Table-driven tests: Use table-driven tests for multiple scenarios
- Error checking: Always check and assert on errors appropriately
Performance and Reliability¶
- Benchmark critical paths: Benchmark performance-critical code
- Race detection: Always run tests with race detection enabled
- Context usage: Test context cancellation and timeouts
- Resource cleanup: Ensure proper cleanup of resources in tests
This comprehensive testing approach ensures your Go MCP server is robust, performant, and maintainable.