Skip to content

Commit e598472

Browse files
authored
Merge pull request #72 from maxatome/v1
NewNotFoundResponder() stack trace reworked
2 parents 3acf212 + 1b0cf84 commit e598472

File tree

4 files changed

+354
-59
lines changed

4 files changed

+354
-59
lines changed

response.go

+69-36
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,73 @@ import (
44
"bytes"
55
"encoding/json"
66
"encoding/xml"
7-
"errors"
87
"fmt"
98
"io"
109
"net/http"
11-
"runtime"
1210
"strconv"
1311
"strings"
1412
)
1513

14+
// Responder is a callback that receives and http request and returns
15+
// a mocked response.
16+
type Responder func(*http.Request) (*http.Response, error)
17+
18+
func (r Responder) times(name string, n int, fn ...func(...interface{})) Responder {
19+
count := 0
20+
return func(req *http.Request) (*http.Response, error) {
21+
count++
22+
if count > n {
23+
err := stackTracer{
24+
err: fmt.Errorf("Responder not found for %s %s (coz %s and already called %d times)", req.Method, req.URL, name, count),
25+
}
26+
if len(fn) > 0 {
27+
err.customFn = fn[0]
28+
}
29+
return nil, err
30+
}
31+
return r(req)
32+
}
33+
}
34+
35+
// Times returns a Responder callable n times before returning an
36+
// error. If the Responder is called more than n times and fn is
37+
// passed and non-nil, it acts as the fn parameter of
38+
// NewNotFoundResponder, allowing to dump the stack trace to localize
39+
// the origin of the call.
40+
func (r Responder) Times(n int, fn ...func(...interface{})) Responder {
41+
return r.times("Times", n, fn...)
42+
}
43+
44+
// Once returns a new Responder callable once before returning an
45+
// error. If the Responder is called 2 or more times and fn is passed
46+
// and non-nil, it acts as the fn parameter of NewNotFoundResponder,
47+
// allowing to dump the stack trace to localize the origin of the
48+
// call.
49+
func (r Responder) Once(fn ...func(...interface{})) Responder {
50+
return r.times("Once", 1, fn...)
51+
}
52+
53+
// Trace returns a new Responder that allow to easily trace the calls
54+
// of the original Responder using fn. It can be used in conjunction
55+
// with the testing package as in the example below with the help of
56+
// (*testing.T).Log method:
57+
// import "testing"
58+
// ...
59+
// func TestMyApp(t *testing.T) {
60+
// ...
61+
// httpmock.RegisterResponder("GET", "/foo/bar",
62+
// httpmock.NewStringResponder(200, "{}").Trace(t.Log),
63+
// )
64+
func (r Responder) Trace(fn func(...interface{})) Responder {
65+
return func(req *http.Request) (*http.Response, error) {
66+
resp, err := r(req)
67+
return resp, stackTracer{
68+
customFn: fn,
69+
err: err,
70+
}
71+
}
72+
}
73+
1674
// ResponderFromResponse wraps an *http.Response in a Responder
1775
func ResponderFromResponse(resp *http.Response) Responder {
1876
return func(req *http.Request) (*http.Response, error) {
@@ -54,43 +112,18 @@ func NewErrorResponder(err error) Responder {
54112
// httpmock.RegisterNoResponder(httpmock.NewNotFoundResponder(t.Fatal))
55113
//
56114
// Will abort the current test and print something like:
57-
// response:69: Responder not found for: GET http://foo.bar/path
58-
// Called from goroutine 20 [running]:
59-
// github.com/jarcoal/httpmock.NewNotFoundResponder.func1(0xc00011f000, 0x0, 0x42dfb1, 0x77ece8)
60-
// /go/src/github.com/jarcoal/httpmock/response.go:67 +0x1c1
61-
// github.com/jarcoal/httpmock.runCancelable(0xc00004bfc0, 0xc00011f000, 0x7692f8, 0xc, 0xc0001208b0)
62-
// /go/src/github.com/jarcoal/httpmock/transport.go:146 +0x7e
63-
// github.com/jarcoal/httpmock.(*MockTransport).RoundTrip(0xc00005c980, 0xc00011f000, 0xc00005c980, 0x0, 0x0)
64-
// /go/src/github.com/jarcoal/httpmock/transport.go:140 +0x19d
65-
// net/http.send(0xc00011f000, 0x7d3440, 0xc00005c980, 0x0, 0x0, 0x0, 0xc000010400, 0xc000047bd8, 0x1, 0x0)
66-
// /usr/local/go/src/net/http/client.go:250 +0x461
67-
// net/http.(*Client).send(0x9f6e20, 0xc00011f000, 0x0, 0x0, 0x0, 0xc000010400, 0x0, 0x1, 0x9f7ac0)
68-
// /usr/local/go/src/net/http/client.go:174 +0xfb
69-
// net/http.(*Client).do(0x9f6e20, 0xc00011f000, 0x0, 0x0, 0x0)
70-
// /usr/local/go/src/net/http/client.go:641 +0x279
71-
// net/http.(*Client).Do(...)
72-
// /usr/local/go/src/net/http/client.go:509
73-
// net/http.(*Client).Get(0x9f6e20, 0xc00001e420, 0x23, 0xc00012c000, 0xb, 0x600)
74-
// /usr/local/go/src/net/http/client.go:398 +0x9e
75-
// net/http.Get(...)
76-
// /usr/local/go/src/net/http/client.go:370
77-
// foo.bar/foobar/foobar.TestMyApp(0xc00011e000)
78-
// /go/src/foo.bar/foobar/foobar/my_app_test.go:272 +0xdbb
79-
// testing.tRunner(0xc00011e000, 0x77e3a8)
80-
// /usr/local/go/src/testing/testing.go:865 +0xc0
81-
// created by testing.(*T).Run
82-
// /usr/local/go/src/testing/testing.go:916 +0x35a
115+
// transport_test.go:735: Called from net/http.Get()
116+
// at /go/src/github.com/jarcoal/httpmock/transport_test.go:714
117+
// github.com/jarcoal/httpmock.TestCheckStackTracer()
118+
// at /go/src/testing/testing.go:865
119+
// testing.tRunner()
120+
// at /go/src/runtime/asm_amd64.s:1337
83121
func NewNotFoundResponder(fn func(...interface{})) Responder {
84122
return func(req *http.Request) (*http.Response, error) {
85-
mesg := fmt.Sprintf("Responder not found for %s %s", req.Method, req.URL)
86-
if fn != nil {
87-
buf := make([]byte, 4096)
88-
n := runtime.Stack(buf, false)
89-
buf = buf[:n]
90-
fn(mesg + "\nCalled from " +
91-
strings.Replace(strings.TrimSuffix(string(buf), "\n"), "\n", "\n ", -1))
123+
return nil, stackTracer{
124+
customFn: fn,
125+
err: fmt.Errorf("Responder not found for %s %s", req.Method, req.URL),
92126
}
93-
return nil, errors.New(mesg)
94127
}
95128
}
96129

response_test.go

+106-17
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ import (
44
"encoding/json"
55
"encoding/xml"
66
"errors"
7-
"fmt"
87
"io/ioutil"
98
"net/http"
10-
"strings"
119
"testing"
1210
)
1311

@@ -51,10 +49,7 @@ func TestResponderFromResponse(t *testing.T) {
5149
}
5250

5351
func TestNewNotFoundResponder(t *testing.T) {
54-
var mesg string
55-
responder := NewNotFoundResponder(func(args ...interface{}) {
56-
mesg = fmt.Sprint(args[0])
57-
})
52+
responder := NewNotFoundResponder(func(args ...interface{}) {})
5853

5954
req, err := http.NewRequest("GET", "http://foo.bar/path", nil)
6055
if err != nil {
@@ -71,15 +66,11 @@ func TestNewNotFoundResponder(t *testing.T) {
7166
t.Error("err should be not nil")
7267
} else if err.Error() != title {
7368
t.Errorf(`err mismatch, got: "%s", expected: "%s"`,
74-
err.Error(),
75-
"Responder not found for: GET http://foo.bar/path")
76-
}
77-
78-
if !strings.HasPrefix(mesg, title+"\nCalled from ") {
79-
t.Error(`mesg should begin with "` + title + `\nCalled from ", but it is: "` + mesg + `"`)
80-
}
81-
if strings.HasSuffix(mesg, "\n") {
82-
t.Error(`mesg should not end with \n, but it is: "` + mesg + `"`)
69+
err, "Responder not found for: GET http://foo.bar/path")
70+
} else if ne, ok := err.(stackTracer); !ok {
71+
t.Errorf(`err type mismatch, got %T, expected httpmock.notFound`, err)
72+
} else if ne.customFn == nil {
73+
t.Error(`err customFn mismatch, got: nil, expected: non-nil`)
8374
}
8475

8576
// nil fn
@@ -93,8 +84,11 @@ func TestNewNotFoundResponder(t *testing.T) {
9384
t.Error("err should be not nil")
9485
} else if err.Error() != title {
9586
t.Errorf(`err mismatch, got: "%s", expected: "%s"`,
96-
err.Error(),
97-
"Responder not found for: GET http://foo.bar/path")
87+
err, "Responder not found for: GET http://foo.bar/path")
88+
} else if ne, ok := err.(stackTracer); !ok {
89+
t.Errorf(`err type mismatch, got %T, expected httpmock.notFound`, err)
90+
} else if ne.customFn != nil {
91+
t.Errorf(`err customFn mismatch, got: %p, expected: nil`, ne.customFn)
9892
}
9993
}
10094

@@ -252,3 +246,98 @@ func TestRewindResponse(t *testing.T) {
252246
}
253247
}
254248
}
249+
250+
func TestResponder(t *testing.T) {
251+
req, err := http.NewRequest(http.MethodGet, "http://foo.bar", nil)
252+
if err != nil {
253+
t.Fatal("Error creating request")
254+
}
255+
resp := &http.Response{}
256+
257+
chk := func(r Responder, expectedResp *http.Response, expectedErr string) {
258+
//t.Helper // Only available since 1.9
259+
gotResp, gotErr := r(req)
260+
if gotResp != expectedResp {
261+
t.Errorf(`Response mismatch, expected: %v, got: %v`, expectedResp, gotResp)
262+
}
263+
var gotErrStr string
264+
if gotErr != nil {
265+
gotErrStr = gotErr.Error()
266+
}
267+
if gotErrStr != expectedErr {
268+
t.Errorf(`Error mismatch, expected: %v, got: %v`, expectedErr, gotErrStr)
269+
}
270+
}
271+
called := false
272+
chkNotCalled := func() {
273+
if called {
274+
//t.Helper // Only available since 1.9
275+
t.Errorf("Original responder should not be called")
276+
called = false
277+
}
278+
}
279+
chkCalled := func() {
280+
if !called {
281+
//t.Helper // Only available since 1.9
282+
t.Errorf("Original responder should be called")
283+
}
284+
called = false
285+
}
286+
287+
r := Responder(func(*http.Request) (*http.Response, error) {
288+
called = true
289+
return resp, nil
290+
})
291+
chk(r, resp, "")
292+
chkCalled()
293+
294+
//
295+
// Once
296+
ro := r.Once()
297+
chk(ro, resp, "")
298+
chkCalled()
299+
300+
chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 2 times)")
301+
chkNotCalled()
302+
303+
chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 3 times)")
304+
chkNotCalled()
305+
306+
ro = r.Once(func(args ...interface{}) {})
307+
chk(ro, resp, "")
308+
chkCalled()
309+
310+
chk(ro, nil, "Responder not found for GET http://foo.bar (coz Once and already called 2 times)")
311+
chkNotCalled()
312+
313+
//
314+
// Times
315+
rt := r.Times(2)
316+
chk(rt, resp, "")
317+
chkCalled()
318+
319+
chk(rt, resp, "")
320+
chkCalled()
321+
322+
chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 3 times)")
323+
chkNotCalled()
324+
325+
chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 4 times)")
326+
chkNotCalled()
327+
328+
rt = r.Times(1, func(args ...interface{}) {})
329+
chk(rt, resp, "")
330+
chkCalled()
331+
332+
chk(rt, nil, "Responder not found for GET http://foo.bar (coz Times and already called 2 times)")
333+
chkNotCalled()
334+
335+
//
336+
// Trace
337+
rt = r.Trace(func(args ...interface{}) {})
338+
chk(rt, resp, "")
339+
chkCalled()
340+
341+
chk(rt, resp, "")
342+
chkCalled()
343+
}

