Skip to content

Commit

Permalink
feat: add ShouldJSONEqual assertion (ovh#676)
Browse files Browse the repository at this point in the history
* Add ShouldJSONEqual assertion

Signed-off-by: Philipp Gillé <[email protected]>

* Fix example in Godoc

Signed-off-by: Philipp Gillé <[email protected]>

* Improve Godoc

Signed-off-by: Philipp Gillé <[email protected]>

* Add to test suite

Signed-off-by: Philipp Gillé <[email protected]>

* Add ShouldJSONEqual to README

Signed-off-by: Philipp Gillé <[email protected]>

* Support primitive JSON values

Signed-off-by: Philipp Gillé <[email protected]>

---------

Signed-off-by: Philipp Gillé <[email protected]>
Signed-off-by: Ivan Velasco <[email protected]>
  • Loading branch information
philippgille authored and ivan-velasco committed Sep 20, 2023
1 parent 999bf13 commit cc70634
Show file tree
Hide file tree
Showing 4 changed files with 326 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ Builtin variables:
* ShouldHappenBetween - [example](https://github.com/ovh/venom/tree/master/tests/assertions/ShouldHappenBetween.yml)
* ShouldTimeEqual - [example](https://github.com/ovh/venom/tree/master/tests/assertions/ShouldTimeEqual.yml)
* ShouldMatchRegex - [example](https://github.com/ovh/venom/tree/master/tests/assertions/ShouldMatchRegex.yml)
* ShouldJSONEqual - [example](https://github.com/ovh/venom/tree/master/tests/assertions/ShouldJSONEqual.yml)

#### `Must` keywords

Expand Down
128 changes: 127 additions & 1 deletion assertions/assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ var assertMap = map[string]AssertFunc{
"ShouldHappenOnOrAfter": ShouldHappenOnOrAfter,
"ShouldHappenBetween": ShouldHappenBetween,
"ShouldTimeEqual": ShouldTimeEqual,
"ShouldJSONEqual": ShouldJSONEqual,
"ShouldBeArray": ShouldBeArray,
"ShouldBeMap": ShouldBeMap,
"ShouldMatchRegex": ShouldMatchRegex,
Expand Down Expand Up @@ -770,7 +771,6 @@ func ShouldHaveLength(actual interface{}, expected ...interface{}) error {
}

return fmt.Errorf("expected '%v' have length of %d but it wasn't (%d)", actual, length, actualLength)

}

// ShouldStartWith receives exactly 2 string parameters and ensures that the first starts with the second.
Expand Down Expand Up @@ -1174,6 +1174,132 @@ func ShouldTimeEqual(actual interface{}, expected ...interface{}) error {
return fmt.Errorf("expected '%v' to be time equals to '%v' ", actualTime, expectedTime)
}

// ShouldJSONEqual receives exactly 2 JSON arguments and does a JSON equality check.
// The latest JSON spec doesn't only allow objects and arrays, but primitive values are valid JSON as well.
// For object equality keys can be in different order, and whitespace (except in keys or values) is ignored.
// For arrays the order is important, but whitespace (except in values) is ignored.
// String, number, true/false are compared as-is.
// `null` JSON values are currently passed as empty string, and are compared against the "null" string.
//
// For an example scenario see `tests/assertions/ShouldJSONEqual.yml`.
func ShouldJSONEqual(actual interface{}, expected ...interface{}) error {
if err := need(1, expected); err != nil {
return err
}

switch actual.(type) {
case map[string]interface{}:
actualMap, err := cast.ToStringMapE(actual)
if err != nil {
return err
}
expectedString, err := cast.ToStringE(expected[0])
if err != nil {
return err
}

// Marshal and unmarshal for later deepequal to work
actualBytes, err := json.Marshal(actualMap)
if err != nil {
return err
}
err = json.Unmarshal(actualBytes, &actualMap)
if err != nil {
return err
}

expectedMap := map[string]interface{}{}
err = json.Unmarshal([]byte(expectedString), &expectedMap)
if err != nil {
return err
}
if reflect.DeepEqual(actualMap, expectedMap) {
return nil
}
return fmt.Errorf("expected '%v' to be JSON equals to '%v' ", actualMap, expectedMap)
case []interface{}:
actualSlice, err := cast.ToSliceE(actual)
if err != nil {
return err
}
expectedString, err := cast.ToStringE(expected[0])
if err != nil {
return err
}

// Marshal and unmarshal for later deepequal to work
actualBytes, err := json.Marshal(actualSlice)
if err != nil {
return err
}
err = json.Unmarshal(actualBytes, &actualSlice)
if err != nil {
return err
}

expectedSlice := []interface{}{}
err = json.Unmarshal([]byte(expectedString), &expectedSlice)
if err != nil {
return err
}
if reflect.DeepEqual(actualSlice, expectedSlice) {
return nil
}
return fmt.Errorf("expected '%v' to be JSON equals to '%v' ", actualSlice, expectedSlice)
case string:
actualString, err := cast.ToStringE(actual)
if err != nil {
return err
}
expectedString, err := cast.ToStringE(expected[0])
if err != nil {
return err
}

if actualString == expectedString {
return nil
}
// Special case: Venom passes an empty string when `actual` JSON is JSON's `null`.
// Above check is already valid when `expected` is an empty string, but
// the user might have passed `null` explicitly.
// TODO: This should be changed as soon as Venom passes Go's `nil` for JSON `null` values.
if actualString == "" && expectedString == "null" {
return nil
}
return fmt.Errorf("expected '%v' to be JSON equals to '%v' ", actualString, expectedString)
case json.Number:
actualFloat, err := cast.ToFloat64E(actual)
if err != nil {
return err
}
expectedFloat, err := cast.ToFloat64E(expected[0])
if err != nil {
return err
}

if actualFloat == expectedFloat {
return nil
}
return fmt.Errorf("expected '%v' to be JSON equals to '%v' ", actualFloat, expectedFloat)
case bool:
actualBool, err := cast.ToBoolE(actual)
if err != nil {
return err
}
expectedBool, err := cast.ToBoolE(expected[0])
if err != nil {
return err
}

if actualBool == expectedBool {
return nil
}
return fmt.Errorf("expected '%v' to be JSON equals to '%v' ", actualBool, expectedBool)
default:
return fmt.Errorf("unexpected type for actual: %T", actual)
}
}

func getTimeFromString(in interface{}) (time.Time, error) {
if t, isTime := in.(time.Time); isTime {
return t, nil
Expand Down
166 changes: 165 additions & 1 deletion assertions/assertions_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package assertions

import (
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestShouldEqual(t *testing.T) {
Expand Down Expand Up @@ -1458,3 +1460,165 @@ func TestShouldMatchRegex(t *testing.T) {
})
}
}

func TestShouldJSONEqual(t *testing.T) {
type args struct {
actual interface{}
expected []interface{}
}

tests := []struct {
name string
args args
wantErr bool
}{
// Objects and arrays
{
name: "object",
args: args{
actual: map[string]interface{}{"a": 1, "b": 2, "c": map[string]interface{}{"x": 1, "y": 2}},
expected: []interface{}{`{"a":1,"b":2,"c":{"x":1,"y":2}}`},
},
},
{
// Spaces, newlines, tabs and key order (including in nested objects) don't matter
name: "object",
args: args{
actual: map[string]interface{}{"a": 1, "b": 2, "c": map[string]interface{}{"x": 1, "y": 2}},
expected: []interface{}{` { "c" : { "y" : 2 , "x" : 1 }, "b" : 2 ,` + "\n\t" + ` "a" : 1 } `},
},
},
{
name: "array",
args: args{
actual: []interface{}{1, 2},
expected: []interface{}{`[1,2]`},
},
},
{
// Spaces, newlines and tabs don't matter
name: "array",
args: args{
actual: []interface{}{1, 2},
expected: []interface{}{` [ 1 ,` + "\n\t" + ` 2 ] `},
},
},
// Object and array errors
{
name: "bad value",
args: args{
actual: map[string]interface{}{"a": 1},
expected: []interface{}{`{"a":2}`},
},
wantErr: true,
},
{
name: "bad type",
args: args{
actual: map[string]interface{}{"a": 1},
expected: []interface{}{`{"a":"1"}`},
},
wantErr: true,
},
{
name: "missing key",
args: args{
actual: map[string]interface{}{"a": 1, "b": 2},
expected: []interface{}{`{"a":1}`},
},
wantErr: true,
},
{
name: "bad array order",
args: args{
actual: map[string]interface{}{"a": []float64{1, 2}},
expected: []interface{}{`{"a":[2,1]}`},
},
wantErr: true,
},
{
name: "object instead of array",
args: args{
actual: map[string]interface{}{"a": 1},
expected: []interface{}{`[1]`},
},
wantErr: true,
},
{
name: "array instead of object",
args: args{
actual: []interface{}{1},
expected: []interface{}{`{"a":1}}`},
},
wantErr: true,
},
// Primitive values
{
name: "string",
args: args{
actual: "a",
expected: []interface{}{"a"},
},
},
{
name: "empty string",
args: args{
actual: "",
expected: []interface{}{""},
},
},
{
name: "number",
args: args{
actual: json.Number("1"),
expected: []interface{}{`1`},
},
},
{
name: "number",
args: args{
actual: json.Number("1.2"),
expected: []interface{}{`1.2`},
},
},
{
name: "boolean",
args: args{
actual: true,
expected: []interface{}{`true`},
},
},
{
// TODO: Shouldn't be valid, but Venom currently passes an empty string to the assertion function when the JSON value is `null`.
name: "null",
args: args{
actual: "",
expected: []interface{}{`null`},
},
},
// Primitive value errors
{
name: "bad value",
args: args{
actual: "a",
expected: []interface{}{"b"},
},
wantErr: true,
},
{
name: "bad type",
args: args{
actual: float64(1),
expected: []interface{}{"1"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ShouldJSONEqual(tt.args.actual, tt.args.expected...); (err != nil) != tt.wantErr {
t.Errorf("ShouldJSONEqual() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
33 changes: 33 additions & 0 deletions tests/assertions/ShouldJSONEqual.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: test ShouldJSONEqual
testcases:
- name: test assertion
steps:
- type: exec
script: |
echo '{
"o" : {
"a" : 1,
"b" : 2,
"c" : {
"x":1,
"y":2
}
},
"a" : [1,2],
"s" : "foo",
"n" : 1.2,
"t" : true,
"f" : false,
"z" : null
}'
assertions:
- result.systemoutjson.o ShouldJSONEqual ' { "c":{ "y" :2 , "x" :1 }, "b" :2 , "a" :1 } '
- result.systemoutjson.o.c ShouldJSONEqual ' { "y" :2 , "x" :1 }'
- result.systemoutjson.a ShouldJSONEqual ' [ 1 , 2 ] '
- result.systemoutjson.s ShouldJSONEqual 'foo'
- result.systemoutjson.n ShouldJSONEqual 1.2
- result.systemoutjson.t ShouldJSONEqual true
- result.systemoutjson.f ShouldJSONEqual false
- result.systemoutjson.z ShouldJSONEqual null
- result.systemoutjson.z ShouldJSONEqual 'null' # ⚠️ Shouldn't be valid, but is required for above `null` check to work
- result.systemoutjson.z ShouldJSONEqual '' # ⚠️ Shouldn't be valid, but Venom treats null as empty string

0 comments on commit cc70634

Please sign in to comment.