Files
gh-buzzdan-ai-coding-rules-…/skills/testing/examples/jsonrpc-mock.md
2025-11-29 18:02:42 +08:00

7.8 KiB

JSON-RPC Server Mock with DSL

When to Use This Example

Use this when:

  • Testing JSON-RPC clients
  • Need to mock JSON-RPC server responses
  • Want configurable mock behavior per method
  • Need to track and assert on received requests
  • Testing with OpenTelemetry trace propagation

Dependency Level: Level 1 (In-Memory) - Uses httptest.Server for in-memory HTTP

Key Insight: When testing a JSON-RPC client, mock the server it calls. Use rich DSL for readable test setup.

Implementation

Rich JSON-RPC Server Mock

// internal/testutils/jrpc_server_mock.go
package testutils

import (
    "errors"
    "fmt"
    "net/http"
    "net/http/httptest"

    "github.com/gorilla/rpc/v2/json2"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/trace"
)

var ErrMethodNotFound = errors.New("method not found")

// TraceQuery holds received JSON-RPC queries for assertions
type TraceQuery struct {
    Method string
    Params string
}

// JrpcTraceServerMock is a rich JSON-RPC server mock with DSL.
// Uses httptest.Server for in-memory HTTP (Level 1).
type JrpcTraceServerMock struct {
    tracer          trace.Tracer
    server          *httptest.Server
    mockResponses   map[string]any    // method -> response
    queriesReceived []TraceQuery      // for assertions
}

// StartJrpcTraceServerMock starts an in-memory JSON-RPC server.
// Returns a rich DSL object for configuring mock responses.
func StartJrpcTraceServerMock() *JrpcTraceServerMock {
    mock := &JrpcTraceServerMock{
        mockResponses: make(map[string]any),
        tracer:        otel.Tracer("trace-server-mock"),
    }

    mux := mock.createHTTPHandlers()
    mock.server = httptest.NewServer(mux)

    return mock
}

// AddMockResponse configures the mock to return a response for a method.
// This is the DSL - chain multiple calls for different methods!
func (m *JrpcTraceServerMock) AddMockResponse(method string, response any) {
    m.mockResponses[method] = response
}

// GetQueriesReceived returns all queries received (for assertions)
func (m *JrpcTraceServerMock) GetQueriesReceived() []TraceQuery {
    return m.queriesReceived
}

// Close shuts down the server (idempotent)
func (m *JrpcTraceServerMock) Close() {
    m.server.Close()
}

// Address returns the server address (for client configuration)
func (m *JrpcTraceServerMock) Address() string {
    return m.server.Listener.Addr().String()
}

func (m *JrpcTraceServerMock) createHTTPHandlers() *http.ServeMux {
    mux := http.NewServeMux()
    codec := json2.NewCodec()

    mux.HandleFunc("/reader", func(w http.ResponseWriter, r *http.Request) {
        // Extract OpenTelemetry context for realistic testing
        reqCtx := r.Context()
        reqCtx = otel.GetTextMapPropagator().Extract(reqCtx, propagation.HeaderCarrier(r.Header))
        reqCtx, span := m.tracer.Start(reqCtx, "jrpc-trace-server",
            trace.WithSpanKind(trace.SpanKindServer))
        defer span.End()

        if r.Method != http.MethodPost {
            w.WriteHeader(http.StatusMethodNotAllowed)
            return
        }

        receivedReq := codec.NewRequest(r)
        method, err := receivedReq.Method()
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        // Check if we have a mock response configured
        if response, exists := m.mockResponses[method]; exists {
            args := struct{}{}
            if err := receivedReq.ReadRequest(&args); err != nil {
                receivedReq.WriteError(w, http.StatusBadRequest, err)
                return
            }

            // Store query for assertions
            m.queriesReceived = append(m.queriesReceived, TraceQuery{
                Method: method,
                Params: fmt.Sprintf("%+v", args),
            })

            // Write mock response
            receivedReq.WriteResponse(w, response)
            return
        }

        // Method not configured
        params := []string{}
        receivedReq.ReadRequest(&params)
        receivedReq.WriteError(w, http.StatusBadRequest, ErrMethodNotFound)
    })

    return mux
}

