diff --git a/README.md b/README.md index c0f0e63b..543e9057 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assertions/assertions.go b/assertions/assertions.go index 10d46c96..7169007a 100644 --- a/assertions/assertions.go +++ b/assertions/assertions.go @@ -59,6 +59,7 @@ var assertMap = map[string]AssertFunc{ "ShouldHappenOnOrAfter": ShouldHappenOnOrAfter, "ShouldHappenBetween": ShouldHappenBetween, "ShouldTimeEqual": ShouldTimeEqual, + "ShouldJSONEqual": ShouldJSONEqual, "ShouldBeArray": ShouldBeArray, "ShouldBeMap": ShouldBeMap, "ShouldMatchRegex": ShouldMatchRegex, @@ -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. @@ -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 diff --git a/assertions/assertions_test.go b/assertions/assertions_test.go index ff4c3a19..7cb4aab0 100644 --- a/assertions/assertions_test.go +++ b/assertions/assertions_test.go @@ -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) { @@ -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) + } + }) + } +} diff --git a/tests/assertions/ShouldJSONEqual.yml b/tests/assertions/ShouldJSONEqual.yml new file mode 100644 index 00000000..cda0ee0e --- /dev/null +++ b/tests/assertions/ShouldJSONEqual.yml @@ -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