Testing
One of Contract’s biggest advantages is testability. Since your services are just plain Go structs with methods, you can test them directly without HTTP mocking, framework setup, or complex test fixtures.Why Contract Services Are Easy to Test
Traditional API testing looks like this:Copy
// Testing a typical HTTP handler - lots of ceremony
func TestCreateTodo(t *testing.T) {
// Setup HTTP server
server := httptest.NewServer(myHandler)
defer server.Close()
// Create HTTP request
body := strings.NewReader(`{"title":"Test"}`)
req, _ := http.NewRequest("POST", server.URL+"/todos", body)
req.Header.Set("Content-Type", "application/json")
// Make HTTP call
resp, err := http.DefaultClient.Do(req)
// Parse response...
// Check status code...
// Unmarshal JSON...
}
Copy
// Testing a Contract service - just call the method!
func TestCreate(t *testing.T) {
svc := todo.NewService()
result, err := svc.Create(context.Background(), &todo.CreateInput{Title: "Test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Title != "Test" {
t.Errorf("expected 'Test', got '%s'", result.Title)
}
}
Unit Testing Your Service
Unit tests test your business logic directly, without any transport layer.Basic Test
Copy
package todo_test
import (
"context"
"testing"
"yourmodule/service/todo"
)
func TestCreate(t *testing.T) {
// Create service instance
svc := todo.NewService()
// Call the method directly
result, err := svc.Create(context.Background(), &todo.CreateInput{
Title: "Buy groceries",
})
// Check the results
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Title != "Buy groceries" {
t.Errorf("expected 'Buy groceries', got '%s'", result.Title)
}
if result.ID == "" {
t.Error("expected ID to be set")
}
}
Table-Driven Tests
For testing multiple scenarios, use table-driven tests:Copy
func TestCreate_Validation(t *testing.T) {
tests := []struct {
name string
input *todo.CreateInput
wantErr bool
errMsg string
}{
{
name: "valid title",
input: &todo.CreateInput{Title: "Buy milk"},
wantErr: false,
},
{
name: "empty title",
input: &todo.CreateInput{Title: ""},
wantErr: true,
errMsg: "title is required",
},
{
name: "title too long",
input: &todo.CreateInput{Title: strings.Repeat("x", 1001)},
wantErr: true,
errMsg: "title too long",
},
}
svc := todo.NewService()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := svc.Create(context.Background(), tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing '%s', got '%s'", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
})
}
}
Testing Error Types
When using Contract’s error types, verify the error code:Copy
import (
"errors"
contract "github.com/go-mizu/mizu/contract/v2"
)
func TestGet_NotFound(t *testing.T) {
svc := todo.NewService()
_, err := svc.Get(context.Background(), &todo.GetInput{ID: "nonexistent"})
// Check that we got an error
if err == nil {
t.Fatal("expected error, got nil")
}
// Check that it's a Contract error
var contractErr *contract.Error
if !errors.As(err, &contractErr) {
t.Fatalf("expected contract.Error, got %T: %v", err, err)
}
// Check the error code
if contractErr.Code != contract.ErrCodeNotFound {
t.Errorf("expected NOT_FOUND, got %s", contractErr.Code)
}
// Check the HTTP status would be correct
if contractErr.HTTPStatus() != 404 {
t.Errorf("expected HTTP 404, got %d", contractErr.HTTPStatus())
}
}
Testing Services with Dependencies
Real services have dependencies like databases and caches. Use dependency injection and interfaces for testability.Define Interfaces
Copy
// In your service package (e.g., todo/service.go)
package todo
type Service struct {
db Database
cache Cache
}
// Database interface for mocking
type Database interface {
FindTodo(ctx context.Context, id string) (*Todo, error)
SaveTodo(ctx context.Context, todo *Todo) error
DeleteTodo(ctx context.Context, id string) error
}
// Cache interface for mocking
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte) error
}
Create Mock Implementations
Copy
// Simple mock for testing
type MockDatabase struct {
todos map[string]*Todo
}
func NewMockDatabase() *MockDatabase {
return &MockDatabase{todos: make(map[string]*Todo)}
}
func (m *MockDatabase) FindTodo(ctx context.Context, id string) (*Todo, error) {
todo, ok := m.todos[id]
if !ok {
return nil, sql.ErrNoRows
}
return todo, nil
}
func (m *MockDatabase) SaveTodo(ctx context.Context, todo *Todo) error {
m.todos[todo.ID] = todo
return nil
}
func (m *MockDatabase) DeleteTodo(ctx context.Context, id string) error {
delete(m.todos, id)
return nil
}
Test with Mocks
Copy
func TestGet_WithMock(t *testing.T) {
// Setup mock with test data
mockDB := NewMockDatabase()
mockDB.todos["1"] = &Todo{ID: "1", Title: "Test Todo"}
// Create service with mock
svc := &Service{db: mockDB}
// Test the method
result, err := svc.Get(context.Background(), &GetInput{ID: "1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Title != "Test Todo" {
t.Errorf("expected 'Test Todo', got '%s'", result.Title)
}
}
Integration Testing Transports
Sometimes you need to test the full HTTP flow. Contract makes this easy too.Testing REST Endpoints
Copy
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-mizu/mizu"
contract "github.com/go-mizu/mizu/contract/v2"
"github.com/go-mizu/mizu/contract/v2/transport/rest"
"yourapp/todo"
)
// Your interface is defined in the todo package as todo.API
func TestREST_Create(t *testing.T) {
// Setup
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
rest.Mount(app.Router, svc)
// Create request
body := strings.NewReader(`{"title": "Test"}`)
req := httptest.NewRequest("POST", "/todos", body)
req.Header.Set("Content-Type", "application/json")
// Execute
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
// Verify status code
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
// Verify response body
var result Todo
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if result.Title != "Test" {
t.Errorf("expected 'Test', got '%s'", result.Title)
}
}
func TestREST_Get_NotFound(t *testing.T) {
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
rest.Mount(app.Router, svc)
req := httptest.NewRequest("GET", "/todos/nonexistent", nil)
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
// Should return 404 for not found
if rec.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rec.Code)
}
}
Testing JSON-RPC
Copy
import (
"github.com/go-mizu/mizu/contract/v2/transport/jsonrpc"
"yourapp/todo"
)
func TestJSONRPC_Create(t *testing.T) {
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
jsonrpc.Mount(app.Router, "/rpc", svc)
body := strings.NewReader(`{
"jsonrpc": "2.0",
"id": 1,
"method": "todos.create",
"params": {"title": "Test"}
}`)
req := httptest.NewRequest("POST", "/rpc", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
// Parse JSON-RPC response
var resp struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
json.NewDecoder(rec.Body).Decode(&resp)
if resp.Error != nil {
t.Fatalf("unexpected error: %s", resp.Error.Message)
}
if resp.Result == nil {
t.Fatal("expected result, got nil")
}
}
func TestJSONRPC_Batch(t *testing.T) {
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
jsonrpc.Mount(app.Router, "/rpc", svc)
body := strings.NewReader(`[
{"jsonrpc": "2.0", "id": 1, "method": "todos.create", "params": {"title": "First"}},
{"jsonrpc": "2.0", "id": 2, "method": "todos.create", "params": {"title": "Second"}}
]`)
req := httptest.NewRequest("POST", "/rpc", body)
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
var responses []map[string]any
json.NewDecoder(rec.Body).Decode(&responses)
if len(responses) != 2 {
t.Errorf("expected 2 responses, got %d", len(responses))
}
}
Testing MCP
Copy
import "github.com/go-mizu/mizu/contract/v2/transport/mcp"
func TestMCP_ToolsCall(t *testing.T) {
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
mcp.Mount(app.Router, "/mcp", svc)
// First, initialize
initBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(initBody))
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
// Then, call a tool
callBody := `{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{"name":"todos.create","arguments":{"title":"Test"}}
}`
req = httptest.NewRequest("POST", "/mcp", strings.NewReader(callBody))
rec = httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
Result struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
}
json.NewDecoder(rec.Body).Decode(&resp)
if resp.Result.IsError {
t.Error("expected success, got error")
}
}
Test Helpers
Create helper functions to reduce boilerplate:Copy
// Setup helper
func setupTestApp(t *testing.T) *mizu.App {
t.Helper()
impl := todo.NewService()
svc := contract.Register[todo.API](impl,
contract.WithDefaultResource("todos"),
)
app := mizu.New()
rest.Mount(app.Router, svc)
jsonrpc.Mount(app.Router, "/rpc", svc)
return app
}
// Usage
func TestIntegration(t *testing.T) {
app := setupTestApp(t)
// Test REST
req := httptest.NewRequest("POST", "/todos", strings.NewReader(`{"title":"Test"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.Router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
Best Practices
1. Test Business Logic Separately from Transports
Copy
// Good: separate tests for each layer
// Unit test - business logic
func TestService_Create(t *testing.T) {
svc := NewService()
result, err := svc.Create(ctx, input)
// Assert...
}
// Integration test - HTTP transport
func TestREST_Create(t *testing.T) {
// HTTP request/response test
}
2. Use Subtests for Organization
Copy
func TestTodoService(t *testing.T) {
svc := todo.NewService()
t.Run("Create", func(t *testing.T) {
result, err := svc.Create(ctx, &todo.CreateInput{Title: "Test"})
// ...
})
t.Run("Get", func(t *testing.T) {
result, err := svc.Get(ctx, &todo.GetInput{ID: "1"})
// ...
})
t.Run("Delete", func(t *testing.T) {
err := svc.Delete(ctx, &todo.DeleteInput{ID: "1"})
// ...
})
}
3. Test Error Conditions
Don’t just test the happy path:Copy
func TestGet_Errors(t *testing.T) {
svc := todo.NewService()
t.Run("not found", func(t *testing.T) {
_, err := svc.Get(ctx, &todo.GetInput{ID: "nonexistent"})
assertErrorCode(t, err, contract.ErrCodeNotFound)
})
t.Run("empty id", func(t *testing.T) {
_, err := svc.Get(ctx, &todo.GetInput{ID: ""})
assertErrorCode(t, err, contract.ErrCodeInvalidArgument)
})
}
// Helper function
func assertErrorCode(t *testing.T, err error, want contract.ErrorCode) {
t.Helper()
var contractErr *contract.Error
if !errors.As(err, &contractErr) {
t.Fatalf("expected contract.Error, got %T", err)
}
if contractErr.Code != want {
t.Errorf("expected %s, got %s", want, contractErr.Code)
}
}
4. Use Parallel Tests When Safe
Copy
func TestService(t *testing.T) {
t.Parallel() // This test can run in parallel with others
tests := []struct {
name string
input *CreateInput
}{
{"test 1", &CreateInput{Title: "One"}},
{"test 2", &CreateInput{Title: "Two"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Subtests can run in parallel
svc := NewService() // Each subtest gets its own service
svc.Create(ctx, tt.input)
// ...
})
}
}
See Also
- Services - Writing testable services
- Errors - Testing error conditions
- Architecture - Understanding what to test at each layer