Repository: amp-labs/connectors
Pull request #647 (2024-07-02 15:46:34)

test: mock Salesforce Write method
# Mock Tests

Write seems to have had an issue when parsing errors during StatusOK. General convertor to golang data types is utilised as errors item has a free structure. This example again was taken from documentation as I failed to produce this case. (I would end up with real error -- non 2xx response codes).
```
{
  "id": "001RM000003oLruYAE",
  "success": false,
  "errors": [
    {
      "statusCode": "MALFORMED_ID",
      "message": "malformed id 001RM000003oLrB000",
      "fields": []
    }
  ]
}
```
Enforced required ObjectName.

# Manual Tests

Manual test for Saleforce which does Read+Write is still passing.
![image](https://github.com/amp-labs/connectors/assets/60330780/8faee455-74c5-4181-ba88-d8932647cbee)


Cobalt0s: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/647?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

* **#649** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/649?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#647** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/647?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈
* **#646** <a href="https://app.graphite.dev/github/pr/amp-labs/connectors/646?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`

This stack of pull requests is managed by Graphite. <a href="https://stacking.dev/?utm_source=stack-comment">Learn more about stacking.</a>
<h2></h2>

 Join @Cobalt0s and the rest of your teammates on <a href="https://graphite.dev?utm-source=stack-comment"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="11px" height="11px"/> <b>Graphite</b></a>
  <!-- Current dependencies on/for this PR: -->

Patch:
diff --git a/common/jsonquery/converters.go b/common/jsonquery/converters.go
index 2fb0b248..daa5d9c2 100644
--- a/common/jsonquery/converters.go
+++ b/common/jsonquery/converters.go
@@ -24,7 +24,7 @@ func (convertor) ArrayToMap(arr []*ajson.Node) ([]map[string]any, error) {
 			return nil, err
 		}
 
-		m, ok := data.(map[string]interface{})
+		m, ok := data.(map[string]any)
 		if !ok {
 			return nil, ErrNotObject
 		}
@@ -35,6 +35,21 @@ func (convertor) ArrayToMap(arr []*ajson.Node) ([]map[string]any, error) {
 	return output, nil
 }
 
+func (convertor) ArrayToObjects(arr []*ajson.Node) ([]any, error) {
+	output := make([]any, 0, len(arr))
+
+	for _, v := range arr {
+		data, err := v.Unpack()
+		if err != nil {
+			return nil, err
+		}
+
+		output = append(output, data)
+	}
+
+	return output, nil
+}
+
 func (convertor) ObjectToMap(node *ajson.Node) (map[string]any, error) {
 	data, err := node.GetObject()
 	if err != nil {
diff --git a/common/jsonquery/defaults.go b/common/jsonquery/defaults.go
index 8017a848..eb5c146b 100644
--- a/common/jsonquery/defaults.go
+++ b/common/jsonquery/defaults.go
@@ -25,3 +25,16 @@ func (q *Query) StrWithDefault(key string, defaultValue string) (string, error)
 
 	return *result, nil
 }
+
+func (q *Query) BoolWithDefault(key string, defaultValue bool) (bool, error) {
+	result, err := q.Bool(key, true)
+	if err != nil {
+		return false, err
+	}
+
+	if result == nil {
+		return defaultValue, nil
+	}
+
+	return *result, nil
+}
diff --git a/common/jsonquery/errors.go b/common/jsonquery/errors.go
index a504dfaf..31852d2f 100644
--- a/common/jsonquery/errors.go
+++ b/common/jsonquery/errors.go
@@ -11,6 +11,7 @@ var (
 	ErrNotArray    = errors.New("JSON value is not an array")
 	ErrNotObject   = errors.New("JSON value is not an object")
 	ErrNotString   = errors.New("JSON value is not a string")
+	ErrNotBool     = errors.New("JSON value is not a boolean")
 	ErrNotNumeric  = errors.New("JSON value is not a numeric")
 	ErrNotInteger  = errors.New("JSON value is not an integer")
 	ErrUnpacking   = errors.New("failed to unpack ajson node")
diff --git a/common/jsonquery/queries.go b/common/jsonquery/queries.go
index 859ac592..73cebc52 100644
--- a/common/jsonquery/queries.go
+++ b/common/jsonquery/queries.go
@@ -97,6 +97,28 @@ func (q *Query) Str(key string, optional bool) (*string, error) {
 	return &txt, nil
 }
 
+func (q *Query) Bool(key string, optional bool) (*bool, error) {
+	node, err := q.getInnerKey(key, optional)
+	if err != nil {
+		return nil, err
+	}
+
+	if node == nil {
+		return nil, nil // nolint:nilnil
+	}
+
+	if node.IsNull() {
+		return nil, handleNullNode(key, optional)
+	}
+
+	flag, err := node.GetBool()
+	if err != nil {
+		return nil, ErrNotBool
+	}
+
+	return &flag, nil
+}
+
 func (q *Query) Array(key string, optional bool) ([]*ajson.Node, error) {
 	node, err := q.getInnerKey(key, optional)
 	if err != nil {
diff --git a/common/types.go b/common/types.go
index 45ec89e7..0913537b 100644
--- a/common/types.go
+++ b/common/types.go
@@ -162,9 +162,9 @@ type WriteResult struct {
 	// RecordId is the ID of the written record.
 	RecordId string `json:"recordId,omitempty"` // optional
 	// Errors is list of error record returned by the API.
-	Errors []interface{} `json:"errors,omitempty"` // optional
+	Errors []any `json:"errors,omitempty"` // optional
 	// Data is a JSON node containing data about the properties that were updated.
-	Data map[string]interface{} `json:"data,omitempty"` // optional
+	Data map[string]any `json:"data,omitempty"` // optional
 }
 
 // DeleteResult is what's returned from deleting data via the Delete call.
diff --git a/salesforce/errors.go b/salesforce/errors.go
index 21ff8839..2a530712 100644
--- a/salesforce/errors.go
+++ b/salesforce/errors.go
@@ -13,10 +13,7 @@ import (
 
 var (
 	ErrNotArray           = errors.New("records is not an array")
-	ErrNotObject          = errors.New("record isn't an object")
 	ErrNotString          = errors.New("nextRecordsUrl isn't a string")
-	ErrNotBool            = errors.New("done isn't a boolean")
-	ErrNotNumeric         = errors.New("totalSize isn't numeric")
 	ErrCannotReadMetadata = errors.New("cannot read object metadata, it is possible you don't have the correct permissions set") // nolint:lll
 )
 
@@ -54,6 +51,10 @@ func (c *Connector) interpretJSONError(res *http.Response, body []byte) error {
 		case "REQUEST_LIMIT_EXCEEDED":
 			return createError(common.ErrLimitExceeded, sfErr)
 		case "INVALID_TYPE":
+			fallthrough
+		case "INVALID_FIELD_FOR_INSERT_UPDATE":
+			fallthrough
+		case "INVALID_FIELD":
 			return createError(common.ErrBadRequest, sfErr)
 		default:
 			continue
diff --git a/salesforce/write.go b/salesforce/write.go
index 74191e98..1944a173 100644
--- a/salesforce/write.go
+++ b/salesforce/write.go
@@ -4,11 +4,16 @@ import (
 	"context"
 
 	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/jsonquery"
 	"github.com/spyzhov/ajson"
 )
 
 // Write will write data to Salesforce.
 func (c *Connector) Write(ctx context.Context, config common.WriteParams) (*common.WriteResult, error) {
+	if len(config.ObjectName) == 0 {
+		return nil, common.ErrMissingObjects
+	}
+
 	url, err := c.getRestApiURL("sobjects", config.ObjectName)
 	if err != nil {
 		return nil, err
@@ -37,7 +42,7 @@ func parseWriteResult(rsp *common.JSONHTTPResponse) (*common.WriteResult, error)
 		}, nil
 	}
 
-	createdRecordId, err := getCreatedRecordId(rsp.Body)
+	recordID, err := jsonquery.New(rsp.Body).Str("id", false)
 	if err != nil {
 		return nil, err
 	}
@@ -47,7 +52,7 @@ func parseWriteResult(rsp *common.JSONHTTPResponse) (*common.WriteResult, error)
 		return nil, err
 	}
 
-	success, err := getSuccess(rsp.Body)
+	success, err := jsonquery.New(rsp.Body).Bool("success", false)
 	if err != nil {
 		return nil, err
 	}
@@ -55,66 +60,23 @@ func parseWriteResult(rsp *common.JSONHTTPResponse) (*common.WriteResult, error)
 	// Salesforce does not return record data upon successful write so we do not populate
 	// the corresponding result field
 	return &common.WriteResult{
-		RecordId: createdRecordId,
+		RecordId: *recordID,
 		Errors:   errors,
-		Success:  success,
+		Success:  *success,
 	}, nil
 }
 
 // getErrors returns the errors from the response.
 func getErrors(node *ajson.Node) ([]any, error) {
-	errors, err := node.GetKey("errors")
+	arr, err := jsonquery.New(node).Array("errors", true)
 	if err != nil {
 		return nil, err
 	}
 
-	if !errors.IsArray() {
-		return nil, ErrNotArray
-	}
-
-	arr := errors.MustArray()
-
-	out := make([]any, 0, len(arr))
-
-	for _, v := range arr {
-		if !v.IsString() {
-			return nil, ErrNotString
-		}
-
-		data, err := v.Unpack()
-		if err != nil {
-			return nil, err
-		}
-
-		m, ok := data.(string)
-		if !ok {
-			return nil, ErrNotString
-		}
-
-		out = append(out, m)
-	}
-
-	return out, nil
-}
-
-func getCreatedRecordId(node *ajson.Node) (string, error) {
-	idNode, err := node.GetKey("id")
-	if err != nil {
-		return "", err
-	}
-
-	return idNode.MustString(), nil
-}
-
-func getSuccess(node *ajson.Node) (bool, error) {
-	successNode, err := node.GetKey("success")
+	objects, err := jsonquery.Convertor.ArrayToObjects(arr)
 	if err != nil {
-		return false, err
-	}
-
-	if !successNode.IsBool() {
-		return false, ErrNotBool
+		return nil, err
 	}
 
-	return successNode.MustBool(), nil
+	return objects, nil
 }


Test patch:
diff --git a/common/jsonquery/queries_test.go b/common/jsonquery/queries_test.go
index 355cc931..3c014fe8 100644
--- a/common/jsonquery/queries_test.go
+++ b/common/jsonquery/queries_test.go
@@ -11,12 +11,14 @@ import (
 // nolint:gochecknoglobals
 var testJSONData = `{
 		"count":38, "text":"Hello World", "pi":3.14, "metadata":null, "list":[1,2,3], "arr":[],
+		"inProgress": false,
 		"payload": {
 			"notes": {
 				"links": null,
 				"body": {
 					"text": "Some notes",
-					"amount": 359
+					"amount": 359,
+					"purchased": true
 				},
 				"nested_arr": [1,2,3,4,5]
 			},
@@ -116,7 +118,7 @@ func TestQuery_Integer(t *testing.T) { // nolint:funlen
 	}
 }
 
-func TestQuery_String(t *testing.T) { // nolint:funlen
+func TestQueryString(t *testing.T) { // nolint:funlen
 	t.Parallel()
 
 	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
@@ -177,7 +179,7 @@ func TestQuery_String(t *testing.T) { // nolint:funlen
 				optional: false,
 				zoom:     []string{"payload", "notes", "body"},
 			},
-			expected: ptrString("Hello World"),
+			expected: ptrString("Some notes"),
 		},
 		{
 			name: "Non existent zoom path is ok for optional string",
@@ -196,7 +198,93 @@ func TestQuery_String(t *testing.T) { // nolint:funlen
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			output, err := New(j).Str(tt.input.key, tt.input.optional)
+			output, err := New(j, tt.input.zoom...).Str(tt.input.key, tt.input.optional)
+			assertJSONManagerOutput(t, tt.name, tt.expected, tt.expectedErr, output, err)
+		})
+	}
+}
+
+func TestQueryBool(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	j := helperCreateJSON(t, testJSONData) // nolint:varnamelen
+
+	type inType struct {
+		key      string
+		optional bool
+		zoom     []string
+	}
+
+	tests := []struct {
+		name        string
+		input       inType
+		expected    *bool
+		expectedErr error
+	}{
+		{
+			name: "Missing required bool",
+			input: inType{
+				key:      "completed",
+				optional: false,
+			},
+			expectedErr: ErrKeyNotFound,
+		},
+		{
+			name: "Missing optional bool",
+			input: inType{
+				key:      "completed",
+				optional: true,
+			},
+			expected: nil,
+		},
+		{
+			name: "Incorrect data type",
+			input: inType{
+				key: "count",
+			},
+			expectedErr: ErrNotBool,
+		},
+		{
+			name: "Key is found but Null",
+			input: inType{
+				key: "metadata",
+			},
+			expectedErr: ErrNullJSON,
+		},
+		{
+			name: "Valid Bool",
+			input: inType{
+				key: "inProgress",
+			},
+			expected: ptrBool(false),
+		},
+		{
+			name: "Reaching for nested bool",
+			input: inType{
+				key:      "purchased",
+				optional: false,
+				zoom:     []string{"payload", "notes", "body"},
+			},
+			expected: ptrBool(true),
+		},
+		{
+			name: "Non existent zoom path is ok for optional bool",
+			input: inType{
+				key:      "applicable",
+				optional: true,
+				zoom:     []string{"payload", "location", "address"},
+			},
+			expected: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			output, err := New(j, tt.input.zoom...).Bool(tt.input.key, tt.input.optional)
 			assertJSONManagerOutput(t, tt.name, tt.expected, tt.expectedErr, output, err)
 		})
 	}
@@ -409,3 +497,7 @@ func ptrInt64(num int64) *int64 {
 func ptrString(text string) *string {
 	return &text
 }
+
+func ptrBool(flag bool) *bool {
+	return &flag
+}
diff --git a/salesforce/test/create-ok.json b/salesforce/test/create-ok.json
new file mode 100644
index 00000000..8a8f04dc
--- /dev/null
+++ b/salesforce/test/create-ok.json
@@ -0,0 +1,5 @@
+{
+  "id": "001ak00000OQTieAAH",
+  "success": true,
+  "errors": []
+}
\ No newline at end of file
diff --git a/salesforce/test/invalid-field-upsert.json b/salesforce/test/invalid-field-upsert.json
new file mode 100644
index 00000000..e5442ff3
--- /dev/null
+++ b/salesforce/test/invalid-field-upsert.json
@@ -0,0 +1,9 @@
+[
+  {
+    "message": "Unable to create/update fields: MasterRecordId. Please check the security settings of this field and verify that it is read/write for your profile or permission set.",
+    "errorCode": "INVALID_FIELD_FOR_INSERT_UPDATE",
+    "fields": [
+      "MasterRecordId"
+    ]
+  }
+]
\ No newline at end of file
diff --git a/salesforce/test/success-with-errors.json b/salesforce/test/success-with-errors.json
new file mode 100644
index 00000000..293d2bec
--- /dev/null
+++ b/salesforce/test/success-with-errors.json
@@ -0,0 +1,11 @@
+{
+  "id": "001RM000003oLruYAE",
+  "success": false,
+  "errors": [
+    {
+      "statusCode": "MALFORMED_ID",
+      "message": "malformed id 001RM000003oLrB000",
+      "fields": []
+    }
+  ]
+}
diff --git a/salesforce/test/unknown-field.json b/salesforce/test/unknown-field.json
new file mode 100644
index 00000000..c5eb5816
--- /dev/null
+++ b/salesforce/test/unknown-field.json
@@ -0,0 +1,6 @@
+[
+  {
+    "message": "No such column 'AccountNumer' on sobject of type Account",
+    "errorCode": "INVALID_FIELD"
+  }
+]
\ No newline at end of file
diff --git a/salesforce/write_test.go b/salesforce/write_test.go
new file mode 100644
index 00000000..e17900b2
--- /dev/null
+++ b/salesforce/write_test.go
@@ -0,0 +1,202 @@
+package salesforce
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/amp-labs/connectors/common"
+	"github.com/amp-labs/connectors/common/interpreter"
+	"github.com/amp-labs/connectors/test/utils/mockutils"
+	"github.com/go-test/deep"
+)
+
+func TestWrite(t *testing.T) { // nolint:funlen,cyclop
+	t.Parallel()
+
+	responseUnknownField := mockutils.DataFromFile(t, "unknown-field.json")
+	responseInvalidFieldUpsert := mockutils.DataFromFile(t, "invalid-field-upsert.json")
+	responseCreateOK := mockutils.DataFromFile(t, "create-ok.json")
+	responseOKWithErrors := mockutils.DataFromFile(t, "success-with-errors.json")
+
+	tests := []struct {
+		name         string
+		input        common.WriteParams
+		server       *httptest.Server
+		connector    Connector
+		comparator   func(actual, expected *common.WriteResult) bool // custom comparison
+		expected     *common.WriteResult
+		expectedErrs []error
+	}{
+		{
+			name: "Write object must be included",
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{common.ErrMissingObjects},
+		},
+		{
+			name:  "Mime response header expected",
+			input: common.WriteParams{ObjectName: "account"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.WriteHeader(http.StatusTeapot)
+			})),
+			expectedErrs: []error{interpreter.ErrMissingContentType},
+		},
+		{
+			name:  "Error response understood for creating with unknown field",
+			input: common.WriteParams{ObjectName: "account", RecordId: "003ak000004dQCUAA2"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseUnknownField)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("No such column 'AccountNumer' on sobject of type Account"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Error response understood for updating reserved field",
+			input: common.WriteParams{ObjectName: "account", RecordId: "003ak000004dQCUAA2"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				w.WriteHeader(http.StatusBadRequest)
+				_, _ = w.Write(responseInvalidFieldUpsert)
+			})),
+			expectedErrs: []error{
+				common.ErrBadRequest,
+				errors.New("Unable to create/update fields: MasterRecordId"), // nolint:goerr113
+			},
+		},
+		{
+			name:  "Write must act as a Create",
+			input: common.WriteParams{ObjectName: "account"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					// cannot be update path
+					mockutils.RespondToMissingQueryParameters(w, r, []string{"_HttpMethod"}, func() {
+						w.WriteHeader(http.StatusOK)
+					})
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Write must act as an Update",
+			input: common.WriteParams{ObjectName: "account", RecordId: "003ak000004dQCUAA2"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					mockutils.RespondToQueryParameters(w, r, url.Values{
+						"_HttpMethod": []string{"PATCH"},
+					}, func() {
+						w.WriteHeader(http.StatusOK)
+					})
+				})
+			})),
+			expected:     &common.WriteResult{Success: true},
+			expectedErrs: nil,
+		},
+		{
+			name:  "Valid creation of account",
+			input: common.WriteParams{ObjectName: "accounts"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(responseCreateOK)
+				})
+			})),
+			expected: &common.WriteResult{
+				Success:  true,
+				RecordId: "001ak00000OQTieAAH",
+				Errors:   []any{},
+				Data:     nil,
+			},
+			expectedErrs: nil,
+		},
+		{
+			name:  "OK Response, but with errors field",
+			input: common.WriteParams{ObjectName: "accounts"},
+			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				mockutils.RespondToMethod(w, r, "POST", func() {
+					w.WriteHeader(http.StatusOK)
+					_, _ = w.Write(responseOKWithErrors)
+				})
+			})),
+			expected: &common.WriteResult{
+				Success:  false,
+				RecordId: "001RM000003oLruYAE",
+				Errors: []any{map[string]any{
+					"statusCode": "MALFORMED_ID",
+					"message":    "malformed id 001RM000003oLrB000",
+					"fields":     []any{},
+				}},
+				Data: nil,
+			},
+			expectedErrs: nil,
+		},
+	}
+
+	for _, tt := range tests { // nolint:dupl
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			defer tt.server.Close()
+
+			ctx := context.Background()
+
+			connector, err := NewConnector(
+				WithAuthenticatedClient(http.DefaultClient),
+				WithWorkspace("test-workspace"),
+			)
+			if err != nil {
+				t.Fatalf("%s: error in test while constructing connector %v", tt.name, err)
+			}
+
+			// for testing we want to redirect calls to our server
+			connector.setBaseURL(tt.server.URL)
+
+			// start of tests
+			output, err := connector.Write(ctx, tt.input)
+			if len(tt.expectedErrs) == 0 && err != nil {
+				t.Fatalf("%s: expected no errors, got: (%v)", tt.name, err)
+			}
+
+			if len(tt.expectedErrs) != 0 && err == nil {
+				t.Fatalf("%s: expected errors (%v), but got nothing", tt.name, tt.expectedErrs)
+			}
+
+			for _, expectedErr := range tt.expectedErrs {
+				if !errors.Is(err, expectedErr) && !strings.Contains(err.Error(), expectedErr.Error()) {
+					t.Fatalf("%s: expected Error: (%v), got: (%v)", tt.name, expectedErr, err)
+				}
+			}
+
+			// compare desired output
+			var ok bool
+			if tt.comparator == nil {
+				// default comparison is concerned about all fields
+				ok = reflect.DeepEqual(output, tt.expected)
+			} else {
+				ok = tt.comparator(output, tt.expected)
+			}
+
+			if !ok {
+				diff := deep.Equal(output, tt.expected)
+				t.Fatalf("%s:, \nexpected: (%v), \ngot: (%v), \ndiff: (%v)", tt.name, tt.expected, output, diff)
+			}
+		})
+	}
+}