Skip to content

Commit 9d6db7c

Browse files
authored
add matchers and jsonhelpers packages (#12)
1 parent 5dfcdd3 commit 9d6db7c

26 files changed

+2153
-0
lines changed

.circleci/config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ orbs:
66
workflows:
77
workflow:
88
jobs:
9+
- go-test:
10+
name: Go 1.17
11+
docker-image: cimg/go:1.17
912
- go-test:
1013
name: Go 1.16
1114
docker-image: cimg/go:1.16

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ The main package provides general-purpose helper functions.
1414

1515
Subpackage `httphelpers` provides convenience wrappers for using `net/http` and `net/http/httptest` in test code.
1616

17+
Subpackage `jsonhelpers` provides functions for manipulating JSON.
18+
1719
Subpackage `ldservices` is specifically for testing LaunchDarkly SDK client components; it provides HTTP handlers that simulate the service endpoints used by the SDK.
1820

21+
Subpackage `matchers` contains a test assertion API with combinators.
22+
1923
Subpackage `testbox` provides the ability to write tests-of-tests within the Go testing framework.
2024

2125
## Usage

jsonhelpers/diff_json.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package jsonhelpers
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"reflect"
7+
"sort"
8+
"strings"
9+
)
10+
11+
// JSONDiffResult is a list of JSONDiffElement values returned by JSONDiff.
12+
type JSONDiffResult []JSONDiffElement
13+
14+
// Describe returns a list of string descriptions of the differences.
15+
func (r JSONDiffResult) Describe(value1Name, value2Name string) []string {
16+
ret := make([]string, 0, len(r))
17+
for _, e := range r {
18+
ret = append(ret, e.Describe(value1Name, value2Name))
19+
}
20+
return ret
21+
}
22+
23+
// JSONDiffElement describes a point of difference between two JSON data structures.
24+
type JSONDiffElement struct {
25+
// Path represents the location of the data as a path from the root.
26+
Path JSONPath
27+
28+
// Value1 is the JSON encoding of the value at that path in the data structure
29+
// that was passed to JSONDiff as json1. An empty string (as opposed to the JSON
30+
// representation of an empty string, `""`) means that this property was missing
31+
// in json1.
32+
Value1 string
33+
34+
// Value2 is the JSON encoding of the value at that path in the data structure
35+
// that was passed to JSONDiff as json2. An empty string (as opposed to the JSON
36+
// representation of an empty string, `""`) means that this property was missing
37+
// in json2.
38+
Value2 string
39+
}
40+
41+
// Describe returns a string description of this difference.
42+
func (e JSONDiffElement) Describe(value1Name, value2Name string) string {
43+
var desc1, desc2 = e.Value1, e.Value2
44+
if desc1 == "" {
45+
desc1 = "<absent>"
46+
}
47+
if desc2 == "" {
48+
desc2 = "<absent>"
49+
}
50+
pathPrefix := ""
51+
if len(e.Path) != 0 {
52+
pathPrefix = fmt.Sprintf("at %s: ", e.Path)
53+
}
54+
return fmt.Sprintf("%s%s = %s, %s = %s", pathPrefix, value1Name, desc1, value2Name, desc2)
55+
}
56+
57+
// JSONPath represents the location of a node in a JSON data structure.
58+
//
59+
// In a JSON object {"a":{"b":2}}, the nested "b":2 property would be referenced as
60+
// JSONPath{{Property: "a"}, {Property: "b"}}.
61+
//
62+
// In a JSON array ["a","b",["c"]], the "c" value would be referenced as
63+
// JSONPath{{Index: 2},{Index: 0}}.
64+
//
65+
// A nil or zero-length slice represents the root of the data.
66+
type JSONPath []JSONPathComponent
67+
68+
// String returns a string representation of the path.
69+
func (p JSONPath) String() string {
70+
parts := make([]string, 0, len(p))
71+
for _, c := range p {
72+
if c.Property == "" {
73+
parts = append(parts, fmt.Sprintf("[%d]", c.Index))
74+
} else {
75+
parts = append(parts, fmt.Sprintf(`"%s"`, c.Property))
76+
}
77+
}
78+
return strings.Join(parts, ".")
79+
}
80+
81+
// JSONPathComponent represents a location within the top level of a JSON object or array.
82+
type JSONPathComponent struct {
83+
// Property is the name of an object property, or "" if this is in an array.
84+
Property string
85+
86+
// Index is the zero-based index of an array element, if this is in an array.
87+
Index int
88+
}
89+
90+
// JSONDiff compares two JSON values and returns an explanation of how they differ, if at all,
91+
// ignoring any differences that do not affect the value semantically (such as whitespace).
92+
// This is for programmatic use; if you want a human-readable test assertion based on the
93+
// same logic, see matchers.JSONEqual (which calls this function).
94+
//
95+
// The two values are provided as marshalled JSON data. If they cannot be parsed, the
96+
// function immediately returns an error.
97+
//
98+
// If the values are deeply equal, the result is nil.
99+
//
100+
// Otherwise, if they are both simple values, the result will contain a single
101+
// JSONDiffElement.
102+
//
103+
// If they are both JSON objects, JSONDiff will compare their properties. It will produce
104+
// a JSONDiffElement for each property where they differ. For instance, comparing
105+
// {"a": 1, "b": 2} with {"a": 1, "b": 3, "c": 4} will produce one element for "b" and
106+
// one for "c". If a property contains an object value on both sides, the comparison will
107+
// proceed recursively and may produce elements with subpaths (see JSONPath).
108+
//
109+
// If they are both JSON arrays, and are of the same length, JSONDiff will compare their
110+
// elements using the same rules as above. For JSON arrays of different lengths, if the
111+
// shorter one matches every corresponding element of the longer one, it will return a
112+
// JSONDiffElement pointing to the first element after the shorter one and listing the
113+
// additional elements starting with a comma (for instance, comparing [10,20] with
114+
// [10,20,30] will return a string of ",30" at index 2); otherwise it will just return
115+
// both arrays in their entirety.
116+
//
117+
// Values that are not of the same type will always produce a single JSONDiffElement
118+
// describing the entire values.
119+
func JSONDiff(json1, json2 []byte) (JSONDiffResult, error) {
120+
var value1, value2 interface{}
121+
if err := json.Unmarshal(json1, &value1); err != nil {
122+
return nil, err
123+
}
124+
if err := json.Unmarshal(json2, &value2); err != nil {
125+
return nil, err
126+
}
127+
return describeValueDifference(value1, value2, nil), nil
128+
}
129+
130+
func describeValueDifference(value1, value2 interface{}, path JSONPath) JSONDiffResult {
131+
if a1, ok := value1.([]interface{}); ok {
132+
if a2, ok := value2.([]interface{}); ok {
133+
return describeArrayValueDifference(a1, a2, path)
134+
}
135+
}
136+
if o1, ok := value1.(map[string]interface{}); ok {
137+
if o2, ok := value2.(map[string]interface{}); ok {
138+
return describeObjectValueDifference(o1, o2, path)
139+
}
140+
}
141+
if reflect.DeepEqual(value1, value2) {
142+
return nil
143+
}
144+
return JSONDiffResult{
145+
{Path: path, Value1: ToJSONString(value1), Value2: ToJSONString(value2)},
146+
}
147+
}
148+
149+
func describeArrayValueDifference(array1, array2 []interface{}, path JSONPath) JSONDiffResult {
150+
if len(array1) != len(array2) {
151+
// Check for the case where one is a shorter version of the other but the same up to that point
152+
if len(array1) != 0 && len(array2) != 0 {
153+
shortestCommonLength := len(array1)
154+
if shortestCommonLength > len(array2) {
155+
shortestCommonLength = len(array2)
156+
}
157+
foundUnequal := false
158+
for i := 0; i < shortestCommonLength; i++ {
159+
if !reflect.DeepEqual(array1[i], array2[i]) {
160+
foundUnequal = true
161+
break
162+
}
163+
}
164+
if !foundUnequal {
165+
var remainder []interface{}
166+
if len(array1) == shortestCommonLength {
167+
remainder = array2[shortestCommonLength:]
168+
} else {
169+
remainder = array1[shortestCommonLength:]
170+
}
171+
remainderStr := ToJSONString(remainder)
172+
remainderStr = "," + strings.TrimSuffix(strings.TrimPrefix(remainderStr, "["), "]")
173+
ret := JSONDiffElement{
174+
Path: append(append(JSONPath(nil), path...), JSONPathComponent{Index: shortestCommonLength}),
175+
}
176+
if len(array1) == shortestCommonLength {
177+
ret.Value2 = remainderStr
178+
} else {
179+
ret.Value1 = remainderStr
180+
}
181+
return JSONDiffResult{ret}
182+
}
183+
}
184+
return JSONDiffResult{
185+
{Path: path, Value1: ToJSONString(array1), Value2: ToJSONString(array2)},
186+
}
187+
}
188+
189+
var diffs JSONDiffResult //nolint:prealloc
190+
191+
for i, value1 := range array1 {
192+
subpath := append(append(JSONPath(nil), path...), JSONPathComponent{Index: i})
193+
value2 := array2[i]
194+
diffs = append(diffs, describeValueDifference(value1, value2, subpath)...)
195+
}
196+
197+
return diffs
198+
}
199+
200+
func describeObjectValueDifference(object1, object2 map[string]interface{}, path JSONPath) JSONDiffResult {
201+
allKeys := make(map[string]struct{})
202+
for key := range object1 {
203+
allKeys[key] = struct{}{}
204+
}
205+
for key := range object2 {
206+
allKeys[key] = struct{}{}
207+
}
208+
allSortedKeys := make([]string, 0, len(allKeys))
209+
for key := range allKeys {
210+
allSortedKeys = append(allSortedKeys, key)
211+
}
212+
sort.Strings(allSortedKeys)
213+
214+
var diffs JSONDiffResult //nolint:prealloc
215+
216+
for _, key := range allSortedKeys {
217+
subpath := append(append(JSONPath(nil), path...), JSONPathComponent{Property: key})
218+
219+
var desc1, desc2 = "", ""
220+
if value1, ok := object1[key]; ok {
221+
if value2, ok := object2[key]; ok {
222+
if reflect.DeepEqual(value1, value2) {
223+
continue
224+
}
225+
diffs = append(diffs, describeValueDifference(value1, value2, subpath)...)
226+
continue
227+
} else {
228+
desc1 = string(CanonicalizeJSON(ToJSON(value1)))
229+
}
230+
} else {
231+
desc2 = string(CanonicalizeJSON(ToJSON(object2[key])))
232+
}
233+
diffs = append(diffs, JSONDiffElement{
234+
Path: subpath,
235+
Value1: desc1,
236+
Value2: desc2,
237+
})
238+
}
239+
return diffs
240+
}

