From 74b9792e8050ad61858cec569e449ac746dc4dda Mon Sep 17 00:00:00 2001 From: Mateusz Hawrus <48822818+nieomylnieja@users.noreply.github.com> Date: Sat, 28 Sep 2024 00:18:23 +0200 Subject: [PATCH] feat: Add test utilities (#25) ## Motivation Testing validation defined with `govy` can be a daunting task. Govy structured errors are information rich, while this is great for end users, it can be a tedious task to verify if one `govy.ValidatorError` is equal to another `govy.ValidatorError`. Often times we might not care about fields like description or even message, we might just want to verify error codes for given properties. Govy's goal is to not only make the end-user's life better but the programmer's just as well, it would benefit the second party to have a ready-to-use utility which could make the testing process of govy-defined validation a breeze. ## Summary - Added `govytest` package. - Added tests to some of the internal helpers. - Added new builtin rule `OneOfProperties` which ensures that at least one of the properties provided by getters is set. ## Release Notes Added `govytest` package which exposes utilities which help test govy validation rules. It comes with two functions `AssertNoError`, which ensures no error was produced, and `AssertError` which checks that the expected errors are equal to the actual `govy.ValidatorError`. Added `OneOfProperties` rule which checks if at least one of the properties is set. --- README.md | 7 + cspell.yaml | 1 + internal/assert/assert.go | 23 ++- internal/errors.go | 38 ++++- internal/errors_test.go | 86 ++++++++++ internal/helpers.go | 5 +- internal/helpers_test.go | 38 +++++ pkg/govy/errors.go | 2 +- pkg/govy/rules.go | 4 +- pkg/govytest/assert.go | 220 ++++++++++++++++++++++++++ pkg/govytest/assert_test.go | 299 +++++++++++++++++++++++++++++++++++ pkg/govytest/doc.go | 2 + pkg/govytest/example_test.go | 169 ++++++++++++++++++++ pkg/rules/error_codes.go | 1 + pkg/rules/forbidden.go | 2 +- pkg/rules/one_of.go | 28 +++- pkg/rules/one_of_test.go | 42 +++++ pkg/rules/required.go | 2 +- 18 files changed, 946 insertions(+), 23 deletions(-) create mode 100644 internal/errors_test.go create mode 100644 internal/helpers_test.go create mode 100644 pkg/govytest/assert.go create mode 100644 pkg/govytest/assert_test.go create mode 100644 pkg/govytest/doc.go create mode 100644 pkg/govytest/example_test.go diff --git a/README.md b/README.md index 6e58635..197970c 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,13 @@ func Example_nameInference() { } ``` +#### Testing helpers + +Package [govytest](./pkg/govytest/) provides utilities which aid the process of +writing unit tests for validation rules defined with govy. +Checkout [testable examples](https://pkg.go.dev/github.com/nobl9/govy/pkg/govytest#pkg-examples) +for a concise overview of the package's capabilities. + ## Rationale Why was this library created? diff --git a/cspell.yaml b/cspell.yaml index bcec7fe..666e38a 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -44,6 +44,7 @@ words: - govulncheck - govy - govyconfig + - govytest - ldflags - nobl - pkgs diff --git a/internal/assert/assert.go b/internal/assert/assert.go index 9d2abb2..836c49f 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -22,7 +22,7 @@ func Require(t *testing.T, isPassing bool) { func Equal(t *testing.T, expected, actual interface{}) bool { t.Helper() if !areEqual(expected, actual) { - return fail(t, "Expected %v, got %v", expected, actual) + return fail(t, "Expected: %v, actual: %v", expected, actual) } return true } @@ -36,11 +36,20 @@ func True(t *testing.T, actual bool) bool { return true } +// True fails the test if the actual value is not false. +func False(t *testing.T, actual bool) bool { + t.Helper() + if actual { + return fail(t, "Should be false") + } + return true +} + // Len fails the test if the object is not of the expected length. func Len(t *testing.T, object interface{}, length int) bool { t.Helper() if actual := getLen(object); actual != length { - return fail(t, "Expected length %d, got %d", length, actual) + return fail(t, "Expected length: %d, actual: %d", length, actual) } return true } @@ -53,7 +62,7 @@ func IsType[T any](t *testing.T, object interface{}) bool { case T: return true default: - return fail(t, "Expected type %T, got %T", *new(T), object) + return fail(t, "Expected type: %T, actual: %T", *new(T), object) } } @@ -61,7 +70,7 @@ func IsType[T any](t *testing.T, object interface{}) bool { func Error(t *testing.T, err error) bool { t.Helper() if err == nil { - return fail(t, "An error is expected but got nil.") + return fail(t, "An error is expected but actual nil.") } return true } @@ -82,7 +91,7 @@ func EqualError(t *testing.T, expected error, actual string) bool { return false } if expected.Error() != actual { - return fail(t, "Expected error message %q, got %q", expected.Error(), actual) + return fail(t, "Expected error message: %q, actual: %q", expected.Error(), actual) } return true } @@ -94,7 +103,7 @@ func ErrorContains(t *testing.T, expected error, contains string) bool { return false } if !strings.Contains(expected.Error(), contains) { - return fail(t, "Expected error message to contain %q, got %q", contains, expected.Error()) + return fail(t, "Expected error message to contain %q, actual %q", contains, expected.Error()) } return true } @@ -103,7 +112,7 @@ func ErrorContains(t *testing.T, expected error, contains string) bool { func ElementsMatch[T comparable](t *testing.T, expected, actual []T) bool { t.Helper() if len(expected) != len(actual) { - return fail(t, "Slices are not equal in length, expected: %d, got: %d", len(expected), len(actual)) + return fail(t, "Slices are not equal in length, expected: %d, actual: %d", len(expected), len(actual)) } actualVisited := make([]bool, len(actual)) diff --git a/internal/errors.go b/internal/errors.go index cd11782..a06a06c 100644 --- a/internal/errors.go +++ b/internal/errors.go @@ -5,11 +5,16 @@ import ( "fmt" "reflect" "strings" + "time" ) // JoinErrors joins multiple errors into a single pretty-formatted string. +// JoinErrors assumes the errors are not nil, if this presumption is broken the formatting might not be correct. func JoinErrors[T error](b *strings.Builder, errs []T, indent string) { for i, err := range errs { + if error(err) == nil { + continue + } buildErrorMessage(b, err.Error(), indent) if i < len(errs)-1 { b.WriteString("\n") @@ -32,13 +37,17 @@ func buildErrorMessage(b *strings.Builder, errMsg, indent string) { var newLineReplacer = strings.NewReplacer("\n", "\\n", "\r", "\\r") // PropertyValueString returns the string representation of the given value. -// Structs, interfaces, maps and slices are converted to compacted JSON strings. +// Structs, interfaces, maps and slices are converted to compacted JSON strings (see struct exceptions below). // It tries to improve readability by: -// - limiting the string to 100 characters -// - removing leading and trailing whitespaces -// - escaping newlines -// If value is a struct implementing [fmt.Stringer] String method will be used -// only if the struct does not contain any JSON tags. +// - limiting the string to 100 characters +// - removing leading and trailing whitespaces +// - escaping newlines +// +// If value is a struct implementing [fmt.Stringer] [fmt.Stringer.String] method will be used only if: +// - the struct does not contain any JSON tags +// - the struct is not empty or it is empty but does not have any fields +// +// If a value is a struct of type [time.Time] it will be formatted using [time.RFC3339] layout. func PropertyValueString(v interface{}) string { if v == nil { return "" @@ -48,13 +57,18 @@ func PropertyValueString(v interface{}) string { var s string switch ft.Kind() { case reflect.Interface, reflect.Map, reflect.Slice: - if reflect.ValueOf(v).IsZero() { + if rv.IsZero() { break } raw, _ := json.Marshal(v) s = string(raw) case reflect.Struct: - if reflect.ValueOf(v).IsZero() { + // If the struct is empty and it has. + if rv.IsZero() && rv.NumField() != 0 { + break + } + if timeDate, ok := v.(time.Time); ok { + s = timeDate.Format(time.RFC3339) break } if stringer, ok := v.(fmt.Stringer); ok && !hasJSONTags(v, rv.Kind() == reflect.Pointer) { @@ -63,6 +77,14 @@ func PropertyValueString(v interface{}) string { } raw, _ := json.Marshal(v) s = string(raw) + case reflect.Ptr: + if rv.IsNil() { + return "" + } + deref := rv.Elem().Interface() + return PropertyValueString(deref) + case reflect.Func: + return "func" case reflect.Invalid: return "" default: diff --git a/internal/errors_test.go b/internal/errors_test.go new file mode 100644 index 0000000..cdb8eea --- /dev/null +++ b/internal/errors_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/nobl9/govy/internal/assert" +) + +func TestJoinErrors(t *testing.T) { + tests := []struct { + in []error + out string + }{ + {nil, ""}, + {[]error{nil, nil}, ""}, + // Incorrect formatting, this test case ensures the function does not panic. + {[]error{nil, errors.New("some error"), nil}, " - some error\n"}, + {[]error{errors.New("- some error")}, " - some error"}, + {[]error{errors.New("- some error"), errors.New("some other error")}, " - some error\n - some other error"}, + } + for _, tc := range tests { + b := strings.Builder{} + JoinErrors(&b, tc.in, " ") + assert.Equal(t, tc.out, b.String()) + } + t.Run("custom indent", func(t *testing.T) { + b := strings.Builder{} + JoinErrors(&b, []error{errors.New("some error")}, " ") + assert.Equal(t, " - some error", b.String()) + }) +} + +func TestPropertyValueString(t *testing.T) { + tests := []struct { + in any + out string + }{ + {nil, ""}, + {any(nil), ""}, + {false, "false"}, + {true, "true"}, + {any("this"), "this"}, + {func() {}, "func"}, + {ptr("this"), "this"}, + {struct{ This string }{This: "this"}, `{"This":"this"}`}, + {ptr(struct{ This string }{This: "this"}), `{"This":"this"}`}, + {struct { + This string `json:"this"` + }{This: "this"}, `{"this":"this"}`}, + {map[string]string{"this": "this"}, `{"this":"this"}`}, + {[]string{"this", "that"}, `["this","that"]`}, + {0, "0"}, + {0.0, "0"}, + {2, "2"}, + {0.123, "0.123"}, + {time.Second, "1s"}, + {time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "2024-01-01T00:00:00Z"}, + {mockEmptyStringer{}, "mock"}, + {mockStringerWithTags{}, ""}, + {mockStringerWithTags{Mock: "mock"}, `{"mock":"mock"}`}, + {ptr(mockEmptyStringer{}), "mock"}, + } + for _, tc := range tests { + got := PropertyValueString(tc.in) + assert.Equal(t, tc.out, got) + } +} + +type mockEmptyStringer struct{} + +func (m mockEmptyStringer) String() string { + return "mock" +} + +type mockStringerWithTags struct { + Mock string `json:"mock"` +} + +func (m mockStringerWithTags) String() string { + return "stringer" +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/helpers.go b/internal/helpers.go index c27480d..385e56e 100644 --- a/internal/helpers.go +++ b/internal/helpers.go @@ -12,7 +12,10 @@ const RequiredErrorMessage = "property is required but was empty" const RequiredErrorCodeString = "required" // IsEmptyFunc verifies if the value is zero value of its type. -func IsEmptyFunc(v interface{}) bool { +func IsEmpty(v interface{}) bool { + if v == nil { + return true + } rv := reflect.ValueOf(v) return rv.Kind() == 0 || rv.IsZero() } diff --git a/internal/helpers_test.go b/internal/helpers_test.go new file mode 100644 index 0000000..49323c4 --- /dev/null +++ b/internal/helpers_test.go @@ -0,0 +1,38 @@ +package internal + +import ( + "testing" + + "github.com/nobl9/govy/internal/assert" +) + +func TestIsEmpty(t *testing.T) { + tests := []struct { + in any + out bool + }{ + {nil, true}, + {any(nil), true}, + {any(""), true}, + {"", true}, + {0, true}, + {0.0, true}, + {false, true}, + {struct{}{}, true}, + {map[int]string{}, false}, + {[]int{}, false}, + {ptr(struct{}{}), false}, + {ptr(""), false}, + {make(chan int), false}, + {any("this"), false}, + {0.123, false}, + {true, false}, + {struct{ This string }{This: "this"}, false}, + {map[int]string{0: ""}, false}, + {ptr(struct{ This string }{This: "this"}), false}, + {[]int{0}, false}, + } + for _, tc := range tests { + assert.Equal(t, tc.out, IsEmpty(tc.in)) + } +} diff --git a/pkg/govy/errors.go b/pkg/govy/errors.go index fb04901..0d18f02 100644 --- a/pkg/govy/errors.go +++ b/pkg/govy/errors.go @@ -110,7 +110,7 @@ func NewPropertyError(propertyName string, propertyValue interface{}, errs ...er type PropertyError struct { PropertyName string `json:"propertyName"` - PropertyValue string `json:"propertyValue"` + PropertyValue string `json:"propertyValue,omitempty"` // IsKeyError is set to true if the error was created through map key validation. // PropertyValue in this scenario will be the key value, equal to the last element of PropertyName path. IsKeyError bool `json:"isKeyError,omitempty"` diff --git a/pkg/govy/rules.go b/pkg/govy/rules.go index 6d5c39a..fc99218 100644 --- a/pkg/govy/rules.go +++ b/pkg/govy/rules.go @@ -51,7 +51,7 @@ func Transform[T, N, S any](getter PropertyGetter[T, S], transform Transformer[T name: inferName(), transformGetter: func(s S) (transformed N, original any, err error) { v := getter(s) - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return transformed, nil, emptyErr{} } transformed, err = transform(v) @@ -280,7 +280,7 @@ func (r PropertyRules[T, S]) getValue(st S) (v T, skip bool, propErr *PropertyEr } return v, false, NewPropertyError(r.name, propValue, err) } - isEmpty := isEmptyError || (!r.isPointer && internal.IsEmptyFunc(v)) + isEmpty := isEmptyError || (!r.isPointer && internal.IsEmpty(v)) // If the value is not empty we simply return it. if !isEmpty { return v, false, nil diff --git a/pkg/govytest/assert.go b/pkg/govytest/assert.go new file mode 100644 index 0000000..ef1c183 --- /dev/null +++ b/pkg/govytest/assert.go @@ -0,0 +1,220 @@ +package govytest + +import ( + "encoding/json" + "strings" + + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/rules" +) + +// testingT is an interface that is compatible with *testing.T. +// It is used to make the functions in this package testable. +type testingT interface { + Errorf(format string, args ...any) + Error(args ...any) + Helper() +} + +// ExpectedRuleError defines the expectations for the asserted error. +// Its fields are used to find and match an actual [govy.RuleError]. +type ExpectedRuleError struct { + // Required. Matched against [govy.PropertyError.PropertyName]. + PropertyName string `json:"propertyName"` + // Optional. Matched against [govy.RuleError.Code]. + Code govy.ErrorCode `json:"code,omitempty"` + // Optional. Matched against [govy.RuleError.Message]. + Message string `json:"message,omitempty"` + // Optional. Matched against [govy.RuleError.Message] (partial). + ContainsMessage string `json:"containsMessage,omitempty"` + // Optional. Matched against [govy.PropertyError.IsKeyError]. + IsKeyError bool `json:"isKeyError,omitempty"` +} + +// expectedRuleErrorValidation defines the validation rules for [ExpectedRuleError]. +var expectedRuleErrorValidation = govy.New( + govy.For(func(e ExpectedRuleError) string { return e.PropertyName }). + WithName("propertyName"). + Required(), + govy.For(govy.GetSelf[ExpectedRuleError]()). + Rules(rules.OneOfProperties(map[string]func(e ExpectedRuleError) any{ + "code": func(e ExpectedRuleError) any { return e.Code }, + "message": func(e ExpectedRuleError) any { return e.Message }, + "containsMessage": func(e ExpectedRuleError) any { return e.ContainsMessage }, + })), +).InferName() + +// Validate checks if the [ExpectedRuleError] is valid. +func (e ExpectedRuleError) Validate() error { + return expectedRuleErrorValidation.Validate(e) +} + +// AssertNoError asserts that the provided error is nil. +// If the error is not nil and of type [govy.ValidatorError] it will try +// encoding it to JSON and pretty printing the encountered error. +// +// It returns true if the error is nil, false otherwise. +func AssertNoError(t testingT, err error) bool { + t.Helper() + if err == nil { + return true + } + errMsg := err.Error() + if vErr, ok := err.(*govy.ValidatorError); ok { + encErr, _ := json.MarshalIndent(vErr, "", " ") + errMsg = string(encErr) + } + t.Errorf("Received unexpected error:\n%+s", errMsg) + return false +} + +// AssertError asserts that the given error has: +// - type equal to [*govy.ValidatorError] +// - the expected number of [govy.RuleError] +// - at least one error which matches each of the provided [ExpectedRuleError] +// +// [ExpectedRuleError] and actual error are considered equal if their [] to the same property and either: +// - [ExpectedRuleError.Code] is equal to [govy.RuleError.Code] +// - [ExpectedRuleError.Message] is equal to [govy.RuleError.Message] +// - [ExpectedRuleError.ContainsMessage] is part of [govy.RuleError.Message] +// +// If [ExpectedRuleError.IsKeyError] is provided it will be required to match +// the actual [govy.PropertyError.IsKeyError]. +// +// It returns true if the error matches the expectations, false otherwise. +func AssertError( + t testingT, + err error, + expectedErrors ...ExpectedRuleError, +) bool { + t.Helper() + + if !validateExpectedErrors(t, expectedErrors) { + return false + } + validatorErr, ok := assertValidatorError(t, err) + if !ok { + return false + } + if !assertErrorsCount(t, validatorErr, len(expectedErrors)) { + return false + } + matched := make(matchedErrors, len(expectedErrors)) + for _, expected := range expectedErrors { + if !assertErrorMatches(t, validatorErr, expected, matched) { + return false + } + } + return true +} + +func validateExpectedErrors(t testingT, expectedErrors []ExpectedRuleError) bool { + t.Helper() + if len(expectedErrors) == 0 { + t.Errorf("%T must not be empty.", expectedErrors) + return false + } + for _, expected := range expectedErrors { + if err := expected.Validate(); err != nil { + t.Error(err.Error()) + return false + } + } + return true +} + +func assertValidatorError(t testingT, err error) (*govy.ValidatorError, bool) { + t.Helper() + + if err == nil { + t.Errorf("Input error should not be nil.") + return nil, false + } + validatorErr, ok := err.(*govy.ValidatorError) + if !ok { + t.Errorf("Input error should be of type %T.", &govy.ValidatorError{}) + } + return validatorErr, ok +} + +func assertErrorsCount( + t testingT, + validatorErr *govy.ValidatorError, + expectedErrorsCount int, +) bool { + t.Helper() + + actualErrorsCount := 0 + for _, actual := range validatorErr.Errors { + actualErrorsCount += len(actual.Errors) + } + if expectedErrorsCount != actualErrorsCount { + t.Errorf("%T contains different number of errors than expected, expected: %d, actual: %d.", + validatorErr, expectedErrorsCount, actualErrorsCount) + return false + } + return true +} + +type matchedErrors map[int]map[int]struct{} + +func (m matchedErrors) Add(propertyErrorIdx, ruleErrorIdx int) bool { + if _, ok := m[propertyErrorIdx]; !ok { + m[propertyErrorIdx] = make(map[int]struct{}) + } + _, ok := m[propertyErrorIdx][ruleErrorIdx] + m[propertyErrorIdx][ruleErrorIdx] = struct{}{} + return ok +} + +func assertErrorMatches( + t testingT, + validatorErr *govy.ValidatorError, + expected ExpectedRuleError, + matched matchedErrors, +) bool { + t.Helper() + + multiMatch := false + for i, actual := range validatorErr.Errors { + if actual.PropertyName != expected.PropertyName { + continue + } + if expected.IsKeyError != actual.IsKeyError { + continue + } + for j, actualRuleErr := range actual.Errors { + actualMessage := actualRuleErr.Error() + matchedCtr := 0 + if expected.Message == "" || expected.Message == actualMessage { + matchedCtr++ + } + if expected.ContainsMessage == "" || + strings.Contains(actualMessage, expected.ContainsMessage) { + matchedCtr++ + } + if expected.Code == "" || + expected.Code == actualRuleErr.Code || + govy.HasErrorCode(actualRuleErr, expected.Code) { + matchedCtr++ + } + if matchedCtr == 3 { + if matched.Add(i, j) { + multiMatch = true + continue + } + return true + } + } + } + + if multiMatch { + t.Errorf("Actual error was matched multiple times. Consider providing a more specific %T list.", expected) + return false + } + encExpected, _ := json.MarshalIndent(expected, "", " ") + encActual, _ := json.MarshalIndent(validatorErr.Errors, "", " ") + t.Errorf("Expected error was not found.\nEXPECTED:\n%s\nACTUAL:\n%s", + string(encExpected), string(encActual)) + return false +} diff --git a/pkg/govytest/assert_test.go b/pkg/govytest/assert_test.go new file mode 100644 index 0000000..e51c5f8 --- /dev/null +++ b/pkg/govytest/assert_test.go @@ -0,0 +1,299 @@ +package govytest_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/nobl9/govy/internal/assert" + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/govytest" +) + +func TestAssertNoError(t *testing.T) { + t.Run("no error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, nil) + assert.True(t, ok) + }) + t.Run("generic error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, errors.New("this")) + assert.False(t, ok) + assert.Equal(t, "Received unexpected error:\nthis", mt.recordedError) + }) + t.Run("validator error", func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertNoError(mt, &govy.ValidatorError{Name: "Service"}) + assert.False(t, ok) + assert.Equal(t, `Received unexpected error: +{ + "errors": null, + "name": "Service" +}`, mt.recordedError) + }) +} + +func TestAssertError(t *testing.T) { + tests := map[string]struct { + ok bool + inputError error + expectedErrors []govytest.ExpectedRuleError + out string + }{ + "no expected errors": { + ok: false, + out: "[]govytest.ExpectedRuleError must not be empty.", + }, + "invalid input": { + ok: false, + expectedErrors: []govytest.ExpectedRuleError{{}}, + out: `Validation for ExpectedRuleError has failed for the following properties: + - 'propertyName': + - property is required but was empty + - one of [code, containsMessage, message] properties must be set, none was provided`, + }, + "nil error": { + ok: false, + inputError: nil, + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "Input error should not be nil.", + }, + "wrong type of error": { + ok: false, + inputError: errors.New(""), + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "Input error should be of type *govy.ValidatorError.", + }, + "errors count mismatch": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + {Errors: []*govy.RuleError{{}, {}}}, + }}, + expectedErrors: []govytest.ExpectedRuleError{{PropertyName: "this", Message: "test"}}, + out: "*govy.ValidatorError contains different number of errors than expected, expected: 1, actual: 2.", + }, + "no matches": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test"}, + }, + out: `Expected error was not found. +EXPECTED: +{ + "propertyName": "this", + "message": "test" +} +ACTUAL: +[ + { + "propertyName": "that", + "errors": [ + { + "error": "test" + } + ] + } +]`, + }, + "match on message": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}, {Message: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1"}, + {PropertyName: "this", Message: "test2"}, + {PropertyName: "that", Message: "test3"}, + }, + }, + "match on code": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Code: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Code: "test2"}, {Code: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Code: "test1"}, + {PropertyName: "this", Code: "test2"}, + {PropertyName: "that", Code: "test3"}, + }, + }, + "match on message contains": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}, {Message: "test1"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "that", ContainsMessage: "test"}, + }, + }, + "match on message and code": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1"}, + {PropertyName: "this", Message: "test2", Code: "code2"}, + {PropertyName: "that", Message: "test3", Code: "code3"}, + }, + }, + "fail to match on message and code": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1"}, + {PropertyName: "this", Message: "test2", Code: "code2"}, + {PropertyName: "that", Message: "test3", Code: "code4"}, + }, + out: `Expected error was not found. +EXPECTED: +{ + "propertyName": "that", + "code": "code4", + "message": "test3" +} +ACTUAL: +[ + { + "propertyName": "that", + "errors": [ + { + "error": "test3", + "code": "code3" + } + ] + }, + { + "propertyName": "this", + "errors": [ + { + "error": "test2", + "code": "code2" + }, + { + "error": "test1", + "code": "code1" + } + ] + } +]`, + }, + "match on message, code and message contains": { + ok: true, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3", Code: "code3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{ + {Message: "test2", Code: "code2"}, + {Message: "test1", Code: "code1"}, + }, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", Message: "test1", Code: "code1", ContainsMessage: "test"}, + {PropertyName: "this", Message: "test2", Code: "code2", ContainsMessage: "test"}, + {PropertyName: "that", Message: "test3", Code: "code3", ContainsMessage: "test"}, + }, + }, + "error was matched multiple times": { + ok: false, + inputError: &govy.ValidatorError{Errors: []*govy.PropertyError{ + { + PropertyName: "that", + Errors: []*govy.RuleError{{Message: "test3"}}, + }, + { + PropertyName: "this", + Errors: []*govy.RuleError{{Message: "test2"}}, + }, + }}, + expectedErrors: []govytest.ExpectedRuleError{ + {PropertyName: "this", ContainsMessage: "test"}, + {PropertyName: "this", ContainsMessage: "test"}, + }, + out: "Actual error was matched multiple times. Consider providing a more specific govytest.ExpectedRuleError list.", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mt := new(mockTestingT) + ok := govytest.AssertError(mt, tc.inputError, tc.expectedErrors...) + if tc.ok { + assert.True(t, ok) + } else { + assert.Require(t, assert.False(t, ok)) + assert.Equal(t, tc.out, mt.recordedError) + } + }) + } +} + +type mockTestingT struct { + recordedError string +} + +func (m *mockTestingT) Errorf(format string, args ...any) { + m.recordedError = fmt.Sprintf(format, args...) +} + +func (m *mockTestingT) Error(args ...any) { + m.recordedError = fmt.Sprint(args...) +} + +func (m *mockTestingT) Helper() {} diff --git a/pkg/govytest/doc.go b/pkg/govytest/doc.go new file mode 100644 index 0000000..4dc2705 --- /dev/null +++ b/pkg/govytest/doc.go @@ -0,0 +1,2 @@ +// Package govytest provides utilities for testing validation rules defined with govy. +package govytest diff --git a/pkg/govytest/example_test.go b/pkg/govytest/example_test.go new file mode 100644 index 0000000..3a53c8e --- /dev/null +++ b/pkg/govytest/example_test.go @@ -0,0 +1,169 @@ +package govytest_test + +import ( + "fmt" + + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/govytest" + "github.com/nobl9/govy/pkg/rules" +) + +type Teacher struct { + Name string `json:"name"` + University University `json:"university"` +} + +type University struct { + Name string `json:"name"` + Address string `json:"address"` +} + +// You can use [govytest.AssertNoError] to ensure no error was produced by [govy.Validator.Validate]. +// If an error was produced, it will be printed to the stdout in JSON format. +// +// To demonstrate the erroneous output of [govytest.AssertNoError] we'll fail the assertion. +func ExampleAssertNoError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We'll use a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertNoError(mt, err) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Received unexpected error: + // { + // "errors": [ + // { + // "propertyName": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of [Jake, George]", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyName": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ], + // "name": "John" + // } +} + +// Verifying that expected errors were produced by [govy.Validator.Validate] can be a tedious task. +// Often times we might only care about [govy.ErrorCode] and not the message or description of the error. +// To help in that process, [govytest.AssertError] can be used to ensure that the expected errors were produced. +// It accepts multiple [govytest.ExpectedRuleError], each being a short and concise +// representation of the error we're expecting to occur. +// For more details on how to use [govytest.ExpectedRuleError], see its code documentation. +// +// To demonstrate the erroneous output of [govytest.AssertError] we'll fail the assertion. +func ExampleAssertError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We'll use a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertError(mt, err, + govytest.ExpectedRuleError{ + PropertyName: "name", + ContainsMessage: "one of", + }, + govytest.ExpectedRuleError{ + PropertyName: "university.address", + Code: "greater_than", + }, + ) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Expected error was not found. + // EXPECTED: + // { + // "propertyName": "university.address", + // "code": "greater_than" + // } + // ACTUAL: + // [ + // { + // "propertyName": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of [Jake, George]", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyName": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ] +} diff --git a/pkg/rules/error_codes.go b/pkg/rules/error_codes.go index f47e0ed..16a4f46 100644 --- a/pkg/rules/error_codes.go +++ b/pkg/rules/error_codes.go @@ -45,6 +45,7 @@ const ( ErrorCodeMapMinLength govy.ErrorCode = "map_min_length" ErrorCodeMapMaxLength govy.ErrorCode = "map_max_length" ErrorCodeOneOf govy.ErrorCode = "one_of" + ErrorCodeOneOfProperties govy.ErrorCode = "one_of_properties" ErrorCodeMutuallyExclusive govy.ErrorCode = "mutually_exclusive" ErrorCodeSliceUnique govy.ErrorCode = "slice_unique" ErrorCodeURL govy.ErrorCode = "url" diff --git a/pkg/rules/forbidden.go b/pkg/rules/forbidden.go index d45ec4a..c6481c6 100644 --- a/pkg/rules/forbidden.go +++ b/pkg/rules/forbidden.go @@ -11,7 +11,7 @@ import ( func Forbidden[T any]() govy.Rule[T] { msg := "property is forbidden" return govy.NewRule(func(v T) error { - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return nil } return errors.New(msg) diff --git a/pkg/rules/one_of.go b/pkg/rules/one_of.go index c7e4690..ba5e831 100644 --- a/pkg/rules/one_of.go +++ b/pkg/rules/one_of.go @@ -31,15 +31,39 @@ func OneOf[T comparable](values ...T) govy.Rule[T] { }()) } +// OneOfProperties checks if at least one of the properties is set. +// Property is considered set if its value is not empty (non-zero). +func OneOfProperties[S any](getters map[string]func(s S) any) govy.Rule[S] { + return govy.NewRule(func(s S) error { + for _, getter := range getters { + v := getter(s) + if !internal.IsEmpty(v) { + return nil + } + } + keys := maps.Keys(getters) + slices.Sort(keys) + return fmt.Errorf( + "one of %s properties must be set, none was provided", + prettyOneOfList(keys)) + }). + WithErrorCode(ErrorCodeOneOfProperties). + WithDescription(func() string { + keys := maps.Keys(getters) + return fmt.Sprintf("at least one of the properties must be set: %s", strings.Join(keys, ", ")) + }()) +} + // MutuallyExclusive checks if properties are mutually exclusive. -// This means, exactly one of the properties can be provided. +// This means, exactly one of the properties can be set. +// Property is considered set if its value is not empty (non-zero). // If required is true, then a single non-empty property is required. func MutuallyExclusive[S any](required bool, getters map[string]func(s S) any) govy.Rule[S] { return govy.NewRule(func(s S) error { var nonEmpty []string for name, getter := range getters { v := getter(s) - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { continue } nonEmpty = append(nonEmpty, name) diff --git a/pkg/rules/one_of_test.go b/pkg/rules/one_of_test.go index 7f46a8c..5af4597 100644 --- a/pkg/rules/one_of_test.go +++ b/pkg/rules/one_of_test.go @@ -86,4 +86,46 @@ func TestMutuallyExclusive(t *testing.T) { }) } +func TestOneOfProperties(t *testing.T) { + type PaymentMethod struct { + Cash *string + Card *string + Transfer *string + } + getters := map[string]func(p PaymentMethod) any{ + "Cash": func(p PaymentMethod) any { return p.Cash }, + "Card": func(p PaymentMethod) any { return p.Card }, + "Transfer": func(p PaymentMethod) any { return p.Transfer }, + } + t.Run("passes", func(t *testing.T) { + err := OneOfProperties(getters).Validate(PaymentMethod{ + Cash: nil, + Card: ptr("2$"), + Transfer: nil, + }) + assert.NoError(t, err) + err = OneOfProperties(getters).Validate(PaymentMethod{ + Cash: ptr("1$"), + Card: ptr("2$"), + Transfer: nil, + }) + assert.NoError(t, err) + err = OneOfProperties(getters).Validate(PaymentMethod{ + Cash: ptr("1$"), + Card: ptr("2$"), + Transfer: ptr("3$"), + }) + assert.NoError(t, err) + }) + t.Run("fails", func(t *testing.T) { + err := OneOfProperties(getters).Validate(PaymentMethod{ + Cash: nil, + Card: nil, + Transfer: nil, + }) + assert.EqualError(t, err, "one of [Card, Cash, Transfer] properties must be set, none was provided") + assert.True(t, govy.HasErrorCode(err, ErrorCodeOneOfProperties)) + }) +} + func ptr[T any](v T) *T { return &v } diff --git a/pkg/rules/required.go b/pkg/rules/required.go index b1fa7b9..acbdf4c 100644 --- a/pkg/rules/required.go +++ b/pkg/rules/required.go @@ -8,7 +8,7 @@ import ( // Required ensures the property's value is not empty (i.e. it's not its type's zero value). func Required[T any]() govy.Rule[T] { return govy.NewRule(func(v T) error { - if internal.IsEmptyFunc(v) { + if internal.IsEmpty(v) { return govy.NewRuleError( internal.RequiredErrorMessage, ErrorCodeRequired,