Skip to content

containsf revisited #42

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

Open
wants to merge 3 commits into
base: main
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
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions array.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] == "<<UNORDERED>>" {
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 "<<UNORDERED>>"
}
}
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 == "" {
Expand Down
53 changes: 53 additions & 0 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "<<PRESENCE>>" {
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
Expand Down
7 changes: 7 additions & 0 deletions exports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...))
}
116 changes: 99 additions & 17 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
}
})

Expand All @@ -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) })
}
})
})
Expand Down Expand Up @@ -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) })
}
})

Expand Down Expand Up @@ -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) })
}
})

Expand Down Expand Up @@ -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) })
}
})
})
Expand Down Expand Up @@ -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"]`,
},
`["<<UNORDERED>>", "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) })
}
})

Expand Down Expand Up @@ -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) })
}
})

Expand Down Expand Up @@ -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) })
}
})
})
Expand Down Expand Up @@ -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) })
}
})

Expand Down Expand Up @@ -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": {`[]`, `["<<UNORDERED>>"]`, nil},
"unordered single-element array contains empty array": {`["fish"]`, `["<<UNORDERED>>"]`, 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"]`, `["<<UNORDERED>>", "beta", "alpha"]`, nil},
"unordered multi-element array does not contain single element": {`["alpha", "beta", "gamma"]`, `["<<UNORDERED>>", "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"]`, `["<<UNORDERED>>", "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 "<<UNORDERED>>" (if it's explicitly written it will be ignored)
// This is so that nested arrays don't have to repeatedly say "<<UNORDERED">> 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.
Copy link
Owner Author

@kinbiko kinbiko Jun 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea 1

  • Assertf: Implicit "<<ORDERED>>", explicit "<<UNORDERED>>"
  • Containsf: Implicit "<<UNORDERED>>", explicit "<<ORDERED>>"

When *f is called with an implicit directive it is ignored.

When *f is called with an explicit directive it applies on only that array.

"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 "<<UNORDERED>>" 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": "<<PRESENCE>>"}`, []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"}]`, `["<<UNORDERED>>", 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": [ "<<UNORDERED>>", { "fork": { "start": "stop" }, "nested": ["<<UNORDERED>>", "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)
}
Expand Down
14 changes: 14 additions & 0 deletions object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down