From ad5a135b56ee66f12e46d911b5947fad3f1535ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Budai?= Date: Thu, 3 Mar 2022 00:22:15 +0100 Subject: [PATCH] internal/test: add small library for tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yeah, we have TestRoute. It has one issue though: It doesn't have support for passing a custom context. One option is to extend the method with yet argument but since it already has 9 (!!!), this seems like a huge mess. Therefore, I decided to invent a new small library for writing API tests. It uses structs heavily which means that adding features to it doesn't mean changing 100 lines of code (like adding another arg to TestRoute does). I hope that we can start using this library more in our tests as it was designed to be very flexible and powerfule. Signed-off-by: Ondřej Budai --- internal/test/apicall.go | 99 ++++++++++++++++++++++++++++++++++++++ internal/test/request.go | 23 +++++++++ internal/test/validator.go | 48 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 internal/test/apicall.go create mode 100644 internal/test/request.go create mode 100644 internal/test/validator.go diff --git a/internal/test/apicall.go b/internal/test/apicall.go new file mode 100644 index 000000000..7a5610b48 --- /dev/null +++ b/internal/test/apicall.go @@ -0,0 +1,99 @@ +package test + +import ( + "bytes" + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// APICall is a small function object for testing HTTP APIs +type APICall struct { + // http.Handler to run the call against + Handler http.Handler + + // HTTP method, e.g. http.MethodPatch + Method string + + // Request Path + Path string + + // Request body. If nil, an empty body is sent + RequestBody RequestBody + + // Request header. If nil, default header is sent + Header http.Header + + // Request context. If nil, default context is used + Context context.Context + + // Status that's expected to be received. If set to 0, the status is not checked + ExpectedStatus int + + // Validator for the response body. If set to nil, the body is not validated + ExpectedBody BodyValidator +} + +// Do performs the request as defined in the APICall struct. +// +// If any errors occur when doing the request, or any of the validators fail, t.FailNow() is called +// Note that HTTP error status is not checked if ExpectedStatus == 0 +// +// The result of the HTTP call is returned +func (a APICall) Do(t *testing.T) APICallResult { + t.Helper() + + var bodyReader io.Reader + if a.RequestBody != nil { + bodyReader = bytes.NewReader(a.RequestBody.Body()) + } + + req := httptest.NewRequest(a.Method, a.Path, bodyReader) + + if a.Context != nil { + req = req.WithContext(a.Context) + } + + req.Header = a.Header + if req.Header == nil { + req.Header = http.Header{} + } + + if a.RequestBody != nil && a.RequestBody.ContentType() != "" { + req.Header.Set("Content-Type", a.RequestBody.ContentType()) + } + respRecorder := httptest.NewRecorder() + a.Handler.ServeHTTP(respRecorder, req) + resp := respRecorder.Result() + + body, err := ioutil.ReadAll(resp.Body) + require.NoErrorf(t, err, "%s: could not read response body", a.Path) + + if a.ExpectedStatus != 0 { + assert.Equalf(t, a.ExpectedStatus, resp.StatusCode, "%s: SendHTTP failed for path", a.Path) + } + if a.ExpectedBody != nil { + err = a.ExpectedBody.Validate(body) + require.NoError(t, err, "%s: cannot validate response body", a.Path) + } + + return APICallResult{ + Body: body, + StatusCode: resp.StatusCode, + } +} + +// APICallResult holds a parsed response for an APICall +type APICallResult struct { + // Full body as read from the server + Body []byte + + // Status code returned from the server + StatusCode int +} diff --git a/internal/test/request.go b/internal/test/request.go new file mode 100644 index 000000000..893dab465 --- /dev/null +++ b/internal/test/request.go @@ -0,0 +1,23 @@ +package test + +// RequestBody is an abstract interface for defining request bodies for APICall +type RequestBody interface { + // Body returns the intended request body as a slice of bytes + Body() []byte + + // ContentType returns value for Content-Type request header + ContentType() string +} + +// JSONRequestBody is just a simple wrapper over plain string. +// +// Body is just the string converted to a slice of bytes and content type is set to application/json +type JSONRequestBody string + +func (b JSONRequestBody) Body() []byte { + return []byte(b) +} + +func (b JSONRequestBody) ContentType() string { + return "application/json" +} diff --git a/internal/test/validator.go b/internal/test/validator.go new file mode 100644 index 000000000..c568e47d9 --- /dev/null +++ b/internal/test/validator.go @@ -0,0 +1,48 @@ +package test + +import ( + "encoding/json" + "fmt" + + "github.com/google/go-cmp/cmp" +) + +// BodyValidator is an abstract interface for defining validators for response bodies +type BodyValidator interface { + // Validate returns nil if the body is valid. If the body isn't valid, a descriptive error is returned. + Validate(body []byte) error +} + +// JSONValidator is a simple validator for validating JSON responses +type JSONValidator struct { + // Content is the expected json content of the body + // + // Note that the key order of maps is arbitrary + Content string + + // IgnoreFields is a list of JSON keys that should be removed from both expected body and actual body + IgnoreFields []string +} + +func (b JSONValidator) Validate(body []byte) error { + var reply, expected interface{} + err := json.Unmarshal(body, &reply) + if err != nil { + return fmt.Errorf("json.Unmarshal failed: %s\n%w", string(body), err) + } + + err = json.Unmarshal([]byte(b.Content), &expected) + if err != nil { + return fmt.Errorf("expected JSON is invalid: %s\n%w", string(b.Content), err) + } + + dropFields(reply, b.IgnoreFields...) + dropFields(expected, b.IgnoreFields...) + + diff := cmp.Diff(expected, reply) + if diff != "" { + return fmt.Errorf("bodies don't match: %s", diff) + } + + return nil +}