transport.go

+80-6
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@ import (
66
"fmt"
77
"net/http"
88
"net/url"
9+
"runtime"
910
"sort"
1011
"strings"
1112
"sync"
1213
)
1314

14-
// Responder is a callback that receives and http request and returns
15-
// a mocked response.
16-
type Responder func(*http.Request) (*http.Response, error)
17-
1815
// NoResponderFound is returned when no responders are found for a given HTTP method and URL.
1916
var NoResponderFound = errors.New("no responder found") // nolint: golint
2017

@@ -143,7 +140,8 @@ func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
143140
func runCancelable(responder Responder, req *http.Request) (*http.Response, error) {
144141
ctx := req.Context()
145142
if req.Cancel == nil && ctx.Done() == nil { // nolint: staticcheck
146-
return responder(req)
143+
resp, err := responder(req)
144+
return resp, checkStackTracer(req, err)
147145
}
148146

149147
// Set up a goroutine that translates a close(req.Cancel) into a
@@ -197,7 +195,83 @@ func runCancelable(responder Responder, req *http.Request) (*http.Response, erro
197195
// first goroutine.
198196
done <- struct{}{}
199197

200-
return r.response, r.err
198+
return r.response, checkStackTracer(req, r.err)
199+
}
200+
201+
type stackTracer struct {
202+
customFn func(...interface{})
203+
err error
204+
}
205+
206+
func (n stackTracer) Error() string {
207+
if n.err == nil {
208+
return ""
209+
}
210+
return n.err.Error()
211+
}
212+
213+
// checkStackTracer checks for specific error returned by
214+
// NewNotFoundResponder function or Debug Responder method.
215+
func checkStackTracer(req *http.Request, err error) error {
216+
if nf, ok := err.(stackTracer); ok {
217+
if nf.customFn != nil {
218+
pc := make([]uintptr, 128)
219+
npc := runtime.Callers(2, pc)
220+
pc = pc[:npc]
221+
222+
var mesg bytes.Buffer
223+
var netHTTPBegin, netHTTPEnd bool
224+
225+
// Start recording at first net/http call if any...
226+
for {
227+
frames := runtime.CallersFrames(pc)
228+
229+
var lastFn string
230+
for {
231+
frame, more := frames.Next()
232+
233+
if !netHTTPEnd {
234+
if netHTTPBegin {
235+
netHTTPEnd = !strings.HasPrefix(frame.Function, "net/http.")
236+
} else {
237+
netHTTPBegin = strings.HasPrefix(frame.Function, "net/http.")
238+
}
239+
}
240+
241+
if netHTTPEnd {
242+
if lastFn != "" {
243+
if mesg.Len() == 0 {
244+
if nf.err != nil {
245+
mesg.WriteString(nf.err.Error())
246+
} else {
247+
fmt.Fprintf(&mesg, "%s %s", req.Method, req.URL)
248+
}
249+
mesg.WriteString("\nCalled from ")
250+
} else {
251+
mesg.WriteString("\n ")
252+
}
253+
fmt.Fprintf(&mesg, "%s()\n at %s:%d", lastFn, frame.File, frame.Line)
254+
}
255+
}
256+
lastFn = frame.Function
257+
258+
if !more {
259+
break
260+
}
261+
}
262+
263+
// At least one net/http frame found
264+
if mesg.Len() > 0 {
265+
break
266+
}
267+
netHTTPEnd = true // retry without looking at net/http frames
268+
}
269+
270+
nf.customFn(mesg.String())
271+
}
272+
err = nf.err
273+
}
274+
return err
201275
}
202276

203277
// responderForKey returns a responder for a given key.

0 commit comments

Comments
 (0)