Usage Examples

Setup in Test Suite

func (suite *TaskmonTestSuite) SetupSuite() {
    // Start in-memory JSON-RPC server mock (Level 1)
    suite.jrpcServerMock = testutils.StartJrpcTraceServerMock()

    // Configure mock responses using DSL
    suite.jrpcServerMock.AddMockResponse("protocol", struct {
        Version string `json:"version"`
        Date    string `json:"date"`
    }{
        Version: "3.18.0",
        Date:    "Sep-04-2018",
    })

    suite.jrpcServerMock.AddMockResponse("get_traces", struct {
        Traces []string `json:"traces"`
    }{
        Traces: []string{"trace1", "trace2"},
    })

    // Configure your client to use the mock server
    client := jrpc.NewClient(suite.jrpcServerMock.Address() + "/reader")
}

func (suite *TaskmonTestSuite) TearDownSuite() {
    suite.jrpcServerMock.Close()
}

Test with Assertions

func (suite *TaskmonTestSuite) TestProtocolVersion() {
    // Call your code that makes JSON-RPC requests
    version, err := suite.taskmon.GetProtocolVersion()
    suite.Require().NoError(err)
    suite.Equal("3.18.0", version.Version)

    // Assert on received queries
    queries := suite.jrpcServerMock.GetQueriesReceived()
    suite.Require().Len(queries, 1)
    suite.Equal("protocol", queries[0].Method)
}

func (suite *TaskmonTestSuite) TestGetTraces() {
    // Call your code
    traces, err := suite.taskmon.GetTraces()
    suite.Require().NoError(err)
    suite.Equal([]string{"trace1", "trace2"}, traces)

    // Verify the right method was called
    queries := suite.jrpcServerMock.GetQueriesReceived()
    suite.Require().Len(queries, 2) // protocol + get_traces
    suite.Equal("get_traces", queries[1].Method)
}

Why This Pattern is Excellent

  1. Rich DSL - AddMockResponse() for easy, readable configuration
  2. Readable Setup - Tests are self-documenting, clear intent
  3. In-Memory - Uses httptest.Server (Level 1, no network I/O)
  4. Query Tracking - GetQueriesReceived() for assertions on what was called
  5. OpenTelemetry Integration - Realistic trace propagation for observability testing
  6. Idempotent Cleanup - Safe to call Close() multiple times
  7. Flexible - Configure any method/response combination dynamically

Key Design Principles

DSL for Configuration

Mock setup should read like configuration:

mock.AddMockResponse("method_name", expectedResponse)
mock.AddMockResponse("another_method", anotherResponse)

Query Tracking for Assertions

Always track what was received:

  • Method names called
  • Parameters passed
  • Order of calls
  • Number of calls

Built on httptest.Server

httptest.Server provides:

  • In-memory HTTP (no network I/O)
  • Automatic address allocation
  • Clean lifecycle management
  • Standard library, no dependencies

Pattern Comparison

Pattern Use When
httptest.Server Simple HTTP mocking
NATS test harness Need real NATS (pub/sub)
gRPC client mock Testing gRPC server
JSON-RPC server mock Testing JSON-RPC client

Benefits

  • In-Memory - No network I/O, pure Go
  • Fast - Microsecond startup time
  • Configurable - Dynamic response configuration per test
  • Trackable - Full visibility into received requests
  • OpenTelemetry-aware - Realistic trace propagation
  • Reusable - Same infrastructure across test levels

Key Takeaways

  1. Mock servers should have rich DSL - Makes setup readable
  2. Track received requests - Essential for assertions
  3. Use httptest.Server - Perfect for HTTP-based protocols
  4. Make setup read like configuration - Self-documenting tests
  5. Support trace propagation - Realistic observability testing
  6. Idempotent cleanup - Safe resource management