diff --git a/array.go b/array.go index 0e4b353..d072b3a 100644 --- a/array.go +++ b/array.go @@ -8,6 +8,13 @@ import ( func (a *Asserter) checkArray(path string, act, exp []interface{}) { a.tt.Helper() + + var unordered bool + if len(exp) > 0 && exp[0] == "<>" { + unordered = true + exp = exp[1:] + } + if len(act) != len(exp) { a.tt.Errorf("length of arrays at '%s' were different. Expected array to be of length %d, but contained %d element(s)", path, len(exp), len(act)) serializedAct, serializedExp := serialize(act), serialize(exp) @@ -18,11 +25,86 @@ func (a *Asserter) checkArray(path string, act, exp []interface{}) { } return } + + if unordered { + a.checkUnorderedArray(path, act, exp) + return + } + for i := range act { a.pathassertf(path+fmt.Sprintf("[%d]", i), serialize(act[i]), serialize(exp[i])) } } +func (a *Asserter) checkUnorderedArray(path string, act, exp []interface{}) { + for i := range act { + hasMatch := false + for j := range act { + ap := arrayPrinter{} + New(&ap).pathassertf("", serialize(act[i]), serialize(exp[j])) + hasMatch = hasMatch || len(ap) == 0 + } + if !hasMatch { + serializedAct, serializedExp := serialize(act), serialize(exp) + a.tt.Errorf("elements at '%s' are different, even when ignoring order within the array:\nexpected some ordering of\n%s\nbut got\n%s", path, serializedExp, serializedAct) + } + } +} + +func (a *Asserter) checkContainsArray(path string, act, exp []interface{}) { + a.tt.Helper() + + var unordered bool + if len(exp) > 0 && exp[0] == "<>" { + unordered = true + exp = exp[1:] + } + + if len(act) < len(exp) { + a.tt.Errorf("length of expected array at '%s' was longer (length %d) than the actual array (length %d)", path, len(exp), len(act)) + serializedAct, serializedExp := serialize(act), serialize(exp) + a.tt.Errorf("actual JSON at '%s' was: %+v, but expected JSON to contain: %+v", path, serializedAct, serializedExp) + return + } + + if unordered { + a.checkContainsUnorderedArray(path, act, exp) + return + } + for i := range exp { + a.pathContainsf(fmt.Sprintf("%s[%d]", path, i), serialize(act[i]), serialize(exp[i])) + } +} + +func (a *Asserter) checkContainsUnorderedArray(path string, act, exp []interface{}) { + mismatchedExpPaths := map[string]string{} + for i := range exp { + found := false + serializedExp := serialize(exp[i]) + for j := range act { + ap := arrayPrinter{} + serializedAct := serialize(act[j]) + New(&ap).pathContainsf("", serializedAct, serializedExp) + if len(ap) == 0 { + found = true + } + } + if !found { + mismatchedExpPaths[fmt.Sprintf("%s[%d]", path, i+1)] = serializedExp // + 1 because 0th element is "<>" + } + } + for path, serializedExp := range mismatchedExpPaths { + a.tt.Errorf("element at %s in the expected payload was not found anywhere in the actual JSON array:\n%s\nnot found in\n%s", path, serializedExp, serialize(act)) + } +} + +type arrayPrinter []string + +func (p *arrayPrinter) Errorf(msg string, args ...interface{}) { + n := append(*p, fmt.Sprintf(msg, args...)) + *p = n +} + func extractArray(s string) ([]interface{}, error) { s = strings.TrimSpace(s) if len(s) == 0 { diff --git a/core.go b/core.go index 9666485..f439202 100644 --- a/core.go +++ b/core.go @@ -2,7 +2,6 @@ package jsonassert import ( "encoding/json" - "errors" "fmt" "strings" ) @@ -59,13 +58,62 @@ func (a *Asserter) pathassertf(path, act, exp string) { } } -func serialize(a interface{}) string { - bytes, err := json.Marshal(a) +func (a *Asserter) pathContainsf(path, act, exp string) { + a.tt.Helper() + if act == exp { + return + } + actType, err := findType(act) if err != nil { - // Really don't want to panic here, but I can't see a reasonable solution. - // If this line *does* get executed then we should really investigate what kind of input was given - panic(errors.New("unexpected failure to re-serialize nested JSON. Please raise an issue including this error message and both the expected and actual JSON strings you used to trigger this panic" + err.Error())) + a.tt.Errorf("'actual' JSON is not valid JSON: " + err.Error()) + return + } + expType, err := findType(exp) + if err != nil { + a.tt.Errorf("'expected' JSON is not valid JSON: " + err.Error()) + return + } + + // If we're only caring about the presence of the key, then don't bother checking any further + if expPresence, _ := extractString(exp); expPresence == "<>" { + if actType == jsonNull { + a.tt.Errorf(`expected the presence of any value at '%s', but was absent`, path) + } + return + } + + if actType != expType { + a.tt.Errorf("actual JSON (%s) and expected JSON (%s) were of different types at '%s'", actType, expType, path) + return + } + switch expType { + case jsonBoolean: + actBool, _ := extractBoolean(act) + expBool, _ := extractBoolean(exp) + a.checkBoolean(path, actBool, expBool) + case jsonNumber: + actNumber, _ := extractNumber(act) + expNumber, _ := extractNumber(exp) + a.checkNumber(path, actNumber, expNumber) + case jsonString: + actString, _ := extractString(act) + expString, _ := extractString(exp) + a.checkString(path, actString, expString) + case jsonObject: + actObject, _ := extractObject(act) + expObject, _ := extractObject(exp) + a.checkContainsObject(path, actObject, expObject) + case jsonArray: + actArray, _ := extractArray(act) + expArray, _ := extractArray(exp) + a.checkContainsArray(path, actArray, expArray) + case jsonNull: + // Intentionally don't check as it wasn't expected in the payload } +} + +func serialize(a interface{}) string { + bytes, _ := json.Marshal(a) return string(bytes) } diff --git a/exports.go b/exports.go index e11fcb7..3a00378 100644 --- a/exports.go +++ b/exports.go @@ -104,3 +104,10 @@ func (a *Asserter) Assertf(actualJSON, expectedJSON string, fmtArgs ...interface a.tt.Helper() a.pathassertf("$", actualJSON, fmt.Sprintf(expectedJSON, fmtArgs...)) } + +// TODO: remember to document what happens if you call Containsf with a null +// property as currently it will treat it as the key being missing. +func (a *Asserter) Containsf(actualJSON, expectedJSON string, fmtArgs ...interface{}) { + a.tt.Helper() + a.pathContainsf("$", actualJSON, fmt.Sprintf(expectedJSON, fmtArgs...)) +} diff --git a/go.mod b/go.mod index ea7e5da..7f381c8 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,3 @@ module github.com/kinbiko/jsonassert + +go 1.12 diff --git a/integration_test.go b/integration_test.go index dc9aabb..20ffb5b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -74,6 +74,14 @@ but expected JSON was: {name: "different non-empty arrays", act: `["hello"]`, exp: `["world"]`, msgs: []string{ `expected string at '$[0]' to be 'world' but was 'hello'`, }}, + {name: "identical non-empty unsorted arrays", act: `["hello", "world"]`, exp: `["<>", "world", "hello"]`, msgs: []string{}}, + {name: "different non-empty unsorted arrays", act: `["hello", "world"]`, exp: `["<>", "世界", "hello"]`, msgs: []string{ + `elements at '$' are different, even when ignoring order within the array: +expected some ordering of +["世界","hello"] +but got +["hello","world"]`, + }}, {name: "different length non-empty arrays", act: `["hello", "world"]`, exp: `["world"]`, msgs: []string{ `length of arrays at '$' were different. Expected array to be of length 1, but contained 2 element(s)`, `actual JSON at '$' was: ["hello","world"], but expected JSON was: ["world"]`, @@ -117,6 +125,160 @@ but expected JSON was: } } +func TestContainsf(t *testing.T) { + tt := []struct { + name string + act string + exp string + msgs []string + }{ + {name: "actual not valid json", act: `foo`, exp: `"foo"`, msgs: []string{ + `'actual' JSON is not valid JSON: unable to identify JSON type of "foo"`, + }}, + {name: "expected not valid json", act: `"foo"`, exp: `foo`, msgs: []string{ + `'expected' JSON is not valid JSON: unable to identify JSON type of "foo"`, + }}, + {name: "number contains a number", act: `5`, exp: `5`, msgs: nil}, + {name: "number does not contain a different number", act: `5`, exp: `-2`, msgs: []string{ + "expected number at '$' to be '-2.0000000' but was '5.0000000'", + }}, + {name: "string contains a string", act: `"foo"`, exp: `"foo"`, msgs: nil}, + {name: "string does not contain a different string", act: `"foo"`, exp: `"bar"`, msgs: []string{ + "expected string at '$' to be 'bar' but was 'foo'", + }}, + {name: "boolean contains a boolean", act: `true`, exp: `true`, msgs: nil}, + {name: "boolean does not contain a different boolean", act: `true`, exp: `false`, msgs: []string{ + "expected boolean at '$' to be false but was true", + }}, + {name: "empty array contains empty array", act: `[]`, exp: `[]`, msgs: nil}, + {name: "single-element array contains empty array", act: `["fish"]`, exp: `[]`, msgs: nil}, + {name: "unordered empty array contains empty array", act: `[]`, exp: `["<>"]`, msgs: nil}, + {name: "unordered single-element array contains empty array", act: `["fish"]`, exp: `["<>"]`, msgs: nil}, + {name: "empty array contains single-element array", act: `[]`, exp: `["fish"]`, msgs: []string{ + "length of expected array at '$' was longer (length 1) than the actual array (length 0)", + `actual JSON at '$' was: [], but expected JSON to contain: ["fish"]`, + }}, + {name: "unordered multi-element array contains subset", act: `["alpha", "beta", "gamma"]`, exp: `["<>", "beta", "alpha"]`, msgs: nil}, + {name: "unordered multi-element array does not contain single element", act: `["alpha", "beta", "gamma"]`, exp: `["<>", "delta", "alpha"]`, msgs: []string{ + `element at $[1] in the expected payload was not found anywhere in the actual JSON array: +"delta" +not found in +["alpha","beta","gamma"]`, + }}, + {name: "unordered multi-element array contains none of multi-element array", act: `["alpha", "beta", "gamma"]`, exp: `["<>", "delta", "pi", "omega"]`, msgs: []string{ + `element at $[1] in the expected payload was not found anywhere in the actual JSON array: +"delta" +not found in +["alpha","beta","gamma"]`, + `element at $[2] in the expected payload was not found anywhere in the actual JSON array: +"pi" +not found in +["alpha","beta","gamma"]`, + `element at $[3] in the expected payload was not found anywhere in the actual JSON array: +"omega" +not found in +["alpha","beta","gamma"]`, + }}, + {name: "multi-element array contains itself", act: `["alpha", "beta"]`, exp: `["alpha", "beta"]`, msgs: nil}, + {name: "multi-element array does not contain itself permuted", act: `["alpha", "beta"]`, exp: `["beta" ,"alpha"]`, msgs: []string{ + "expected string at '$[0]' to be 'beta' but was 'alpha'", + "expected string at '$[1]' to be 'alpha' but was 'beta'", + }}, + // Allow users to test against a subset of the payload without erroring out. + // This is to avoid the frustraion and unintuitive solution of adding "<>" in order to "enable" subsetting, + // which is really implied with the `contains` part of the API name. + {name: "multi-element array does contain its subset", act: `["alpha", "beta"]`, exp: `["alpha"]`, msgs: []string{}}, + {name: "multi-element array does not contain its superset", act: `["alpha", "beta"]`, exp: `["alpha", "beta", "gamma"]`, msgs: []string{ + "length of expected array at '$' was longer (length 3) than the actual array (length 2)", + `actual JSON at '$' was: ["alpha","beta"], but expected JSON to contain: ["alpha","beta","gamma"]`, + }}, + {name: "expected and actual have different types", act: `{"foo": "bar"}`, exp: `null`, msgs: []string{ + "actual JSON (object) and expected JSON (null) were of different types at '$'", + }}, + {name: "expected any value, but got null", act: `{"foo": null}`, exp: `{"foo": "<>"}`, msgs: []string{ + "expected the presence of any value at '$.foo', but was absent", + }}, + {name: "unordered multi-element array of different types contains subset", act: `["alpha", 5, false, ["foo"], {"bar": "baz"}]`, exp: `["<>", 5, "alpha", {"bar": "baz"}]`, msgs: nil}, + + {name: "object contains its subset", act: `{"foo": "bar", "alpha": "omega"}`, exp: `{"alpha": "omega"}`, msgs: nil}, + /* + { + name: "big fat test", + act: `{ + "arr": [ + "alpha", + 5, + false, + ["foo"], + { + "bar": "baz", + "fork": { + "start": "stop" + }, + "nested": ["really", "fast"] + } + ], + "fish": "mooney" + }`, + exp: `{ + "arr": [ + "<>", + 5, + { + "fork": { + "start": "stop" + }, + "nested": ["fast"] + } + ] + }`, msgs: nil}, + */ + } + for _, tc := range tt { + t.Run(tc.name, func(st *testing.T) { + tp, ja := setup() + ja.Containsf(tc.act, tc.exp) + if got := len(tp.messages); got != len(tc.msgs) { + st.Errorf("expected %d assertion message(s) but got %d", len(tc.msgs), got) + if len(tc.msgs) > 0 { + st.Errorf("Expected the following messages:") + for _, msg := range tc.msgs { + st.Errorf(" - %s", msg) + } + } + + if len(tp.messages) > 0 { + st.Errorf("Got the following messages:") + for _, msg := range tp.messages { + st.Errorf(" - %s", msg) + } + } + return + } + + if len(tc.msgs) == 1 { + if exp, got := tc.msgs[0], tp.messages[0]; got != exp { + st.Errorf("expected assertion message:\n'%s'\nbut got\n'%s'", exp, got) + } + return + } + + for _, exp := range tc.msgs { + found := false + for _, got := range tp.messages { + if got == exp { + found = true + } + } + if !found { + st.Errorf("couldn't find expected assertion message:\n'%s'", exp) + } + } + }) + } + +} + func setup() (*testPrinter, *jsonassert.Asserter) { tp := &testPrinter{} return tp, jsonassert.New(tp) diff --git a/object.go b/object.go index 388ecd7..de3b617 100644 --- a/object.go +++ b/object.go @@ -24,10 +24,24 @@ func (a *Asserter) checkObject(path string, act, exp map[string]interface{}) { } } -func difference(act, exp map[string]interface{}) []string { +func (a *Asserter) checkContainsObject(path string, act, exp map[string]interface{}) { + a.tt.Helper() + + if missingExpected := difference(exp, act); len(missingExpected) != 0 { + a.tt.Errorf("expected object key(s) %+v missing at '%s'", serialize(missingExpected), path) + } + for key := range exp { + if contains(act, key) { + a.pathContainsf(path+"."+key, serialize(act[key]), serialize(exp[key])) + } + } +} + +// difference returns a slice of the keys that were found in a but not in b. +func difference(a, b map[string]interface{}) []string { unique := []string{} - for key := range act { - if !contains(exp, key) { + for key := range a { + if !contains(b, key) { unique = append(unique, key) } }