Chapter 14: Function Variable Injection¶
Description¶
Store function references (json.Marshal, http.NewRequest, time.Now) as struct fields so tests can replace them without interfaces or mocking frameworks. Each function becomes a test seam: the production version is the real standard library call; the test version returns controlled values or records what was called.
Code¶
type WebhookPayload struct {
Timestamp time.Time `json:"timestamp"`
Data string `json:"data"`
Event string `json:"event"`
}
type WebhookSender struct {
httpNewRequest func(method, url string, body io.Reader) (*http.Request, error)
jsonMarshal func(v any) ([]byte, error)
client HTTPClient
Endpoint string
}
func NewWebhookSender(endpoint string) *WebhookSender {
return &WebhookSender{
Endpoint: endpoint,
client: &http.Client{},
jsonMarshal: json.Marshal,
httpNewRequest: http.NewRequest,
}
}
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
func (s *WebhookSender) Send(event, data string) error {
payload := WebhookPayload{
Event: event,
Data: data,
Timestamp: time.Now(),
}
body, err := s.jsonMarshal(payload)
if err != nil {
return fmt.Errorf("serializing payload: %w", err)
}
req, err := s.httpNewRequest(http.MethodPost, s.Endpoint, strings.NewReader(string(body)))
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return nil
}
Test¶
type mockHTTPClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
return m.DoFunc(req)
}
type checkSendFn func(*testing.T, error)
var checkSend = func(fns ...checkSendFn) []checkSendFn { return fns }
func TestWebhookSender_Send(t *testing.T) {
checkError := func(want string) checkSendFn {
return func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.Contains(t, err.Error(), want)
}
}
checkSuccess := func() checkSendFn {
return func(t *testing.T, err error) {
t.Helper()
assert.NoError(t, err)
}
}
tests := []struct {
name string
before func(*WebhookSender)
checks []checkSendFn
}{
{
name: "successful send",
before: func(s *WebhookSender) {
s.client = &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`ok`)),
}, nil
},
}
},
checks: checkSend(checkSuccess()),
},
{
name: "json marshal error",
before: func(s *WebhookSender) {
s.jsonMarshal = func(v any) ([]byte, error) {
return nil, fmt.Errorf("json: unexpected error")
}
},
checks: checkSend(checkError("serializing payload")),
},
{
name: "http new request error",
before: func(s *WebhookSender) {
s.httpNewRequest = func(method, url string, body io.Reader) (*http.Request, error) {
return nil, fmt.Errorf("invalid method")
}
},
checks: checkSend(checkError("creating request")),
},
{
name: "http client error",
before: func(s *WebhookSender) {
s.client = &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
},
checks: checkSend(checkError("sending request")),
},
{
name: "non-ok status",
before: func(s *WebhookSender) {
s.client = &mockHTTPClient{
DoFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusForbidden,
Body: io.NopCloser(strings.NewReader(`forbidden`)),
}, nil
},
}
},
checks: checkSend(checkError("unexpected status: 403")),
},
{
name: "nil hooks work with defaults",
before: nil,
checks: checkSend(checkError("connection refused")), // will actually error since no real server
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := NewWebhookSender("http://localhost:9999/webhook")
if tt.before != nil {
tt.before(s)
}
err := s.Send("user.created", `{"id":42}`)
for _, fn := range tt.checks {
fn(t, err)
}
})
}
}
func TestNewWebhookSender_Defaults(t *testing.T) {
s := NewWebhookSender("http://example.com")
assert.NotNil(t, s.client)
assert.NotNil(t, s.jsonMarshal)
assert.NotNil(t, s.httpNewRequest)
b, err := s.jsonMarshal(map[string]string{"a": "b"})
require.NoError(t, err)
assert.True(t, bytes.Contains(b, []byte(`"a":"b"`)))
}
Scaffold¶
Generate test scaffolding with go-testgen:
go-testgen report . --format table
go-testgen gen . Sender.Send
go-testgen gen . Sender.Defaults --style simple
Testing Approach¶
Function variable injection:
- No interfaces needed for functions — standard library functions (
json.Marshal,http.NewRequest) become struct fields with the same signature. Tests construct the struct with the constructor and override fields via before hooks. - Table-driven before hooks — the
beforefunction mutates the injected function fields (jsonMarshal,httpNewRequest,client) per case. One test function covers success plus every failure path (marshal, request creation, client send, non-OK status, defaults) without touching the network. - Error path injection —
jsonMarshal,httpNewRequest, orclientcan be swapped to return an error on demand. Testing the "marshal failed" path would require a malformed struct with realjson.Marshal; with injection, it's a one-line stub. - Standard assertions only — testify is used for plain
assert/requireinside typed check closures; the pattern itself is only closures and struct fields. No mock framework, no code generation, no capture-and-verify scaffolding. Works with any function you want to control in tests.
View source code on GitHub