diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7f210eb..ac8e756 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -5,10 +5,10 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - name: Set up Go 1.17 + - name: Set up Go 1.19 uses: actions/setup-go@v1 with: - go-version: 1.17 + go-version: 1.19 id: go - name: Check out code into the Go module directory diff --git a/array.go b/array.go index f59fddd..c8b79c9 100644 --- a/array.go +++ b/array.go @@ -79,6 +79,54 @@ func (a *Asserter) checkArrayOrdered(path string, act, exp []interface{}) { } } +func (a *Asserter) checkContainsArray(path string, act, exp []interface{}) { + a.tt.Helper() + + if len(exp) > 0 && exp[0] == "<>" { + 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 + } + + a.checkContainsUnorderedArray(path, act, exp) +} + +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) + found = found || len(ap) == 0 + } + 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: +%s +not found in +%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{}, bool) { s = strings.TrimSpace(s) if s == "" { diff --git a/core.go b/core.go index 4eea7a0..ac79a18 100644 --- a/core.go +++ b/core.go @@ -62,6 +62,59 @@ func (a *Asserter) pathassertf(path, act, exp string) { } } +func (a *Asserter) pathContainsf(path, act, exp string) { + a.tt.Helper() + if act == exp { + return + } + actType, err := findType(act) + if err != nil { + 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 { //nolint:errchkjson // Can be confident this won't return an error: the // input will be a nested part of valid JSON, thus valid JSON diff --git a/exports.go b/exports.go index 4d384e7..923d3cb 100644 --- a/exports.go +++ b/exports.go @@ -127,3 +127,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/integration_test.go b/integration_test.go index c92252c..e1a2ab8 100644 --- a/integration_test.go +++ b/integration_test.go @@ -29,7 +29,7 @@ func TestAssertf(t *testing.T) { "strings": {`"hello world"`, `"hello world"`, nil}, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -45,7 +45,7 @@ func TestAssertf(t *testing.T) { "empty v non-empty string": {`""`, `"world"`, []string{`expected string at '$' to be 'world' but was ''`}}, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) }) @@ -83,7 +83,7 @@ func TestAssertf(t *testing.T) { }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -113,7 +113,7 @@ func TestAssertf(t *testing.T) { }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -152,7 +152,7 @@ func TestAssertf(t *testing.T) { }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) }) @@ -196,17 +196,14 @@ but expected JSON was: `["world"]`, []string{`expected string at '$[0]' to be 'world' but was 'hello'`}, }, - "different length non-empty arrays": { + "identical non-empty unsorted arrays": { `["hello", "world"]`, - `["world"]`, - []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"]`, - }, + `["<>", "world", "hello"]`, + []string{}, }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -248,7 +245,7 @@ but expected JSON was: }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -314,7 +311,7 @@ but expected JSON was: }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) }) @@ -346,7 +343,7 @@ potentially in a different order`, }, } { tc := tc - t.Run(name, func(t *testing.T) { tc.check(t) }) + t.Run(name, func(t *testing.T) { tc.checkAssertf(t) }) } }) @@ -449,20 +446,105 @@ but was was missing from actual payload`, }, } - tc.check(t) + tc.checkAssertf(t) }) } +func TestContainsf(t *testing.T) { + t.Parallel() + tt := map[string]*testCase{ + "actual not valid json": { + `foo`, + `"foo"`, + []string{`'actual' JSON is not valid JSON: unable to identify JSON type of "foo"`}, + }, + "expected not valid json": {`"foo"`, `foo`, []string{`'expected' JSON is not valid JSON: unable to identify JSON type of "foo"`}}, + "number contains a number": {`5`, `5`, nil}, + "number does not contain a different number": {`5`, `-2`, []string{"expected number at '$' to be '-2.0000000' but was '5.0000000'"}}, + "string contains a string": {`"foo"`, `"foo"`, nil}, + "string does not contain a different string": {`"foo"`, `"bar"`, []string{"expected string at '$' to be 'bar' but was 'foo'"}}, + "boolean contains a boolean": {`true`, `true`, nil}, + "boolean does not contain a different boolean": {`true`, `false`, []string{"expected boolean at '$' to be false but was true"}}, + "empty array contains empty array": {`[]`, `[]`, nil}, + "single-element array contains empty array": {`["fish"]`, `[]`, nil}, + "unordered empty array contains empty array": {`[]`, `["<>"]`, nil}, + "unordered single-element array contains empty array": {`["fish"]`, `["<>"]`, nil}, + "empty array contains single-element array": {`[]`, `["fish"]`, []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"]`}}, + "unordered multi-element array contains subset": {`["alpha", "beta", "gamma"]`, `["<>", "beta", "alpha"]`, nil}, + "unordered multi-element array does not contain single element": {`["alpha", "beta", "gamma"]`, `["<>", "delta", "alpha"]`, []string{ + `element at $[1] in the expected payload was not found anywhere in the actual JSON array: +"delta" +not found in +["alpha","beta","gamma"]`, + }}, + "unordered multi-element array contains none of multi-element array": {`["alpha", "beta", "gamma"]`, `["<>", "delta", "pi", "omega"]`, []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"]`, + }}, + "multi-element array contains itself": {`["alpha", "beta"]`, `["alpha", "beta"]`, nil}, + // NOTE: There's an important design decision to be made here. + // Currently, in the case of "Containsf" there's an implicit "<>" (if it's explicitly written it will be ignored) + // This is so that nested arrays don't have to repeatedly say "<> assuming the user just wants to check for the existence of some element of an array. + // However, this makes jsonassert useless for cases where you want to partially assert that an ordered array exists. + // Ideally this package should be able to support both nicely. + "multi-element array does contain itself permuted": {`["alpha", "beta"]`, `["beta" ,"alpha"]`, []string{}}, + // 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. + "multi-element array does contain its subset": {`["alpha", "beta"]`, `["beta"]`, []string{}}, + "multi-element array does not contain its superset": {`["alpha", "beta"]`, `["alpha", "beta", "gamma"]`, []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"]`}}, + "expected and actual have different types": {`{"foo": "bar"}`, `null`, []string{"actual JSON (object) and expected JSON (null) were of different types at '$'"}}, + "expected any value but got null": {`{"foo": null}`, `{"foo": "<>"}`, []string{"expected the presence of any value at '$.foo', but was absent"}}, + "unordered multi-element array of different types contains subset": {`["alpha", 5, false, ["foo"], {"bar": "baz"}]`, `["<>", 5, "alpha", {"bar": "baz"}]`, nil}, + "object contains its subset": {`{"foo": "bar", "alpha": "omega"}`, `{"alpha": "omega"}`, nil}, + /* + "array inside object": { + `{ "arr": [ { "fork": { "start": "stop" }, "nested": ["really", "fast"] } ] }`, + `{ "arr": [ "<>", { "fork": { "start": "stop" }, "nested": ["<>", "fast"] } ] }`, + nil, + }, + */ + } + for name, tc := range tt { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + tc.checkContainsf(t) + }) + } +} + type testCase struct { act, exp string msgs []string } -func (tc *testCase) check(t *testing.T) { +func (tc *testCase) checkContainsf(t *testing.T) { + t.Helper() + tp := &testPrinter{messages: nil} + jsonassert.New(tp).Containsf(tc.act, tc.exp) + tc.check(t, tp) +} + +func (tc *testCase) checkAssertf(t *testing.T) { t.Helper() tp := &testPrinter{messages: nil} jsonassert.New(tp).Assertf(tc.act, tc.exp) + tc.check(t, tp) +} +func (tc *testCase) check(t *testing.T, tp *testPrinter) { + t.Helper() if got := len(tp.messages); got != len(tc.msgs) { t.Errorf("expected %d assertion message(s) but got %d", len(tc.msgs), got) } diff --git a/object.go b/object.go index f60bfc3..c645145 100644 --- a/object.go +++ b/object.go @@ -23,6 +23,20 @@ func (a *Asserter) checkObject(path string, act, exp map[string]interface{}) { } } +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(act, exp map[string]interface{}) []string { unique := []string{} for key := range act {