Skip to content

Commit

Permalink
feat: Add test utilities (#25)
Browse files Browse the repository at this point in the history
## 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.
  • Loading branch information
nieomylnieja authored Sep 27, 2024
1 parent be841aa commit 74b9792
Show file tree
Hide file tree
Showing 18 changed files with 946 additions and 23 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ words:
- govulncheck
- govy
- govyconfig
- govytest
- ldflags
- nobl
- pkgs
Expand Down
23 changes: 16 additions & 7 deletions internal/assert/assert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -53,15 +62,15 @@ 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)
}
}

// Error fails the test if the error is nil.
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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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))
Expand Down
38 changes: 30 additions & 8 deletions internal/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ""
Expand All @@ -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) {
Expand All @@ -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:
Expand Down
86 changes: 86 additions & 0 deletions internal/errors_test.go
Original file line number Diff line number Diff line change
@@ -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 }
5 changes: 4 additions & 1 deletion internal/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
38 changes: 38 additions & 0 deletions internal/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
2 changes: 1 addition & 1 deletion pkg/govy/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
4 changes: 2 additions & 2 deletions pkg/govy/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 74b9792

Please sign in to comment.