7.8 KiB
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(¶ms)
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
- Rich DSL -
AddMockResponse()for easy, readable configuration - Readable Setup - Tests are self-documenting, clear intent
- In-Memory - Uses
httptest.Server(Level 1, no network I/O) - Query Tracking -
GetQueriesReceived()for assertions on what was called - OpenTelemetry Integration - Realistic trace propagation for observability testing
- Idempotent Cleanup - Safe to call
Close()multiple times - 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
- Mock servers should have rich DSL - Makes setup readable
- Track received requests - Essential for assertions
- Use httptest.Server - Perfect for HTTP-based protocols
- Make setup read like configuration - Self-documenting tests
- Support trace propagation - Realistic observability testing
- Idempotent cleanup - Safe resource management