From 243547c6de1eb2df15c541a641deefae1dff3d63 Mon Sep 17 00:00:00 2001 From: Gregory Albouy Date: Sun, 6 Nov 2022 12:47:47 +0100 Subject: [PATCH] feat: create package benchttptest - implement AssertEqualRunners, EqualRunners, DiffRunner - Unit test exposed functions --- benchttptest/benchttptest.go | 2 + benchttptest/compare.go | 143 ++++++++++++++++++++++++++ benchttptest/compare_test.go | 189 +++++++++++++++++++++++++++++++++++ go.mod | 2 + go.sum | 2 + 5 files changed, 338 insertions(+) create mode 100644 benchttptest/benchttptest.go create mode 100644 benchttptest/compare.go create mode 100644 benchttptest/compare_test.go diff --git a/benchttptest/benchttptest.go b/benchttptest/benchttptest.go new file mode 100644 index 0000000..190a73b --- /dev/null +++ b/benchttptest/benchttptest.go @@ -0,0 +1,2 @@ +// Package benchttptest proovides utilities for benchttp testing. +package benchttptest diff --git a/benchttptest/compare.go b/benchttptest/compare.go new file mode 100644 index 0000000..70708d6 --- /dev/null +++ b/benchttptest/compare.go @@ -0,0 +1,143 @@ +package benchttptest + +import ( + "bytes" + "crypto/tls" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/benchttp/sdk/benchttp" +) + +// RunnerCmpOptions is the cmp.Options used to compare benchttp.Runner. +// By default, it ignores unexported fields and includes RequestCmpOptions. +var RunnerCmpOptions = cmp.Options{ + cmpopts.IgnoreUnexported(benchttp.Runner{}), + RequestCmpOptions, +} + +// RequestCmpOptions is the cmp.Options used to compare *http.Request. +// It behaves as follows: +// +// - Nil and empty values are considered equal +// +// - Fields that depend on how the request was created are ignored +// to avoid false negatives when comparing requests created in different +// ways (http.NewRequest vs httptest.NewRequest vs &http.Request{}) +// +// - Function fields are ignored +// +// - Body is ignored: it is compared separately +var RequestCmpOptions = cmp.Options{ + cmp.Transformer("Request", instantiateNilRequest), + cmp.Transformer("Request.Header", instantiateNilHeader), + cmp.Transformer("Request.URL", stringifyURL), + cmpopts.IgnoreUnexported(http.Request{}, tls.ConnectionState{}), + cmpopts.IgnoreFields(http.Request{}, unreliableRequestFields...), +} + +var unreliableRequestFields = []string{ + // These fields are automatically set by NewRequest constructor + // from packages http and httptest, as a consequence they can + // trigger false positives when comparing requests that were + // created differently. + "Proto", "ProtoMajor", "ProtoMinor", "ContentLength", + "Host", "RemoteAddr", "RequestURI", "TLS", "Cancel", + + // Function fields cannot be reliably compared + "GetBody", + + // Body field can't be read without altering the Request, causing + // cmp-go to panic. We perform a custom comparison instead. + "Body", +} + +// AssertEqualRunners fails t and shows a diff if a and b are not equal, +// as determined by RunnerCmpOptions. +func AssertEqualRunners(t *testing.T, x, y benchttp.Runner) { + t.Helper() + if !EqualRunners(x, y) { + t.Error(DiffRunner(x, y)) + } +} + +// EqualRunners returns true if x and y are equal, as determined by +// RunnerCmpOptions. +func EqualRunners(x, y benchttp.Runner) bool { + return cmp.Equal(x, y, RunnerCmpOptions) && + compareRequestBody(x.Request, y.Request) +} + +// DiffRunner returns a string showing the diff between x and y, +// as determined by RunnerCmpOptions. +func DiffRunner(x, y benchttp.Runner) string { + b := strings.Builder{} + b.WriteString(cmp.Diff(x, y, RunnerCmpOptions)) + if x.Request != nil && y.Request != nil { + xbody := nopreadBody(x.Request) + ybody := nopreadBody(y.Request) + if !bytes.Equal(xbody, ybody) { + b.WriteString("Request.Body: ") + b.WriteString(cmp.Diff(string(xbody), string(ybody))) + } + } + return b.String() +} + +// helpers + +func instantiateNilRequest(r *http.Request) *http.Request { + if r == nil { + return &http.Request{} + } + return r +} + +func instantiateNilHeader(h http.Header) http.Header { + if h == nil { + return http.Header{} + } + return h +} + +func stringifyURL(u *url.URL) string { + if u == nil { + return "" + } + return u.String() +} + +func compareRequestBody(a, b *http.Request) bool { + ba, bb := nopreadBody(a), nopreadBody(b) + return bytes.Equal(ba, bb) +} + +func nopreadBody(r *http.Request) []byte { + if r == nil || r.Body == nil { + return []byte{} + } + + bbuf := bytes.Buffer{} + + if _, err := io.Copy(&bbuf, r.Body); err != nil { + panic("benchttptest: error reading Request.Body: " + err.Error()) + } + + if r.GetBody != nil { + newbody, err := r.GetBody() + if err != nil { + panic("benchttptest: Request.GetBody error: " + err.Error()) + } + r.Body = newbody + } else { + r.Body = io.NopCloser(&bbuf) + } + + return bbuf.Bytes() +} diff --git a/benchttptest/compare_test.go b/benchttptest/compare_test.go new file mode 100644 index 0000000..7df633e --- /dev/null +++ b/benchttptest/compare_test.go @@ -0,0 +1,189 @@ +package benchttptest_test + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/benchttp/sdk/benchttp" + "github.com/benchttp/sdk/benchttptest" +) + +func TestAssertEqualRunners(t *testing.T) { + for _, tc := range []struct { + name string + pass bool + a, b benchttp.Runner + }{ + { + name: "pass if runners are equal", + pass: true, + a: benchttp.Runner{Requests: 1}, + b: benchttp.Runner{Requests: 1}, + }, + { + name: "fail if runners are not equal", + pass: false, + a: benchttp.Runner{Requests: 1}, + b: benchttp.Runner{Requests: 2}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + tt := &testing.T{} + + benchttptest.AssertEqualRunners(tt, tc.a, tc.b) + if tt.Failed() == tc.pass { + t.Fail() + } + }) + } +} + +func TestEqualRunners(t *testing.T) { + for _, tc := range []struct { + name string + want bool + a, b benchttp.Runner + }{ + { + name: "equal runners", + want: true, + a: benchttp.Runner{Requests: 1}, + b: benchttp.Runner{Requests: 1}, + }, + { + name: "different runners", + want: false, + a: benchttp.Runner{Requests: 1}, + b: benchttp.Runner{Requests: 2}, + }, + { + name: "consider zero requests equal", + want: true, + a: benchttp.Runner{Request: nil}, + b: benchttp.Runner{Request: &http.Request{}}, + }, + { + name: "consider zero request headers equal", + want: true, + a: benchttp.Runner{Request: &http.Request{Header: nil}}, + b: benchttp.Runner{Request: &http.Request{Header: http.Header{}}}, + }, + { + name: "consider zero request bodies equal", + want: true, + a: benchttp.Runner{Request: &http.Request{Body: nil}}, + b: benchttp.Runner{Request: &http.Request{Body: http.NoBody}}, + }, + { + name: "zero request vs non zero request", + want: false, + a: benchttp.Runner{Request: &http.Request{Method: "GET"}}, + b: benchttp.Runner{Request: nil}, + }, + { + name: "different request field values", + want: false, + a: benchttp.Runner{Request: &http.Request{Method: "GET"}}, + b: benchttp.Runner{Request: &http.Request{Method: "POST"}}, + }, + { + name: "ignore unreliable request fields", + want: true, + a: benchttp.Runner{ + Request: httptest.NewRequest( // sets Proto, ContentLength, ... + "POST", + "https://example.com", + nil, + ), + }, + b: benchttp.Runner{ + Request: &http.Request{ + Method: "POST", + URL: mustParseRequestURI("https://example.com"), + }, + }, + }, + { + name: "equal request bodies", + want: true, + a: benchttp.Runner{ + Request: &http.Request{ + Body: io.NopCloser(strings.NewReader("hello")), + }, + }, + b: benchttp.Runner{ + Request: &http.Request{ + Body: io.NopCloser(strings.NewReader("hello")), + }, + }, + }, + { + name: "different request bodies", + want: false, + a: benchttp.Runner{ + Request: &http.Request{ + Body: io.NopCloser(strings.NewReader("hello")), + }, + }, + b: benchttp.Runner{ + Request: &http.Request{ + Body: io.NopCloser(strings.NewReader("world")), + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if benchttptest.EqualRunners(tc.a, tc.b) != tc.want { + t.Error(benchttptest.DiffRunner(tc.a, tc.b)) + } + }) + } + + t.Run("restore request body", func(t *testing.T) { + a := benchttp.Runner{ + Request: httptest.NewRequest( + "POST", + "https://example.com", + strings.NewReader("hello"), + ), + } + b := benchttp.Runner{ + Request: &http.Request{ + Method: "POST", + URL: mustParseRequestURI("https://example.com"), + Body: io.NopCloser(bytes.NewReader([]byte("hello"))), + }, + } + + _ = benchttptest.EqualRunners(a, b) + + ba, bb := mustRead(a.Request.Body), mustRead(b.Request.Body) + want := []byte("hello") + if !bytes.Equal(want, ba) || !bytes.Equal(want, bb) { + t.Fail() + } + }) +} + +// helpers + +func mustParseRequestURI(s string) *url.URL { + u, err := url.ParseRequestURI(s) + if err != nil { + panic(err) + } + return u +} + +func mustRead(r io.Reader) []byte { + b, err := io.ReadAll(r) + if err != nil { + panic("mustRead: " + err.Error()) + } + return b +} diff --git a/go.mod b/go.mod index 06d8eb9..b514887 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 gopkg.in/yaml.v3 v3.0.1 ) + +require github.com/google/go-cmp v0.5.9 diff --git a/go.sum b/go.sum index 12011ba..cb4a9ff 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=