internal/test: add small library for tests

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 <ondrej@budai.cz>
This commit is contained in:
Ondřej Budai 2022-03-03 00:22:15 +01:00 committed by Ondřej Budai
parent ffbbd022e3
commit ad5a135b56
3 changed files with 170 additions and 0 deletions

99
internal/test/apicall.go Normal file
View file

@ -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
}

23
internal/test/request.go Normal file
View file

@ -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"
}

View file

@ -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
}