Skip to main content

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:
// 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...
}
With Contract:
// 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

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

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

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

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

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

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

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

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

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

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