From 37c1c76b01311b223484604b63a52f0eb2b44739 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sun, 11 Aug 2024 11:39:03 -0700 Subject: [PATCH 1/7] Add server-side recording and test replay - Use a server-side middleware to record requests from an external client and record the server's response - Replay these recorded interactions to test that the server produces the same responses --- README.md | 12 +++++ examples/fixtures/middleware.yaml | 77 +++++++++++++++++++++++++++++ examples/middleware_test.go | 60 ++++++++++++++++++++++ pkg/cassette/server_replay.go | 82 +++++++++++++++++++++++++++++++ pkg/recorder/middleware.go | 54 ++++++++++++++++++++ pkg/recorder/recorder.go | 20 ++++++-- 6 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 examples/fixtures/middleware.yaml create mode 100644 examples/middleware_test.go create mode 100644 pkg/cassette/server_replay.go create mode 100644 pkg/recorder/middleware.go diff --git a/README.md b/README.md index da0e1b2..2073620 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,18 @@ defer r.Stop() // Make sure recorder is stopped once done with it ... ``` +## Server Side + +VCR testing can also be used for creating server-side tests. Use the +`recorder.Middleware` with an HTTP handler in order to create fixtures from +incoming requests and the handler's responses. Then, these requests can be +replayed and compared against the recorded responses to create a regression test. + +Rather than mocking/recording external HTTP interactions, this will record and +replay _incoming_ interactions with your application's HTTP server. + +See [an example here](./examples/middleware_test.go). + ## License `go-vcr` is Open Source and licensed under the [BSD diff --git a/examples/fixtures/middleware.yaml b/examples/fixtures/middleware.yaml new file mode 100644 index 0000000..9f93e20 --- /dev/null +++ b/examples/fixtures/middleware.yaml @@ -0,0 +1,77 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: 127.0.0.1:58027 + remote_addr: 127.0.0.1:58028 + request_uri: /request1 + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + User-Agent: + - Go-http-client/1.1 + url: http://go-vcr/request1 + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: OK + headers: + Content-Type: + - text/plain; charset=utf-8 + Key: + - VALUE + status: 200 OK + code: 200 + duration: 83ns + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: 127.0.0.1:58027 + remote_addr: 127.0.0.1:58029 + request_uri: /request2 + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + User-Agent: + - Go-http-client/1.1 + url: http://go-vcr/request2 + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: OK + headers: + Content-Type: + - text/plain; charset=utf-8 + Key: + - VALUE + status: 200 OK + code: 200 + duration: 125ns diff --git a/examples/middleware_test.go b/examples/middleware_test.go new file mode 100644 index 0000000..7773f1f --- /dev/null +++ b/examples/middleware_test.go @@ -0,0 +1,60 @@ +package vcr_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gopkg.in/dnaeon/go-vcr.v3/cassette" + "gopkg.in/dnaeon/go-vcr.v3/recorder" +) + +func TestMiddleware(t *testing.T) { + cassetteName := "fixtures/middleware" + createHandler := func(middleware func(http.Handler) http.Handler) http.Handler { + mux := http.NewServeMux() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("KEY", "VALUE") + w.Write([]byte("OK")) + }) + + if middleware != nil { + handler = middleware(handler).ServeHTTP + } + + mux.Handle("/", handler) + return mux + } + + t.Run("RecordRealInteractionsWithMiddleware", func(t *testing.T) { + recorder, err := recorder.NewWithOptions(&recorder.Options{ + CassetteName: cassetteName, + Mode: recorder.ModeRecordOnly, + }) + if err != nil { + t.Errorf("error creating recorder: %v", err) + } + + // Create the server handler with recorder middleware + handler := createHandler(recorder.Middleware) + defer recorder.Stop() + + server := httptest.NewServer(handler) + defer server.Close() + + _, err = http.Get(server.URL + "/request1") + if err != nil { + t.Errorf("error making request: %v", err) + } + + _, err = http.Get(server.URL + "/request2") + if err != nil { + t.Errorf("error making request: %v", err) + } + }) + + t.Run("ReplayCassetteAndCompare", func(t *testing.T) { + cassette.TestServerReplay(t, cassetteName, createHandler(nil)) + }) +} diff --git a/pkg/cassette/server_replay.go b/pkg/cassette/server_replay.go new file mode 100644 index 0000000..829f198 --- /dev/null +++ b/pkg/cassette/server_replay.go @@ -0,0 +1,82 @@ +package cassette + +import ( + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" +) + +// TestServerReplay loads a Cassette and replays each Interaction with the provided Handler, then compares the response +func TestServerReplay(t *testing.T, cassetteName string, handler http.Handler) { + t.Helper() + + c, err := Load(cassetteName) + if err != nil { + t.Errorf("unexpected error loading Cassette: %v", err) + } + + if len(c.Interactions) == 0 { + t.Error("no interactions in Cassette") + } + + for _, interaction := range c.Interactions { + t.Run(fmt.Sprintf("Interaction_%d", interaction.ID), func(t *testing.T) { + TestInteractionReplay(t, handler, interaction) + }) + } +} + +// TestInteractionReplay replays an Interaction with the provided Handler and compares the response +func TestInteractionReplay(t *testing.T, handler http.Handler, interaction *Interaction) { + t.Helper() + + req, err := interaction.GetHTTPRequest() + if err != nil { + t.Errorf("unexpected error getting interaction request: %v", err) + } + + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + expectedResp, err := interaction.GetHTTPResponse() + if err != nil { + t.Errorf("unexpected error getting interaction response: %v", err) + } + + if expectedResp.StatusCode != w.Result().StatusCode { + t.Errorf("status code does not match: expected=%d actual=%d", expectedResp.StatusCode, w.Result().StatusCode) + } + + expectedBody, err := io.ReadAll(expectedResp.Body) + if err != nil { + t.Errorf("unexpected reading response body: %v", err) + } + + if string(expectedBody) != w.Body.String() { + t.Errorf("body does not match: expected=%s actual=%s", expectedBody, w.Body.String()) + } + + if !headersEqual(expectedResp.Header, w.Header()) { + t.Errorf("header values do not match. expected=%v actual=%v", expectedResp.Header, w.Header()) + } +} + +func headersEqual(expected, actual http.Header) bool { + return maps.EqualFunc( + expected, actual, + func(v1, v2 []string) bool { + slices.Sort(v1) + slices.Sort(v2) + + if !slices.Equal(v1, v2) { + return false + } + + return true + }, + ) +} diff --git a/pkg/recorder/middleware.go b/pkg/recorder/middleware.go new file mode 100644 index 0000000..ac66bad --- /dev/null +++ b/pkg/recorder/middleware.go @@ -0,0 +1,54 @@ +package recorder + +import ( + "net/http" + "net/http/httptest" +) + +// Middleware intercepts and records all incoming requests and the server's response +func (rec *Recorder) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := newPassthrough(w) + next.ServeHTTP(ww, r) + + // On the server side, requests do not have Host and Scheme so it must be set + r.URL.Host = "go-vcr" + r.URL.Scheme = "http" + + // copy headers from real response + for k, vv := range ww.real.Header() { + for _, v := range vv { + ww.recorder.Result().Header.Add(k, v) + } + } + + _, _ = rec.executeAndRecord(r, ww.recorder.Result()) + }) +} + +var _ http.ResponseWriter = &passthroughWriter{} + +// passthroughWriter uses the original ResponseWriter and an httptest.ResponseRecorder +// so the middleware can capture response details and passthrough to the client +type passthroughWriter struct { + recorder *httptest.ResponseRecorder + real http.ResponseWriter +} + +func newPassthrough(real http.ResponseWriter) passthroughWriter { + return passthroughWriter{recorder: httptest.NewRecorder(), real: real} +} + +func (p passthroughWriter) Header() http.Header { + return p.real.Header() +} + +func (p passthroughWriter) Write(in []byte) (int, error) { + _, _ = p.recorder.Write(in) + return p.real.Write(in) +} + +func (p passthroughWriter) WriteHeader(statusCode int) { + p.recorder.WriteHeader(statusCode) + p.real.WriteHeader(statusCode) +} diff --git a/pkg/recorder/recorder.go b/pkg/recorder/recorder.go index 9a74395..95cbd5d 100644 --- a/pkg/recorder/recorder.go +++ b/pkg/recorder/recorder.go @@ -385,7 +385,8 @@ func (rec *Recorder) getRoundTripper() http.RoundTripper { } // requestHandler proxies requests to their original destination -func (rec *Recorder) requestHandler(r *http.Request) (*cassette.Interaction, error) { +// If serverResponse is provided, this is used for the recording instead of using RoundTrip +func (rec *Recorder) requestHandler(r *http.Request, serverResponse *http.Response) (*cassette.Interaction, error) { if err := r.Context().Err(); err != nil { return nil, err } @@ -455,11 +456,15 @@ func (rec *Recorder) requestHandler(r *http.Request) (*cassette.Interaction, err } // Perform request to it's original destination and record the interactions + // If serverResponse is provided, use it instead var start time.Time start = time.Now() - resp, err := rec.getRoundTripper().RoundTrip(r) - if err != nil { - return nil, err + resp := serverResponse + if resp == nil { + resp, err = rec.getRoundTripper().RoundTrip(r) + if err != nil { + return nil, err + } } requestDuration := time.Since(start) defer resp.Body.Close() @@ -573,6 +578,11 @@ func (rec *Recorder) applyHooks(i *cassette.Interaction, kind HookKind) error { // RoundTrip implements the [http.RoundTripper] interface func (rec *Recorder) RoundTrip(req *http.Request) (*http.Response, error) { + return rec.executeAndRecord(req, nil) +} + +// executeAndRecord is used internally by the Middleware to allow recording a response on the server side +func (rec *Recorder) executeAndRecord(req *http.Request, serverResponse *http.Response) (*http.Response, error) { // Passthrough mode, use real transport if rec.mode == ModePassthrough { return rec.getRoundTripper().RoundTrip(req) @@ -585,7 +595,7 @@ func (rec *Recorder) RoundTrip(req *http.Request) (*http.Response, error) { } } - interaction, err := rec.requestHandler(req) + interaction, err := rec.requestHandler(req, serverResponse) if err != nil { return nil, err } From 64989688c6d4e9c291ae7a0e834967caecf72b02 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sun, 11 Aug 2024 22:25:30 -0700 Subject: [PATCH 2/7] Fix request body handling --- pkg/cassette/server_replay.go | 5 +++++ pkg/recorder/middleware.go | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/pkg/cassette/server_replay.go b/pkg/cassette/server_replay.go index 829f198..5853a83 100644 --- a/pkg/cassette/server_replay.go +++ b/pkg/cassette/server_replay.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "slices" + "strings" "testing" ) @@ -39,6 +40,10 @@ func TestInteractionReplay(t *testing.T, handler http.Handler, interaction *Inte t.Errorf("unexpected error getting interaction request: %v", err) } + if len(req.Form) > 0 { + req.Body = io.NopCloser(strings.NewReader(req.Form.Encode())) + } + w := httptest.NewRecorder() handler.ServeHTTP(w, req) diff --git a/pkg/recorder/middleware.go b/pkg/recorder/middleware.go index ac66bad..34cc18a 100644 --- a/pkg/recorder/middleware.go +++ b/pkg/recorder/middleware.go @@ -1,6 +1,8 @@ package recorder import ( + "bytes" + "io" "net/http" "net/http/httptest" ) @@ -9,8 +11,15 @@ import ( func (rec *Recorder) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := newPassthrough(w) + + // Tee the body so it can be read by the next handler and by the recorder + body := &bytes.Buffer{} + r.Body = io.NopCloser(io.TeeReader(r.Body, body)) + next.ServeHTTP(ww, r) + r.Body = io.NopCloser(body) + // On the server side, requests do not have Host and Scheme so it must be set r.URL.Host = "go-vcr" r.URL.Scheme = "http" From afe4d0d1b5944dc3ab8ae9e6f52454e5e2cdee96 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sun, 11 Aug 2024 23:05:11 -0700 Subject: [PATCH 3/7] Fix matching of request form --- pkg/cassette/cassette.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/cassette/cassette.go b/pkg/cassette/cassette.go index f04dca1..bd3abfa 100644 --- a/pkg/cassette/cassette.go +++ b/pkg/cassette/cassette.go @@ -324,6 +324,13 @@ func (m *defaultMatcher) matcher(r *http.Request, i Request) bool { return false } + // Only ParseForm for non-GET requests since that would use query params + if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch { + err := r.ParseForm() + if err != nil { + return false + } + } if !m.deepEqualContents(r.Form, i.Form) { return false } From e39e60682518d4720f10754d76c731a5df9daeb4 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Mon, 12 Aug 2024 16:24:44 -0700 Subject: [PATCH 4/7] Fix middleware for requests with bodies --- examples/fixtures/middleware.yaml | 94 +++++++++++++++++++++++++++++-- examples/middleware_test.go | 28 ++++++++- pkg/recorder/recorder.go | 5 ++ 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/examples/fixtures/middleware.yaml b/examples/fixtures/middleware.yaml index 9f93e20..1cc0e0a 100644 --- a/examples/fixtures/middleware.yaml +++ b/examples/fixtures/middleware.yaml @@ -9,8 +9,8 @@ interactions: content_length: 0 transfer_encoding: [] trailer: {} - host: 127.0.0.1:58027 - remote_addr: 127.0.0.1:58028 + host: 127.0.0.1:60761 + remote_addr: 127.0.0.1:60762 request_uri: /request1 body: "" form: {} @@ -37,7 +37,7 @@ interactions: - VALUE status: 200 OK code: 200 - duration: 83ns + duration: 42ns - id: 1 request: proto: HTTP/1.1 @@ -46,8 +46,8 @@ interactions: content_length: 0 transfer_encoding: [] trailer: {} - host: 127.0.0.1:58027 - remote_addr: 127.0.0.1:58029 + host: 127.0.0.1:60761 + remote_addr: 127.0.0.1:60763 request_uri: /request2 body: "" form: {} @@ -74,4 +74,88 @@ interactions: - VALUE status: 200 OK code: 200 + duration: 41ns + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 9 + transfer_encoding: [] + trailer: {} + host: 127.0.0.1:60761 + remote_addr: 127.0.0.1:60764 + request_uri: /postform + body: key=value + form: + key: + - value + headers: + Accept-Encoding: + - gzip + Content-Length: + - "9" + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - Go-http-client/1.1 + url: http://go-vcr/postform + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: key=value + headers: + Content-Type: + - text/plain; charset=utf-8 + Key: + - VALUE + status: 200 OK + code: 200 + duration: 375ns + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 15 + transfer_encoding: [] + trailer: {} + host: 127.0.0.1:60761 + remote_addr: 127.0.0.1:60765 + request_uri: /postdata + body: '{"key":"value"}' + form: {} + headers: + Accept-Encoding: + - gzip + Content-Length: + - "15" + Content-Type: + - application/json + User-Agent: + - Go-http-client/1.1 + url: http://go-vcr/postdata + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"key":"value"}' + headers: + Content-Type: + - text/plain; charset=utf-8 + Key: + - VALUE + status: 200 OK + code: 200 duration: 125ns diff --git a/examples/middleware_test.go b/examples/middleware_test.go index 7773f1f..17a0ec5 100644 --- a/examples/middleware_test.go +++ b/examples/middleware_test.go @@ -1,8 +1,11 @@ package vcr_test import ( + "bytes" + "io" "net/http" "net/http/httptest" + "net/url" "testing" "gopkg.in/dnaeon/go-vcr.v3/cassette" @@ -16,7 +19,13 @@ func TestMiddleware(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("KEY", "VALUE") - w.Write([]byte("OK")) + + body, _ := io.ReadAll(r.Body) + if len(body) > 0 { + w.Write(body) + } else { + w.Write([]byte("OK")) + } }) if middleware != nil { @@ -27,10 +36,13 @@ func TestMiddleware(t *testing.T) { return mux } + // In a real-world scenario, the recorder will run outside of unit tests + // since you want to be able to record real application behavior t.Run("RecordRealInteractionsWithMiddleware", func(t *testing.T) { recorder, err := recorder.NewWithOptions(&recorder.Options{ - CassetteName: cassetteName, - Mode: recorder.ModeRecordOnly, + CassetteName: cassetteName, + Mode: recorder.ModeRecordOnly, + BlockRealTransportUnsafeMethods: false, }) if err != nil { t.Errorf("error creating recorder: %v", err) @@ -52,6 +64,16 @@ func TestMiddleware(t *testing.T) { if err != nil { t.Errorf("error making request: %v", err) } + + _, err = http.PostForm(server.URL+"/postform", url.Values{"key": []string{"value"}}) + if err != nil { + t.Errorf("error making request: %v", err) + } + + _, err = http.Post(server.URL+"/postdata", "application/json", bytes.NewBufferString(`{"key":"value"}`)) + if err != nil { + t.Errorf("error making request: %v", err) + } }) t.Run("ReplayCassetteAndCompare", func(t *testing.T) { diff --git a/pkg/recorder/recorder.go b/pkg/recorder/recorder.go index 95cbd5d..1a614c0 100644 --- a/pkg/recorder/recorder.go +++ b/pkg/recorder/recorder.go @@ -453,6 +453,11 @@ func (rec *Recorder) requestHandler(r *http.Request, serverResponse *http.Respon if r.Body != nil && r.Body != http.NoBody { // Record the request body so we can add it to the cassette r.Body = io.NopCloser(io.TeeReader(r.Body, reqBody)) + if serverResponse != nil { + // when serverResponse is provided by middleware, it has to be read in order + // for reqBody buffer to be populated + _, _ = io.ReadAll(r.Body) + } } // Perform request to it's original destination and record the interactions From b766d74b1174c5e2e38a375e1c2bb6e9c4ec0fe5 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sat, 17 Aug 2024 16:51:45 -0700 Subject: [PATCH 5/7] Improve example test for middleware --- examples/fixtures/middleware.yaml | 32 +++++++------- examples/middleware_test.go | 72 +++++++++++++++++++------------ 2 files changed, 61 insertions(+), 43 deletions(-) diff --git a/examples/fixtures/middleware.yaml b/examples/fixtures/middleware.yaml index 1cc0e0a..29ef1ef 100644 --- a/examples/fixtures/middleware.yaml +++ b/examples/fixtures/middleware.yaml @@ -9,8 +9,8 @@ interactions: content_length: 0 transfer_encoding: [] trailer: {} - host: 127.0.0.1:60761 - remote_addr: 127.0.0.1:60762 + host: "" + remote_addr: "" request_uri: /request1 body: "" form: {} @@ -37,7 +37,7 @@ interactions: - VALUE status: 200 OK code: 200 - duration: 42ns + duration: 0s - id: 1 request: proto: HTTP/1.1 @@ -46,9 +46,9 @@ interactions: content_length: 0 transfer_encoding: [] trailer: {} - host: 127.0.0.1:60761 - remote_addr: 127.0.0.1:60763 - request_uri: /request2 + host: "" + remote_addr: "" + request_uri: /request2?query=example body: "" form: {} headers: @@ -56,7 +56,7 @@ interactions: - gzip User-Agent: - Go-http-client/1.1 - url: http://go-vcr/request2 + url: http://go-vcr/request2?query=example method: GET response: proto: HTTP/1.1 @@ -66,7 +66,9 @@ interactions: trailer: {} content_length: -1 uncompressed: false - body: OK + body: |- + query=example + OK headers: Content-Type: - text/plain; charset=utf-8 @@ -74,7 +76,7 @@ interactions: - VALUE status: 200 OK code: 200 - duration: 41ns + duration: 0s - id: 2 request: proto: HTTP/1.1 @@ -83,8 +85,8 @@ interactions: content_length: 9 transfer_encoding: [] trailer: {} - host: 127.0.0.1:60761 - remote_addr: 127.0.0.1:60764 + host: "" + remote_addr: "" request_uri: /postform body: key=value form: @@ -117,7 +119,7 @@ interactions: - VALUE status: 200 OK code: 200 - duration: 375ns + duration: 0s - id: 3 request: proto: HTTP/1.1 @@ -126,8 +128,8 @@ interactions: content_length: 15 transfer_encoding: [] trailer: {} - host: 127.0.0.1:60761 - remote_addr: 127.0.0.1:60765 + host: "" + remote_addr: "" request_uri: /postdata body: '{"key":"value"}' form: {} @@ -158,4 +160,4 @@ interactions: - VALUE status: 200 OK code: 200 - duration: 125ns + duration: 0s diff --git a/examples/middleware_test.go b/examples/middleware_test.go index 17a0ec5..8a1c15d 100644 --- a/examples/middleware_test.go +++ b/examples/middleware_test.go @@ -14,43 +14,31 @@ import ( func TestMiddleware(t *testing.T) { cassetteName := "fixtures/middleware" - createHandler := func(middleware func(http.Handler) http.Handler) http.Handler { - mux := http.NewServeMux() - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("KEY", "VALUE") - - body, _ := io.ReadAll(r.Body) - if len(body) > 0 { - w.Write(body) - } else { - w.Write([]byte("OK")) - } - }) - - if middleware != nil { - handler = middleware(handler).ServeHTTP - } - - mux.Handle("/", handler) - return mux - } // In a real-world scenario, the recorder will run outside of unit tests // since you want to be able to record real application behavior t.Run("RecordRealInteractionsWithMiddleware", func(t *testing.T) { - recorder, err := recorder.NewWithOptions(&recorder.Options{ - CassetteName: cassetteName, - Mode: recorder.ModeRecordOnly, - BlockRealTransportUnsafeMethods: false, + rec, err := recorder.NewWithOptions(&recorder.Options{ + CassetteName: cassetteName, + Mode: recorder.ModeRecordOnly, + SkipRequestLatency: true, }) if err != nil { t.Errorf("error creating recorder: %v", err) } + // Use a BeforeSaveHook to remove host, remote_addr, and duration + // since they change whenever the test runs + rec.AddHook(func(i *cassette.Interaction) error { + i.Request.Host = "" + i.Request.RemoteAddr = "" + i.Response.Duration = 0 + return nil + }, recorder.BeforeSaveHook) + // Create the server handler with recorder middleware - handler := createHandler(recorder.Middleware) - defer recorder.Stop() + handler := createHandler(rec.Middleware) + defer rec.Stop() server := httptest.NewServer(handler) defer server.Close() @@ -60,7 +48,7 @@ func TestMiddleware(t *testing.T) { t.Errorf("error making request: %v", err) } - _, err = http.Get(server.URL + "/request2") + _, err = http.Get(server.URL + "/request2?query=example") if err != nil { t.Errorf("error making request: %v", err) } @@ -80,3 +68,31 @@ func TestMiddleware(t *testing.T) { cassette.TestServerReplay(t, cassetteName, createHandler(nil)) }) } + +// createHandler will return an HTTP handler with optional middleware. It will respond to +// simple requests for testing +func createHandler(middleware func(http.Handler) http.Handler) http.Handler { + mux := http.NewServeMux() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("KEY", "VALUE") + + query := r.URL.Query().Encode() + if query != "" { + w.Write([]byte(query + "\n")) + } + + body, _ := io.ReadAll(r.Body) + if len(body) > 0 { + w.Write(body) + } else { + w.Write([]byte("OK")) + } + }) + + if middleware != nil { + handler = middleware(handler).ServeHTTP + } + + mux.Handle("/", handler) + return mux +} From 4e9b52ed4ad9886918fdb4781943b7a5a167fc3f Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sat, 17 Aug 2024 17:30:16 -0700 Subject: [PATCH 6/7] Allow custom assertions with ReplayAssertFunc --- pkg/cassette/server_replay.go | 42 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/cassette/server_replay.go b/pkg/cassette/server_replay.go index 5853a83..a18908d 100644 --- a/pkg/cassette/server_replay.go +++ b/pkg/cassette/server_replay.go @@ -11,6 +11,26 @@ import ( "testing" ) +// ReplayAssertFunc is used to assert the results of replaying a recorded request against a handler. +// It receives the current Interaction and the httptest.ResponseRecorder. +type ReplayAssertFunc func(t *testing.T, expected *Interaction, actual *httptest.ResponseRecorder) + +// DefaultReplayAssertFunc compares the response status code, body, and headers. +// It can be overridden for more specific tests or to use your preferred assertion libraries +var DefaultReplayAssertFunc ReplayAssertFunc = func(t *testing.T, expected *Interaction, actual *httptest.ResponseRecorder) { + if expected.Response.Code != actual.Result().StatusCode { + t.Errorf("status code does not match: expected=%d actual=%d", expected.Response.Code, actual.Result().StatusCode) + } + + if expected.Response.Body != actual.Body.String() { + t.Errorf("body does not match: expected=%s actual=%s", expected.Response.Body, actual.Body.String()) + } + + if !headersEqual(expected.Response.Headers, actual.Header()) { + t.Errorf("header values do not match. expected=%v actual=%v", expected.Response.Headers, actual.Header()) + } +} + // TestServerReplay loads a Cassette and replays each Interaction with the provided Handler, then compares the response func TestServerReplay(t *testing.T, cassetteName string, handler http.Handler) { t.Helper() @@ -47,27 +67,7 @@ func TestInteractionReplay(t *testing.T, handler http.Handler, interaction *Inte w := httptest.NewRecorder() handler.ServeHTTP(w, req) - expectedResp, err := interaction.GetHTTPResponse() - if err != nil { - t.Errorf("unexpected error getting interaction response: %v", err) - } - - if expectedResp.StatusCode != w.Result().StatusCode { - t.Errorf("status code does not match: expected=%d actual=%d", expectedResp.StatusCode, w.Result().StatusCode) - } - - expectedBody, err := io.ReadAll(expectedResp.Body) - if err != nil { - t.Errorf("unexpected reading response body: %v", err) - } - - if string(expectedBody) != w.Body.String() { - t.Errorf("body does not match: expected=%s actual=%s", expectedBody, w.Body.String()) - } - - if !headersEqual(expectedResp.Header, w.Header()) { - t.Errorf("header values do not match. expected=%v actual=%v", expectedResp.Header, w.Header()) - } + DefaultReplayAssertFunc(t, interaction, w) } func headersEqual(expected, actual http.Header) bool { From 5ae4774e1b3ab0b1f647c0f9da8ea10267fd8ad5 Mon Sep 17 00:00:00 2001 From: Calvin McLean Date: Sun, 18 Aug 2024 09:30:05 -0700 Subject: [PATCH 7/7] Rename HTTPMiddleware and fix tests --- README.md | 2 +- examples/middleware_test.go | 32 +++++++++++++++----------------- pkg/recorder/middleware.go | 4 ++-- pkg/recorder/recorder.go | 2 +- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2073620..f2aa4a0 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ defer r.Stop() // Make sure recorder is stopped once done with it ## Server Side VCR testing can also be used for creating server-side tests. Use the -`recorder.Middleware` with an HTTP handler in order to create fixtures from +`recorder.HTTPMiddleware` with an HTTP handler in order to create fixtures from incoming requests and the handler's responses. Then, these requests can be replayed and compared against the recorded responses to create a regression test. diff --git a/examples/middleware_test.go b/examples/middleware_test.go index 8a1c15d..f3f85d8 100644 --- a/examples/middleware_test.go +++ b/examples/middleware_test.go @@ -8,8 +8,8 @@ import ( "net/url" "testing" - "gopkg.in/dnaeon/go-vcr.v3/cassette" - "gopkg.in/dnaeon/go-vcr.v3/recorder" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" ) func TestMiddleware(t *testing.T) { @@ -18,26 +18,24 @@ func TestMiddleware(t *testing.T) { // In a real-world scenario, the recorder will run outside of unit tests // since you want to be able to record real application behavior t.Run("RecordRealInteractionsWithMiddleware", func(t *testing.T) { - rec, err := recorder.NewWithOptions(&recorder.Options{ - CassetteName: cassetteName, - Mode: recorder.ModeRecordOnly, - SkipRequestLatency: true, - }) + rec, err := recorder.New( + recorder.WithCassette(cassetteName), + recorder.WithMode(recorder.ModeRecordOnly), + // Use a BeforeSaveHook to remove host, remote_addr, and duration + // since they change whenever the test runs + recorder.WithHook(func(i *cassette.Interaction) error { + i.Request.Host = "" + i.Request.RemoteAddr = "" + i.Response.Duration = 0 + return nil + }, recorder.BeforeSaveHook), + ) if err != nil { t.Errorf("error creating recorder: %v", err) } - // Use a BeforeSaveHook to remove host, remote_addr, and duration - // since they change whenever the test runs - rec.AddHook(func(i *cassette.Interaction) error { - i.Request.Host = "" - i.Request.RemoteAddr = "" - i.Response.Duration = 0 - return nil - }, recorder.BeforeSaveHook) - // Create the server handler with recorder middleware - handler := createHandler(rec.Middleware) + handler := createHandler(rec.HTTPMiddleware) defer rec.Stop() server := httptest.NewServer(handler) diff --git a/pkg/recorder/middleware.go b/pkg/recorder/middleware.go index 34cc18a..f5ed5c3 100644 --- a/pkg/recorder/middleware.go +++ b/pkg/recorder/middleware.go @@ -7,8 +7,8 @@ import ( "net/http/httptest" ) -// Middleware intercepts and records all incoming requests and the server's response -func (rec *Recorder) Middleware(next http.Handler) http.Handler { +// HTTPMiddleware intercepts and records all incoming requests and the server's response +func (rec *Recorder) HTTPMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := newPassthrough(w) diff --git a/pkg/recorder/recorder.go b/pkg/recorder/recorder.go index 1a614c0..ec5ff68 100644 --- a/pkg/recorder/recorder.go +++ b/pkg/recorder/recorder.go @@ -586,7 +586,7 @@ func (rec *Recorder) RoundTrip(req *http.Request) (*http.Response, error) { return rec.executeAndRecord(req, nil) } -// executeAndRecord is used internally by the Middleware to allow recording a response on the server side +// executeAndRecord is used internally by the HTTPMiddleware to allow recording a response on the server side func (rec *Recorder) executeAndRecord(req *http.Request, serverResponse *http.Response) (*http.Response, error) { // Passthrough mode, use real transport if rec.mode == ModePassthrough {