jsonhelpers/diff_json_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package jsonhelpers
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestJSONDiff(t *testing.T) {
12+
diffResult := func(t *testing.T, value1, value2 []byte) JSONDiffResult {
13+
diff, err := JSONDiff(value1, value2)
14+
assert.NoError(t, err)
15+
return diff
16+
}
17+
18+
t.Run("equality and inequality without detailed diff", func(t *testing.T) {
19+
values := []interface{}{
20+
nil,
21+
true,
22+
false,
23+
3,
24+
3.5,
25+
"x",
26+
[]string{"a", "b"},
27+
map[string]interface{}{"a": []int{1, 2}},
28+
}
29+
for i, value1 := range values {
30+
jsonValue1, _ := json.Marshal(value1)
31+
t.Run(fmt.Sprintf("%s == %s", string(jsonValue1), string(jsonValue1)), func(t *testing.T) {
32+
assert.Nil(t, diffResult(t, jsonValue1, jsonValue1))
33+
})
34+
for j, value2 := range values {
35+
if j == i {
36+
continue
37+
}
38+
jsonValue2, _ := json.Marshal(value2)
39+
t.Run(fmt.Sprintf("%s != %s", string(jsonValue1), string(jsonValue2)), func(t *testing.T) {
40+
diff := diffResult(t, jsonValue1, jsonValue2)
41+
assert.Len(t, diff, 1)
42+
assert.Nil(t, diff[0].Path)
43+
assert.Equal(t, string(jsonValue1), diff[0].Value1)
44+
assert.Equal(t, string(jsonValue2), diff[0].Value2)
45+
})
46+
}
47+
}
48+
})
49+
50+
t.Run("inequality with object diff", func(t *testing.T) {
51+
assert.Equal(t, JSONDiffResult{
52+
{Path: JSONPath{{Property: "b"}}, Value1: "2", Value2: "3"},
53+
}, diffResult(t, []byte(`{"a":1,"b":2}`), []byte(`{"a":1,"b":3}`)))
54+
55+
assert.Equal(t, JSONDiffResult{
56+
{Path: JSONPath{{Property: "b"}}, Value1: "2", Value2: ""},
57+
}, diffResult(t, []byte(`{"a":1,"b":2}`), []byte(`{"a":1}`)))
58+
59+
assert.Equal(t, JSONDiffResult{
60+
{Path: JSONPath{{Property: "b"}}, Value1: "", Value2: "2"},
61+
}, diffResult(t, []byte(`{"a":1}`), []byte(`{"a":1,"b":2}`)))
62+
63+
assert.Equal(t, JSONDiffResult{
64+
{Path: JSONPath{{Property: "b"}, {Property: "c"}}, Value1: "2", Value2: "3"},
65+
}, diffResult(t, []byte(`{"a":1,"b":{"c":2}}`), []byte(`{"a":1,"b":{"c":3}}`)))
66+
67+
assert.Equal(t, JSONDiffResult{
68+
{Path: JSONPath{{Property: "b"}, {Index: 1}}, Value1: `"d"`, Value2: `"e"`},
69+
}, diffResult(t, []byte(`{"a":1,"b":["c","d"]}`), []byte(`{"a":1,"b":["c","e"]}`)))
70+
})
71+
72+
t.Run("inequality with array diff", func(t *testing.T) {
73+
assert.Equal(t, JSONDiffResult{
74+
{Path: JSONPath{{Index: 1}}, Value1: `"b"`, Value2: `"c"`},
75+
}, diffResult(t, []byte(`["a","b"]`), []byte(`["a","c"]`)))
76+
77+
assert.Equal(t, JSONDiffResult{
78+
{Path: JSONPath{{Index: 1}, {Property: "b"}}, Value1: `2`, Value2: `3`},
79+
}, diffResult(t, []byte(`["a",{"b":2}]`), []byte(`["a",{"b":3}]`)))
80+
81+
assert.Equal(t, JSONDiffResult{
82+
{Path: JSONPath{{Index: 2}}, Value1: ``, Value2: `,"c"`},
83+
}, diffResult(t, []byte(`["a","b"]`), []byte(`["a","b","c"]`)))
84+
85+
assert.Equal(t, JSONDiffResult{
86+
{Path: JSONPath{{Index: 2}}, Value1: `,"c"`, Value2: ``},
87+
}, diffResult(t, []byte(`["a","b","c"]`), []byte(`["a","b"]`)))
88+
89+
assert.Equal(t, JSONDiffResult{
90+
{Path: nil, Value1: `["a","d"]`, Value2: `["a","b","c"]`},
91+
}, diffResult(t, []byte(`["a","d"]`), []byte(`["a","b","c"]`)))
92+
})
93+
}
94+
95+
func TestJSONDiffResultStrings(t *testing.T) {
96+
assert.Equal(t, "x = abc, y = def",
97+
JSONDiffElement{Value1: "abc", Value2: "def"}.Describe("x", "y"))
98+
99+
assert.Equal(t, `at "prop1": x = abc, y = def`,
100+
JSONDiffElement{Path: JSONPath{{Property: "prop1"}}, Value1: "abc", Value2: "def"}.
101+
Describe("x", "y"))
102+
103+
assert.Equal(t, `at "prop1"."prop2": x = abc, y = def`,
104+
JSONDiffElement{Path: JSONPath{{Property: "prop1"}, {Property: "prop2"}}, Value1: "abc", Value2: "def"}.
105+
Describe("x", "y"))
106+
107+
assert.Equal(t, "at [1]: x = abc, y = def",
108+
JSONDiffElement{Path: JSONPath{{Index: 1}}, Value1: "abc", Value2: "def"}.
109+
Describe("x", "y"))
110+
111+
assert.Equal(t, `at [1]."prop2": x = abc, y = def`,
112+
JSONDiffElement{Path: JSONPath{{Index: 1}, {Property: "prop2"}}, Value1: "abc", Value2: "def"}.
113+
Describe("x", "y"))
114+
}

0 commit comments

Comments
 (0)