Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the snapshot helper to be able to save raw json and parsed and validated response struct #206

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
23 changes: 11 additions & 12 deletions internal/test/helper_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"testing"

"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/go-openapi/runtime"
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -88,27 +87,27 @@ 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 {
t.Fatalf("Failed to parse response body: %v", err)
}
}

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)
Expand All @@ -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{}
Expand Down
87 changes: 83 additions & 4 deletions internal/test/helper_snapshot.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package test

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"

"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/davecgh/go-spew/spew"
"github.com/go-openapi/runtime"
"github.com/pmezard/go-difflib/difflib"
)

Expand All @@ -35,6 +39,7 @@ type snapshoter struct {
label string
replacer func(s string) string
location string
skips []string
}

var Snapshoter = snapshoter{
Expand All @@ -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 + <redacted>
return re.ReplaceAllString(s, `"$1": <redacted>,`)
}

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))

Expand All @@ -67,6 +132,7 @@ func (s snapshoter) Save(t TestingT, data ...interface{}) {
}

t.Errorf("Updating snapshot: '%s'", snapshotName)
return
}

prevSnapBytes, err := os.ReadFile(snapshotAbsPath)
Expand All @@ -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)
Expand All @@ -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...)
}

Expand All @@ -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 + <redacted>
return re.ReplaceAllString(s, "$1: <redacted>,")
return reStruct.ReplaceAllString(re.ReplaceAllString(s, "$1: <redacted>,"), "$1 { <redacted> }")
}

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
Expand Down
61 changes: 59 additions & 2 deletions internal/test/helper_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
})
}
8 changes: 8 additions & 0 deletions test/testdata/snapshots/TestSaveResponseAndValidate.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
(*types.GetUserInfoResponse)({
Email: <redacted>,
Scopes: ([]string) (len=1) {
(string) (len=3) "app"
},
Sub: (*string)((len=36) "f6ede5d8-e22a-4ca5-aa12-67821865a3e5"),
UpdatedAt: <redacted>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"email": <redacted>,
"scopes": [
"app"
],
"sub": "f6ede5d8-e22a-4ca5-aa12-67821865a3e5",
"updated_at": <redacted>,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
(struct { ID string; A string; B int; C bool; D interface {}; E []string; F map[string]int }) {
ID: <redacted>,
A: (string) (len=3) "foo",
B: (int) 1,
C: (bool) true,
D: (struct { Foo string; Bar int }) { <redacted> },
E: ([]string) (len=2) { <redacted> },
F: (map[string]int) (len=3) { <redacted> }
}