diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a281e2..56830e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - Please follow the update process in *[I just want to update / upgrade my project!](https://github.com/allaboutapps/go-starter/wiki/FAQ#i-just-want-to-update--upgrade-my-project)*. ## Unreleased +- Added new features to the test snapshot helper. The snapshoter can now be used to save the json response body and the parsed and validated go-swagger type. + - The replacer function of `Skip` and `Redact` now supports multiline skips for maps or slices. ## 2023-05-03 - Switch [from Go 1.19.3 to Go 1.20.3](https://go.dev/doc/devel/release#go1.20) (requires `./docker-helper.sh --rebuild`). diff --git a/internal/test/helper_request.go b/internal/test/helper_request.go index b1d2a189..d3ee8b4e 100644 --- a/internal/test/helper_request.go +++ b/internal/test/helper_request.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "testing" "allaboutapps.dev/aw/go-starter/internal/api" "github.com/go-openapi/runtime" @@ -18,7 +17,7 @@ import ( type GenericPayload map[string]interface{} type GenericArrayPayload []interface{} -func (g GenericPayload) Reader(t *testing.T) *bytes.Reader { +func (g GenericPayload) Reader(t TestingT) *bytes.Reader { t.Helper() b, err := json.Marshal(g) @@ -29,7 +28,7 @@ func (g GenericPayload) Reader(t *testing.T) *bytes.Reader { return bytes.NewReader(b) } -func (g GenericArrayPayload) Reader(t *testing.T) *bytes.Reader { +func (g GenericArrayPayload) Reader(t TestingT) *bytes.Reader { t.Helper() b, err := json.Marshal(g) @@ -40,7 +39,7 @@ func (g GenericArrayPayload) Reader(t *testing.T) *bytes.Reader { return bytes.NewReader(b) } -func PerformRequestWithParams(t *testing.T, s *api.Server, method string, path string, body GenericPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { +func PerformRequestWithParams(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { t.Helper() if body == nil { @@ -50,7 +49,7 @@ func PerformRequestWithParams(t *testing.T, s *api.Server, method string, path s return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams) } -func PerformRequestWithArrayAndParams(t *testing.T, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { +func PerformRequestWithArrayAndParams(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { t.Helper() if body == nil { @@ -60,7 +59,7 @@ func PerformRequestWithArrayAndParams(t *testing.T, s *api.Server, method string return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams) } -func PerformRequestWithRawBody(t *testing.T, s *api.Server, method string, path string, body io.Reader, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { +func PerformRequestWithRawBody(t TestingT, s *api.Server, method string, path string, body io.Reader, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, body) @@ -88,19 +87,19 @@ func PerformRequestWithRawBody(t *testing.T, s *api.Server, method string, path return res } -func PerformRequest(t *testing.T, s *api.Server, method string, path string, body GenericPayload, headers http.Header) *httptest.ResponseRecorder { +func PerformRequest(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header) *httptest.ResponseRecorder { t.Helper() return PerformRequestWithParams(t, s, method, path, body, headers, nil) } -func PerformRequestWithArray(t *testing.T, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header) *httptest.ResponseRecorder { +func PerformRequestWithArray(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header) *httptest.ResponseRecorder { t.Helper() return PerformRequestWithArrayAndParams(t, s, method, path, body, headers, nil) } -func ParseResponseBody(t *testing.T, res *httptest.ResponseRecorder, v interface{}) { +func ParseResponseBody(t TestingT, res *httptest.ResponseRecorder, v interface{}) { t.Helper() if err := json.NewDecoder(res.Result().Body).Decode(&v); err != nil { @@ -108,7 +107,7 @@ func ParseResponseBody(t *testing.T, res *httptest.ResponseRecorder, v interface } } -func ParseResponseAndValidate(t *testing.T, res *httptest.ResponseRecorder, v runtime.Validatable) { +func ParseResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) { t.Helper() ParseResponseBody(t, res, &v) @@ -118,13 +117,13 @@ func ParseResponseAndValidate(t *testing.T, res *httptest.ResponseRecorder, v ru } } -func HeadersWithAuth(t *testing.T, token string) http.Header { +func HeadersWithAuth(t TestingT, token string) http.Header { t.Helper() return HeadersWithConfigurableAuth(t, "Bearer", token) } -func HeadersWithConfigurableAuth(t *testing.T, scheme string, token string) http.Header { +func HeadersWithConfigurableAuth(t TestingT, scheme string, token string) http.Header { t.Helper() headers := http.Header{} diff --git a/internal/test/helper_snapshot.go b/internal/test/helper_snapshot.go index 5eba3c3c..2896c43b 100644 --- a/internal/test/helper_snapshot.go +++ b/internal/test/helper_snapshot.go @@ -1,8 +1,11 @@ package test import ( + "bytes" + "encoding/json" "errors" "fmt" + "net/http/httptest" "os" "path/filepath" "regexp" @@ -10,6 +13,7 @@ import ( "allaboutapps.dev/aw/go-starter/internal/util" "github.com/davecgh/go-spew/spew" + "github.com/go-openapi/runtime" "github.com/pmezard/go-difflib/difflib" ) @@ -35,6 +39,7 @@ type snapshoter struct { label string replacer func(s string) string location string + skips []string } var Snapshoter = snapshoter{ @@ -57,6 +62,66 @@ func (s snapshoter) Save(t TestingT, data ...interface{}) { } dump := s.replacer(spewConfig.Sdump(data...)) + + s.save(t, dump) +} + +// SaveString creates a snapshot of the raw string. +// Used to snapshot payloads or mails as formatted data. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +// vastly inspired by https://github.com/bradleyjkemp/cupaloy +// main reason for self implementation is the replacer function and general flexibility +func (s snapshoter) SaveString(t TestingT, data string) { + t.Helper() + err := os.MkdirAll(s.location, os.ModePerm) + if err != nil { + t.Fatal(err) + } + + data = s.replacer(data) + + s.save(t, data) +} + +// SaveResponseAndValidate is used to create 2 snapshots for endpoint tests. +// One snapshot will save the raw JSON response as indented JSON. +// For the second snapshot the response will be parsed and validated using request helpers (helper_request.go) +// Afterwards a dump of the response will be saved. +// It will fail the test if the dump is different from the saved dump. +// It will also fail if it is the creation or an update of the snapshot. +func (s snapshoter) SaveResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) { + t.Helper() + + // snapshot prettyfied json first + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, res.Body.Bytes(), "", "\t"); err != nil { + t.Fatal(err) + } + + jsonS := s + // set custom replacer for JSON compared to dumps + jsonS.replacer = func(s string) string { + skipString := strings.Join(jsonS.skips, "|") + re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString)) + if err != nil { + panic(err) + } + + // replace lines with property name + + return re.ReplaceAllString(s, `"$1": ,`) + } + + jsonS.label += "JSON" + jsonS.SaveString(t, prettyJSON.String()) + + // bind and snapshot response type struct + ParseResponseAndValidate(t, res, v) + s.Save(t, v) +} + +func (s snapshoter) save(t TestingT, dump string) { + t.Helper() snapshotName := fmt.Sprintf("%s%s", strings.Replace(t.Name(), "/", "-", -1), s.label) snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.golden", snapshotName)) @@ -67,6 +132,7 @@ func (s snapshoter) Save(t TestingT, data ...interface{}) { } t.Errorf("Updating snapshot: '%s'", snapshotName) + return } prevSnapBytes, err := os.ReadFile(snapshotAbsPath) @@ -77,7 +143,8 @@ func (s snapshoter) Save(t TestingT, data ...interface{}) { t.Fatal(err) } - t.Fatalf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName) + t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName) + return } t.Fatal(err) @@ -96,12 +163,13 @@ func (s snapshoter) Save(t TestingT, data ...interface{}) { t.Fatal(err) } - t.Error(diff) + t.Error(fmt.Sprintf("%s: %s", snapshotName, diff)) } } // SaveU is a short version for .Update(true).Save(...) func (s snapshoter) SaveU(t TestingT, data ...interface{}) { + t.Helper() s.Update(true).Save(t, data...) } @@ -110,20 +178,31 @@ func (s snapshoter) SaveU(t TestingT, data ...interface{}) { // Each line of the formatted dump is matched against the property name defined in skip and // the value will be replaced to deal with generated values that change each test. func (s snapshoter) Skip(skip []string) snapshoter { + s.skips = skip s.replacer = func(s string) string { skipString := strings.Join(skip, "|") - re, err := regexp.Compile(fmt.Sprintf("(%s): .*", skipString)) + re, err := regexp.Compile(fmt.Sprintf("(?m)(\\s+%s): .*[^{]$", skipString)) + if err != nil { + panic(err) + } + + reStruct, err := regexp.Compile(fmt.Sprintf("((\\s+%s): .*){\n([^}]|\n)*}", skipString)) if err != nil { panic(err) } // replace lines with property name + - return re.ReplaceAllString(s, "$1: ,") + return reStruct.ReplaceAllString(re.ReplaceAllString(s, "$1: ,"), "$1 { }") } return s } +// Redact is a wrapper for Skip for easier usage with a variadic. +func (s snapshoter) Redact(skip ...string) snapshoter { + return s.Skip(skip) +} + // Upadte is used to force an update for the snapshot. Will fail the test. func (s snapshoter) Update(update bool) snapshoter { s.update = update diff --git a/internal/test/helper_snapshot_test.go b/internal/test/helper_snapshot_test.go index ed56b9b0..b814fe0e 100644 --- a/internal/test/helper_snapshot_test.go +++ b/internal/test/helper_snapshot_test.go @@ -1,13 +1,16 @@ package test_test import ( + "net/http" "os" "path/filepath" "regexp" "testing" + "allaboutapps.dev/aw/go-starter/internal/api" "allaboutapps.dev/aw/go-starter/internal/test" "allaboutapps.dev/aw/go-starter/internal/test/mocks" + "allaboutapps.dev/aw/go-starter/internal/types" "allaboutapps.dev/aw/go-starter/internal/util" "github.com/go-openapi/swag" "github.com/stretchr/testify/mock" @@ -147,10 +150,11 @@ func TestSnapshotNotExists(t *testing.T) { tMock.On("Fatalf", mock.Anything, mock.Anything).Return() tMock.On("Fatal", mock.Anything).Return() tMock.On("Error", mock.Anything).Return() + tMock.On("Errorf", mock.Anything, mock.Anything).Return() test.Snapshoter.Save(tMock, a, b) tMock.AssertNotCalled(t, "Error") - tMock.AssertNotCalled(t, "Fatalf") - tMock.AssertCalled(t, "Fatalf", mock.Anything, mock.Anything) + tMock.AssertNotCalled(t, "Fatal") + tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything) } func TestSnapshotSkipFields(t *testing.T) { @@ -176,6 +180,43 @@ func TestSnapshotSkipFields(t *testing.T) { test.Snapshoter.Skip([]string{"ID"}).Save(t, a) } +func TestSnapshotSkipMultilineFields(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + randID, err := util.GenerateRandomBase64String(20) + require.NoError(t, err) + a := struct { + ID string + A string + B int + C bool + D interface{} + E []string + F map[string]int + }{ + ID: randID, + A: "foo", + B: 1, + C: true, + D: struct { + Foo string + Bar int + }{ + Foo: "skip me", + Bar: 3, + }, + E: []string{"skip me", "skip me too"}, + F: map[string]int{ + "skip me": 1, + "skip me too": 2, + "skip me three": 3, + }, + } + + test.Snapshoter.Skip([]string{"ID", "D", "E", "F"}).Save(t, a) +} + func TestSnapshotWithLabel(t *testing.T) { if test.UpdateGoldenGlobal { t.Skip() @@ -217,3 +258,19 @@ func TestSnapshotWithLocation(t *testing.T) { location := filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata") test.Snapshoter.Location(location).Save(t, a) } + +func TestSaveResponseAndValidate(t *testing.T) { + if test.UpdateGoldenGlobal { + t.Skip() + } + + test.WithTestServer(t, func(s *api.Server) { + fixtures := test.Fixtures() + + res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fixtures.User1AccessToken1.Token)) + require.Equal(t, http.StatusOK, res.Result().StatusCode) + + var response types.GetUserInfoResponse + test.Snapshoter.Redact("Email", "UpdatedAt", "updated_at").SaveResponseAndValidate(t, res, &response) + }) +} diff --git a/test/testdata/snapshots/TestSaveResponseAndValidate.golden b/test/testdata/snapshots/TestSaveResponseAndValidate.golden new file mode 100644 index 00000000..bf124625 --- /dev/null +++ b/test/testdata/snapshots/TestSaveResponseAndValidate.golden @@ -0,0 +1,8 @@ +(*types.GetUserInfoResponse)({ + Email: , + Scopes: ([]string) (len=1) { + (string) (len=3) "app" + }, + Sub: (*string)((len=36) "f6ede5d8-e22a-4ca5-aa12-67821865a3e5"), + UpdatedAt: , +}) diff --git a/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden b/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden new file mode 100644 index 00000000..76a8e92b --- /dev/null +++ b/test/testdata/snapshots/TestSaveResponseAndValidateJSON.golden @@ -0,0 +1,8 @@ +{ + "email": , + "scopes": [ + "app" + ], + "sub": "f6ede5d8-e22a-4ca5-aa12-67821865a3e5", + "updated_at": , +} diff --git a/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden b/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden new file mode 100644 index 00000000..64612b34 --- /dev/null +++ b/test/testdata/snapshots/TestSnapshotSkipMultilineFields.golden @@ -0,0 +1,9 @@ +(struct { ID string; A string; B int; C bool; D interface {}; E []string; F map[string]int }) { + ID: , + A: (string) (len=3) "foo", + B: (int) 1, + C: (bool) true, + D: (struct { Foo string; Bar int }) { }, + E: ([]string) (len=2) { }, + F: (map[string]int) (len=3) { } +}