From 7f4f393d20d4ce262279c2c24c59e5cc05185606 Mon Sep 17 00:00:00 2001 From: Alexander Metzner Date: Thu, 23 Feb 2023 20:15:41 +0100 Subject: [PATCH] feature: DeepEquals option ExcludeFields --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++------ deep_equal.go | 42 +++++++++++++++++++++++++ deep_equal_test.go | 45 ++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 733739e..969db60 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deep_equal.go b/deep_equal.go index f8fabc8..a32e893 100644 --- a/deep_equal.go +++ b/deep_equal.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "reflect" + "regexp" "strings" ) @@ -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 { @@ -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) + } } } @@ -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 @@ -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]) diff --git a/deep_equal_test.go b/deep_equal_test.go index 3d9de47..8e42f37 100644 --- a/deep_equal_test.go +++ b/deep_equal_test.go @@ -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 @@ -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) + } +}