Testing with relay¶
relay is designed to be fully testable without network access. The mock transport and the recorder let you write deterministic, fast tests for any code that uses a relay client.
Mock transport¶
relay.NewMockTransport() returns an http.RoundTripper that serves pre-configured responses from a queue. No listening server required.
Single response¶
import (
"testing"
"github.com/jhonsferg/relay"
)
func TestGetUser(t *testing.T) {
mt := relay.NewMockTransport()
mt.Enqueue(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{"id":1,"name":"Alice"}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
})
client := relay.New(relay.Config{Transport: mt})
var user struct {
ID int `json:"id"`
Name string `json:"name"`
}
_, err := client.R().SetResult(&user).GET(context.Background(), "/users/1")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("got %q, want Alice", user.Name)
}
}
Multiple sequential responses¶
mt := relay.NewMockTransport()
// First call returns 503
mt.Enqueue(&http.Response{StatusCode: 503, Body: http.NoBody})
// retry 1 - still 503
mt.Enqueue(&http.Response{StatusCode: 503, Body: http.NoBody})
// retry 2 - still 503
mt.Enqueue(&http.Response{StatusCode: 503, Body: http.NoBody})
client := relay.New(relay.Config{
Transport: mt,
MaxRetries: 3,
RetryableStatus: []int{503},
})
_, err := client.R().GET(context.Background(), "/flaky")
// err is non-nil after exhausting retries
Retry queuing
relay retries include the original attempt. MaxRetries: 3 means up to 3 total requests (1 original + 2 retries - depending on config). Enqueue enough responses for all attempts.
Asserting request details¶
mt := relay.NewMockTransport()
mt.Enqueue(&http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(`{}`)),
})
client := relay.New(relay.Config{Transport: mt})
client.R().
SetHeader("X-Api-Key", "secret").
GET(context.Background(), "/secure")
req := mt.LastRequest()
if req.Header.Get("X-Api-Key") != "secret" {
t.Error("expected API key header")
}
if mt.CallCount() != 1 {
t.Errorf("expected 1 call, got %d", mt.CallCount())
}
HTTPtest server¶
For integration-style tests that need real HTTP semantics, use Go's net/http/httptest:
func TestWithRealServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/42" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":42,"name":"Bob"}`))
}))
defer srv.Close()
client := relay.New(relay.Config{BaseURL: srv.URL})
var user struct {
ID int `json:"id"`
Name string `json:"name"`
}
_, err := client.R().SetResult(&user).GET(context.Background(), "/users/42")
if err != nil {
t.Fatal(err)
}
if user.ID != 42 {
t.Errorf("got id %d, want 42", user.ID)
}
}
Request recorder¶
The recorder captures all outgoing requests for later assertions without queuing responses. Combine it with a mock transport:
rec := relay.NewRecorder()
mt := relay.NewMockTransport()
mt.Enqueue(okResponse(`{"status":"ok"}`))
client := relay.New(relay.Config{
Transport: relay.ChainTransport(rec, mt),
})
client.R().
SetHeader("Authorization", "Bearer token123").
POST(context.Background(), "/v1/events", map[string]string{"type": "click"})
calls := rec.Calls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
body, _ := io.ReadAll(calls[0].Request.Body)
if !strings.Contains(string(body), "click") {
t.Error("expected body to contain 'click'")
}
Table-driven tests¶
func TestStatusHandling(t *testing.T) {
cases := []struct {
name string
statusCode int
wantErr bool
}{
{"ok", 200, false},
{"not found", 404, true},
{"server error", 500, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mt := relay.NewMockTransport()
mt.Enqueue(&http.Response{
StatusCode: tc.statusCode,
Body: http.NoBody,
})
client := relay.New(relay.Config{
Transport: mt,
MaxRetries: 0, // disable retries for determinism
})
_, err := client.R().GET(context.Background(), "/check")
if (err != nil) != tc.wantErr {
t.Errorf("wantErr=%v got err=%v", tc.wantErr, err)
}
})
}
}
Testing hooks¶
func TestOnRetryHook(t *testing.T) {
var attempts int
mt := relay.NewMockTransport()
mt.Enqueue(&http.Response{StatusCode: 500, Body: http.NoBody})
mt.Enqueue(&http.Response{StatusCode: 500, Body: http.NoBody})
mt.Enqueue(&http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{}`))})
client := relay.New(relay.Config{
Transport: mt,
MaxRetries: 3,
RetryableStatus: []int{500},
OnRetry: func(n int, _ *http.Request, _ *http.Response, _ error) {
attempts = n
},
})
client.R().GET(context.Background(), "/flaky")
if attempts != 2 {
t.Errorf("expected 2 retry callbacks, got %d", attempts)
}
}
Helper functions¶
// helpers_test.go
func okResponse(body string) *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
func errorResponse(code int) *http.Response {
return &http.Response{
StatusCode: code,
Body: http.NoBody,
}
}
See also¶
- Mock Transport extension - advanced mock patterns
- Error Handling
- Hooks & Middleware