Skip to content

Commit

Permalink
feature: DeepEquals option ExcludeFields
Browse files Browse the repository at this point in the history
  • Loading branch information
halimath committed Feb 23, 2023
1 parent 61f8c8d commit 7f4f393
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 11 deletions.
77 changes: 67 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,79 @@ given values deeply traversing nested structures. It handles all primitive types
arrays and structs. It reports all differences found so test failures are easy to track down.

The equality checking algorithm can be customized on a per-matcher-invocation level using any of the following
options:

Option | Default Value | Description
-- | -- | --
`FloatPrecision` | 10 | Number of significant floating point digits used when comparing `float32` or `float64`
`NilSlicesAreEmpty` | true | Whether a `nil` slice is considered equal to an empty but non-`nil` slice
`NilMapsAreEmpty` | true | Whether a `nil` map is considered equal to an empty but non-`nil` map
`ExcludeUnexportedStructFields` | false | Whether unexported (lower case) struct fields should be ignored when comparing struct values.

The options must be given to the `DeepEqual` matcher:
options. All options must be given to the `DeepEqual` matcher:

```go
ExpectThat(t, map[string]int{}).
Is(DeepEqual(map[string]int(nil), NilMapsAreEmpty(false)))
```


#### Floatint point precision

Passing the `FloatPrecision` option allows you to customize the floating point precision when comparing both
`float32` and `float64`. The default value is 10 decimal digits.

#### Nil slices and maps

By default `nil` slices are considered equal to empty ones as well as `nil` maps are considered equal to empty
ones. You can customize this by passing `NilSlicesAreEmpty(false)` or `NilMapsAreEmpty(false)`.

#### Struct fields

Struct fields can be excluded from the comparison using any of the following methods.

Passing `ExcludeUnexportedStructFields(true)` excludes unexported struct fields (those with a name starting
with a lower case letter) from the comparison. The default is not to exclude them.

Using `ExludeTypes` you can exclude all fields with a type given in the list. `ExcludeTypes` is a slice of
`reflect.Type` so you can pass in any number of types.

`ExcludeFields` allows you to specify path expressions (given as strings) that match a path to a field. The
syntax resembles the format used to report differences (so you can simply copy them from the initial test
failure). In addition, you can use a wildcard `*` to match any field or index value.

The following code sample demonstrates the usage:

```go
type nested struct {
nestedField string
}

type root struct {
stringField string
sliceField []nested
mapField map[string]string
}

first := root{
stringField: "a",
sliceField: []nested{
{nestedField: "b"},
},
mapField: map[string]string{
"foo": "bar",
"spam": "eggs",
},
}

second := root{
stringField: "a",
sliceField: []nested{
{nestedField: "c"},
},
mapField: map[string]string{
"foo": "bar",
"spam": "spam and eggs",
},
}

got := deepEquals(first, second, ExcludeFields{
".sliceField[*].nestedField",
".mapField[spam]",
})
```

## Defining you own matcher

Defining you own matcher is very simple: Implement a type that implements the `Matcher` interface which
Expand Down
42 changes: 42 additions & 0 deletions deep_equal.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"reflect"
"regexp"
"strings"
)

Expand Down Expand Up @@ -47,6 +48,16 @@ type ExcludeTypes []reflect.Type

func (ExcludeTypes) deepEqualOpt() {}

// ExcludeFields is a DeepEqualOpt that lists field patterns that should be
// excluded from the comparison.
//
// The field pattern format uses a dot notation and follows the format used
// in comparison output. The wildcard '*' can be used to match any value
// (i.e. for slice indexes).
type ExcludeFields []string

func (ExcludeFields) deepEqualOpt() {}

// DeepEqual asserts that given and wanted value are deeply equal by using reflection to inspect and dive into
// nested structures.
func DeepEqual[T any](want T, opts ...DeepEqualOpt) Matcher {
Expand Down Expand Up @@ -85,6 +96,19 @@ func deepEquals(want, got any, opts ...DeepEqualOpt) diff {
for _, t := range o {
ctx.excludedTypes[t] = struct{}{}
}
case ExcludeFields:
for _, p := range o {
pat := strings.ReplaceAll(p, ".", "\\.")
pat = strings.ReplaceAll(pat, "[", "\\[")
pat = strings.ReplaceAll(pat, "]", "\\]")
pat = strings.ReplaceAll(pat, "*", "[^.\\]]*")

r, err := regexp.Compile(pat)
if err != nil {
panic(fmt.Sprintf("invalid excluded fields pattern passed to DeepEqual: %q", p))
}
ctx.excludedFields = append(ctx.excludedFields, r)
}
}
}

Expand All @@ -104,6 +128,11 @@ func determineDiff(ctx *diffContext, want, got reflect.Value) {
// Otherwise mark want as visited.
ctx.visit(want)

// Test if the path has been marked for exclusion
if ctx.currentPathExcluded() {
return
}

// If neither want nor got are value (i.e. both are nil) there is no difference.
if !want.IsValid() && !got.IsValid() {
return
Expand Down Expand Up @@ -364,12 +393,25 @@ type diffContext struct {
nilMapsAreEmpty bool
excludeUnexportedStructFields bool
excludedTypes map[reflect.Type]struct{}
excludedFields []*regexp.Regexp

wantsSeen set[reflect.Value]
diff diff
nestingPath []string
}

func (c *diffContext) currentPathExcluded() bool {
p := c.path()

for _, pat := range c.excludedFields {
if pat.MatchString(p) {
return true
}
}

return false
}

func (c *diffContext) visit(want reflect.Value) {
if c.wantsSeen == nil {
c.wantsSeen = make(set[reflect.Value])
Expand Down
45 changes: 44 additions & 1 deletion deep_equal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func TestDeepEquals_excludeUnexportedFields(t *testing.T) {
}
}

func TestDeepEquals_excludeFieldsOfType(t *testing.T) {
func TestDeepEquals_excludeTypes(t *testing.T) {
type s struct {
f string
t int
Expand All @@ -167,3 +167,46 @@ func TestDeepEquals_excludeFieldsOfType(t *testing.T) {
t.Errorf("expected no diff but got %#v", got)
}
}

func TestDeepEquals_excludeFields(t *testing.T) {
type nested struct {
nestedField string
}

type root struct {
stringField string
sliceField []nested
mapField map[string]string
}

first := root{
stringField: "a",
sliceField: []nested{
{nestedField: "b"},
},
mapField: map[string]string{
"foo": "bar",
"spam": "eggs",
},
}

second := root{
stringField: "a",
sliceField: []nested{
{nestedField: "c"},
},
mapField: map[string]string{
"foo": "bar",
"spam": "spam and eggs",
},
}

got := deepEquals(first, second, ExcludeFields{
".sliceField[*].nestedField",
".mapField[spam]",
})

if got != nil {
t.Errorf("expected no diff but got %#v", got)
}
}

0 comments on commit 7f4f393

Please sign in to comment.