Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add test utilities #25

Merged
merged 5 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading