From 7fb0b16914ce0401890b42346bba054025b42bef Mon Sep 17 00:00:00 2001 From: am Date: Fri, 18 Apr 2025 13:17:03 +0100 Subject: [PATCH 1/8] Make concurrency-safe while preserving V1 API --- deep.go | 85 ++++++++++++------------- differ.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 45 deletions(-) create mode 100644 differ.go diff --git a/deep.go b/deep.go index 4be3e1f..936d8ee 100644 --- a/deep.go +++ b/deep.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "reflect" + "slices" "strings" ) @@ -76,7 +77,7 @@ type cmp struct { diff []string buff []string floatFormat string - flag map[byte]bool + conf Differ } var errorType = reflect.TypeOf((*error)(nil)).Elem() @@ -92,38 +93,32 @@ var errorType = reflect.TypeOf((*error)(nil)).Elem() // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. func Equal(a, b interface{}, flags ...interface{}) []string { - aVal := reflect.ValueOf(a) - bVal := reflect.ValueOf(b) - c := &cmp{ - diff: []string{}, - buff: []string{}, - floatFormat: fmt.Sprintf("%%.%df", FloatPrecision), - flag: map[byte]bool{}, - } - for i := range flags { - c.flag[flags[i].(byte)] = true - } - if a == nil && b == nil { - return nil - } else if a == nil && b != nil { - c.saveDiff("", b) - } else if a != nil && b == nil { - c.saveDiff(a, "") - } - if len(c.diff) > 0 { - return c.diff - } + // error ignored to preserve API + differ, _ := New( + WithCompareFunctions(CompareFunctions), + WithCompareUnexportedFields(CompareUnexportedFields), + WithFloatPrecision(FloatPrecision), + WithIgnoreSliceOrder(hasFlag(flags, FLAG_IGNORE_SLICE_ORDER)), + WithLogErrors(LogErrors), + WithMaxDepth(MaxDepth), + WithMaxDiff(MaxDiff), + WithNilMapsAreEmpty(NilMapsAreEmpty), + WithNilPointersAreZero(NilPointersAreZero), + WithNilSlicesAreEmpty(NilSlicesAreEmpty), + ) + return differ.Compare(a, b) +} - c.equals(aVal, bVal, 0) - if len(c.diff) > 0 { - return c.diff // diffs - } - return nil // no diffs +func hasFlag(flags []interface{}, flag byte) bool { + return slices.ContainsFunc(flags, func(i interface{}) bool { + v, ok := i.(byte) + return ok && v == flag + }) } func (c *cmp) equals(a, b reflect.Value, level int) { - if MaxDepth > 0 && level > MaxDepth { - logError(ErrMaxRecursion) + if c.conf.maxDepth > 0 && level > c.conf.maxDepth { + c.logError(ErrMaxRecursion) return } @@ -153,7 +148,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bFullType := bType.PkgPath() + "." + bType.Name() c.saveDiff(aFullType, bFullType) } - logError(ErrTypeMismatch) + c.logError(ErrTypeMismatch) return } @@ -193,10 +188,10 @@ func (c *cmp) equals(a, b reflect.Value, level int) { if bElem { b = b.Elem() } - if aElem && NilPointersAreZero && !a.IsValid() && b.IsValid() { + if aElem && c.conf.nilPointersAreZero && !a.IsValid() && b.IsValid() { a = reflect.Zero(b.Type()) } - if bElem && NilPointersAreZero && !b.IsValid() && a.IsValid() { + if bElem && c.conf.nilPointersAreZero && !b.IsValid() && a.IsValid() { b = reflect.Zero(a.Type()) } c.equals(a, b, level+1) @@ -244,7 +239,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } for i := 0; i < a.NumField(); i++ { - if aType.Field(i).PkgPath != "" && !CompareUnexportedFields { + if aType.Field(i).PkgPath != "" && !c.conf.compareUnexportedFields { continue // skip unexported field, e.g. s in type T struct {s string} } @@ -264,7 +259,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() // pop field name from buff - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } @@ -285,7 +280,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { */ if a.IsNil() || b.IsNil() { - if NilMapsAreEmpty { + if c.conf.nilMapsAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -320,7 +315,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { return } } @@ -333,7 +328,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("map[%v]", key)) c.saveDiff("", b.MapIndex(key)) c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { return } } @@ -343,12 +338,12 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } case reflect.Slice: - if NilSlicesAreEmpty { + if c.conf.nilSlicesAreEmpty { if a.IsNil() && b.Len() != 0 { c.saveDiff("", b) return @@ -378,7 +373,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { return } - if c.flag[FLAG_IGNORE_SLICE_ORDER] { + if c.conf.ignoreSliceOrder { // Compare slices by value and value count; ignore order. // Value equality is impliclity established by the maps: // any value v1 will hash to the same map value if it's equal @@ -411,7 +406,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff("", b.Index(i)) } c.pop() - if len(c.diff) >= MaxDiff { + if len(c.diff) >= c.conf.maxDiff { break } } @@ -451,7 +446,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff(a.String(), b.String()) } case reflect.Func: - if CompareFunctions { + if c.conf.compareFunctions { if !a.IsNil() || !b.IsNil() { aVal, bVal := "nil func", "nil func" if !a.IsNil() { @@ -464,7 +459,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } } default: - logError(ErrNotHandled) + c.logError(ErrNotHandled) } } @@ -506,8 +501,8 @@ func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, } } -func logError(err error) { - if LogErrors { +func (c *cmp) logError(err error) { + if c.conf.logErrors { log.Println(err) } } diff --git a/differ.go b/differ.go new file mode 100644 index 0000000..8060577 --- /dev/null +++ b/differ.go @@ -0,0 +1,186 @@ +// Package deep provides function deep.Equal which is like reflect.DeepEqual but +// returns a list of differences. This is helpful when comparing complex types +// like structures and maps. +package deep + +import ( + "fmt" + "reflect" +) + +type Opt func(*Differ) error + +// WithFloatPrecision is the number of decimal places to round float values +// to when comparing. +func WithFloatPrecision(p int) Opt { + return func(d *Differ) error { + d.floatPrecision = p + return nil + } +} + +// WithMaxDiff specifies the maximum number of differences to return. +func WithMaxDiff(m int) Opt { + return func(d *Differ) error { + d.maxDiff = m + return nil + } +} + +// WithMaxDepth specifies the maximum levels of a struct to recurse into, +// if greater than zero. If zero, there is no limit. +func WithMaxDepth(m int) Opt { + return func(d *Differ) error { + d.maxDepth = m + return nil + } +} + +// WithLogErrors causes errors to be logged to STDERR when true. +func WithLogErrors(b bool) Opt { + return func(differ *Differ) error { + differ.logErrors = b + return nil + } +} + +// WithCompareUnexportedFields causes unexported struct fields, like s in +// T{s int}, to be compared when true. This does not work for comparing +// error or Time types on unexported fields because methods on unexported +// fields cannot be called. +func WithCompareUnexportedFields(b bool) Opt { + return func(differ *Differ) error { + differ.compareUnexportedFields = b + return nil + } +} + +// WithCompareFunctions compares functions the same as reflect.DeepEqual: +// only two nil functions are equal. Every other combination is not equal. +// This is disabled by default because previous versions of this package +// ignored functions. Enabling it can possibly report new diffs. +func WithCompareFunctions(b bool) Opt { + return func(differ *Differ) error { + differ.compareFunctions = b + return nil + } +} + +// WithNilSlicesAreEmpty causes a nil slice to be equal to an empty slice. +func WithNilSlicesAreEmpty(b bool) Opt { + return func(differ *Differ) error { + differ.nilSlicesAreEmpty = b + return nil + } +} + +// WithNilMapsAreEmpty causes a nil map to be equal to an empty map. +func WithNilMapsAreEmpty(b bool) Opt { + return func(differ *Differ) error { + differ.nilMapsAreEmpty = b + return nil + } +} + +// WithNilPointersAreZero causes a nil pointer to be equal to a zero value. +func WithNilPointersAreZero(b bool) Opt { + return func(differ *Differ) error { + differ.nilPointersAreZero = b + return nil + } +} + +// WithIgnoreSliceOrder causes Equal to ignore slice order so that +// []int{1, 2} and []int{2, 1} are equal. Only slices of primitive scalars +// like numbers and strings are supported. Slices of complex types, +// like []T where T is a struct, are undefined because Equal does not +// recurse into the slice value when this flag is enabled. +func WithIgnoreSliceOrder(b bool) Opt { + return func(differ *Differ) error { + differ.ignoreSliceOrder = b + return nil + } +} + +type Differ struct { + compareFunctions bool + compareUnexportedFields bool + floatPrecision int + ignoreSliceOrder bool + logErrors bool + maxDepth int + maxDiff int + nilMapsAreEmpty bool + nilPointersAreZero bool + nilSlicesAreEmpty bool +} + +func New(opts ...Opt) (d Differ, err error) { + d = Differ{ + // options where zero-value equals default value are omitted + floatPrecision: 10, + maxDiff: 10, + } + for opt := range opts { + err = opts[opt](&d) + if err != nil { + return d, fmt.Errorf("invalid option: %w", err) + } + } + return d, nil +} + +type Delta []string + +func (d Delta) Equal(other Delta) bool { + if len(d) != len(other) { + return false + } + for i := range d { + if d[i] != other[i] { + return false + } + } + return true +} + +func (d Delta) ToSlice() []string { + return d +} + +// Compare compares variables a and b, recursing into their structure up to +// MaxDepth levels deep (if greater than zero), and returns a list of differences, +// or nil if there are none. Some differences may not be found if an error is +// also returned. +// +// If a type has an Equal method, like time.Equal, it is called to check for +// equality. +// +// When comparing a struct, if a field has the tag `deep:"-"` then it will be +// ignored. +func (d Differ) Compare(a, b any) Delta { + aVal := reflect.ValueOf(a) + bVal := reflect.ValueOf(b) + c := &cmp{ + conf: d, + diff: []string{}, + buff: []string{}, + floatFormat: fmt.Sprintf("%%.%df", d.floatPrecision), + } + if a == nil && b == nil { + return nil + } else if a == nil && b != nil { + c.saveDiff("", b) + } else if a != nil && b == nil { + c.saveDiff(a, "") + } + if len(c.diff) > 0 { + return c.diff + } + + c.equals(aVal, bVal, 0) + if len(c.diff) > 0 { + return c.diff // diffs + } + return nil // no diffs +} From aeb4df5cd0177ff19ee9a34a26f547eb2f446f99 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 15:06:13 -0400 Subject: [PATCH 2/8] Update to modern Go syntax --- deep.go | 32 ++++++++++++++------------------ deep_test.go | 19 +++++++++---------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/deep.go b/deep.go index 83a2973..a3f9599 100644 --- a/deep.go +++ b/deep.go @@ -80,7 +80,7 @@ type cmp struct { conf Differ } -var errorType = reflect.TypeOf((*error)(nil)).Elem() +var errorType = reflect.TypeFor[error]() // Equal compares variables a and b, recursing into their structure up to // MaxDepth levels deep (if greater than zero), and returns a list of differences, @@ -92,7 +92,7 @@ var errorType = reflect.TypeOf((*error)(nil)).Elem() // // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. -func Equal(a, b interface{}, flags ...interface{}) []string { +func Equal(a, b any, flags ...any) []string { // error ignored to preserve API differ, _ := New( WithCompareFunctions(CompareFunctions), @@ -109,8 +109,8 @@ func Equal(a, b interface{}, flags ...interface{}) []string { return differ.Compare(a, b) } -func hasFlag(flags []interface{}, flag byte) bool { - return slices.ContainsFunc(flags, func(i interface{}) bool { +func hasFlag(flags []any, flag byte) bool { + return slices.ContainsFunc(flags, func(i any) bool { v, ok := i.(byte) return ok && v == flag }) @@ -157,8 +157,8 @@ func (c *cmp) equals(a, b reflect.Value, level int) { bKind := b.Kind() // Do a and b have underlying elements? Yes if they're ptr or interface. - aElem := aKind == reflect.Ptr || aKind == reflect.Interface - bElem := bKind == reflect.Ptr || bKind == reflect.Interface + aElem := aKind == reflect.Pointer || aKind == reflect.Interface + bElem := bKind == reflect.Pointer || bKind == reflect.Interface // If both types implement the error interface, compare the error strings. // This must be done before dereferencing because errors.New() returns a @@ -334,7 +334,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { } case reflect.Array: n := a.Len() - for i := 0; i < n; i++ { + for i := range n { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() @@ -380,23 +380,19 @@ func (c *cmp) equals(a, b reflect.Value, level int) { // to another value v2. Then equality is determined by value // count: presuming v1==v2, then the slices are equal if there // are equal numbers of v1 in each slice. - am := map[interface{}]int{} + am := map[any]int{} for i := 0; i < a.Len(); i++ { am[a.Index(i).Interface()] += 1 } - bm := map[interface{}]int{} + bm := map[any]int{} for i := 0; i < b.Len(); i++ { bm[b.Index(i).Interface()] += 1 } - c.cmpMapValueCounts(a, b, am, bm, true) // a cmp b - c.cmpMapValueCounts(b, a, bm, am, false) // b cmp a + c.cmpMapValueCounts(am, bm, true) // a cmp b + c.cmpMapValueCounts(bm, am, false) // b cmp a } else { // Compare slices by order - n := aLen - if bLen > aLen { - n = bLen - } - for i := 0; i < n; i++ { + for i := range max(aLen, bLen) { c.push(fmt.Sprintf("slice[%d]", i)) if i < aLen && i < bLen { c.equals(a.Index(i), b.Index(i), level+1) @@ -473,7 +469,7 @@ func (c *cmp) pop() { } } -func (c *cmp) saveDiff(aval, bval interface{}) { +func (c *cmp) saveDiff(aval, bval any) { if len(c.buff) > 0 { varName := strings.Join(c.buff, ".") c.diff = append(c.diff, fmt.Sprintf("%s: %v != %v", varName, aval, bval)) @@ -482,7 +478,7 @@ func (c *cmp) saveDiff(aval, bval interface{}) { } } -func (c *cmp) cmpMapValueCounts(a, b reflect.Value, am, bm map[interface{}]int, a2b bool) { +func (c *cmp) cmpMapValueCounts(am, bm map[any]int, a2b bool) { for v := range am { aCount, _ := am[v] bCount, _ := bm[v] diff --git a/deep_test.go b/deep_test.go index e743ae8..9a7fb77 100644 --- a/deep_test.go +++ b/deep_test.go @@ -62,7 +62,6 @@ func TestFloat(t *testing.T) { if diff[0] != "1.123456 != 1.123457" { t.Error("wrong diff:", diff[0]) } - } func TestInt(t *testing.T) { @@ -319,8 +318,8 @@ func TestNotHandled(t *testing.T) { b := unsafe.Pointer(&v) // UnsafePointer added in Go 1.88. Use these lines once this pkg // no longer supports Go 1.17. - //a := reflect.ValueOf(v).UnsafePointer() - //b := reflect.ValueOf(v).UnsafePointer() + // a := reflect.ValueOf(v).UnsafePointer() + // b := reflect.ValueOf(v).UnsafePointer() diff := deep.Equal(a, b) if len(diff) > 0 { t.Error("got diffs:", diff) @@ -1185,12 +1184,12 @@ func TestTimeUnexported(t *testing.T) { } func TestInterface(t *testing.T) { - a := map[string]interface{}{ + a := map[string]any{ "foo": map[string]string{ "bar": "a", }, } - b := map[string]interface{}{ + b := map[string]any{ "foo": map[string]string{ "bar": "b", }, @@ -1211,10 +1210,10 @@ func TestInterface2(t *testing.T) { } }() - a := map[string]interface{}{ + a := map[string]any{ "bar": 1, } - b := map[string]interface{}{ + b := map[string]any{ "bar": 1.23, } diff := deep.Equal(a, b) @@ -1228,10 +1227,10 @@ func TestInterface2(t *testing.T) { func TestInterface3(t *testing.T) { type Value struct{ int } - a := map[string]interface{}{ + a := map[string]any{ "foo": &Value{}, } - b := map[string]interface{}{ + b := map[string]any{ "foo": 1.23, } diff := deep.Equal(a, b) @@ -1431,7 +1430,7 @@ func TestNil(t *testing.T) { } mark := student{"mark", 10} - var someNilThing interface{} = nil + var someNilThing any = nil diff := deep.Equal(someNilThing, mark) if diff == nil { t.Error("Nil value to comparison should not be equal") From d057959b52ee82f0557a21b58a8a77e3ec58b23a Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 15:17:02 -0400 Subject: [PATCH 3/8] Rename Go workflow --- .github/workflows/{go.yml => go-test-matrix.yml} | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) rename .github/workflows/{go.yml => go-test-matrix.yml} (83%) diff --git a/.github/workflows/go.yml b/.github/workflows/go-test-matrix.yml similarity index 83% rename from .github/workflows/go.yml rename to .github/workflows/go-test-matrix.yml index 5177bca..8c490ed 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go-test-matrix.yml @@ -1,7 +1,4 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - -name: Go +name: "Go Test Matrix" on: push: From 3ca8822642353815111a20bbfb50cd063ef972a1 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 16:21:06 -0400 Subject: [PATCH 4/8] Test Differ, rename New to NewDiffer and remove error retval, rename Delta to Diff --- .gitignore | 1 + deep.go | 23 ++++++++------- differ.go | 77 +++++++++++++++++++++----------------------------- differ_test.go | 52 ++++++++++++++++++++++++++++++++++ test/coverage | 4 ++- 5 files changed, 99 insertions(+), 58 deletions(-) create mode 100644 differ_test.go diff --git a/.gitignore b/.gitignore index 53f12f0..5f18ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.swp *.out +coverage.* diff --git a/deep.go b/deep.go index a3f9599..1f4efdc 100644 --- a/deep.go +++ b/deep.go @@ -94,19 +94,18 @@ var errorType = reflect.TypeFor[error]() // ignored. func Equal(a, b any, flags ...any) []string { // error ignored to preserve API - differ, _ := New( + return NewDiffer( WithCompareFunctions(CompareFunctions), WithCompareUnexportedFields(CompareUnexportedFields), - WithFloatPrecision(FloatPrecision), + WithFloatPrecision(uint(FloatPrecision)), WithIgnoreSliceOrder(hasFlag(flags, FLAG_IGNORE_SLICE_ORDER)), WithLogErrors(LogErrors), - WithMaxDepth(MaxDepth), - WithMaxDiff(MaxDiff), + WithMaxDepth(uint(MaxDepth)), + WithMaxDiff(uint(MaxDiff)), WithNilMapsAreEmpty(NilMapsAreEmpty), WithNilPointersAreZero(NilPointersAreZero), WithNilSlicesAreEmpty(NilSlicesAreEmpty), - ) - return differ.Compare(a, b) + ).Diff(a, b) } func hasFlag(flags []any, flag byte) bool { @@ -117,7 +116,7 @@ func hasFlag(flags []any, flag byte) bool { } func (c *cmp) equals(a, b reflect.Value, level int) { - if c.conf.maxDepth > 0 && level > c.conf.maxDepth { + if c.conf.maxDepth > 0 && uint(level) > c.conf.maxDepth { c.logError(ErrMaxRecursion) return } @@ -259,7 +258,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() // pop field name from buff - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -315,7 +314,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } @@ -328,7 +327,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("map[%v]", key)) c.saveDiff("", b.MapIndex(key)) c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { return } } @@ -338,7 +337,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.push(fmt.Sprintf("array[%d]", i)) c.equals(a.Index(i), b.Index(i), level+1) c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } @@ -402,7 +401,7 @@ func (c *cmp) equals(a, b reflect.Value, level int) { c.saveDiff("", b.Index(i)) } c.pop() - if len(c.diff) >= c.conf.maxDiff { + if uint(len(c.diff)) >= c.conf.maxDiff { break } } diff --git a/differ.go b/differ.go index 8060577..d4dcffa 100644 --- a/differ.go +++ b/differ.go @@ -8,39 +8,35 @@ import ( "reflect" ) -type Opt func(*Differ) error +type Opt func(*Differ) // WithFloatPrecision is the number of decimal places to round float values // to when comparing. -func WithFloatPrecision(p int) Opt { - return func(d *Differ) error { +func WithFloatPrecision(p uint) Opt { + return func(d *Differ) { d.floatPrecision = p - return nil } } // WithMaxDiff specifies the maximum number of differences to return. -func WithMaxDiff(m int) Opt { - return func(d *Differ) error { +func WithMaxDiff(m uint) Opt { + return func(d *Differ) { d.maxDiff = m - return nil } } // WithMaxDepth specifies the maximum levels of a struct to recurse into, // if greater than zero. If zero, there is no limit. -func WithMaxDepth(m int) Opt { - return func(d *Differ) error { +func WithMaxDepth(m uint) Opt { + return func(d *Differ) { d.maxDepth = m - return nil } } // WithLogErrors causes errors to be logged to STDERR when true. func WithLogErrors(b bool) Opt { - return func(differ *Differ) error { - differ.logErrors = b - return nil + return func(d *Differ) { + d.logErrors = b } } @@ -49,9 +45,8 @@ func WithLogErrors(b bool) Opt { // error or Time types on unexported fields because methods on unexported // fields cannot be called. func WithCompareUnexportedFields(b bool) Opt { - return func(differ *Differ) error { - differ.compareUnexportedFields = b - return nil + return func(d *Differ) { + d.compareUnexportedFields = b } } @@ -60,33 +55,29 @@ func WithCompareUnexportedFields(b bool) Opt { // This is disabled by default because previous versions of this package // ignored functions. Enabling it can possibly report new diffs. func WithCompareFunctions(b bool) Opt { - return func(differ *Differ) error { - differ.compareFunctions = b - return nil + return func(d *Differ) { + d.compareFunctions = b } } // WithNilSlicesAreEmpty causes a nil slice to be equal to an empty slice. func WithNilSlicesAreEmpty(b bool) Opt { - return func(differ *Differ) error { - differ.nilSlicesAreEmpty = b - return nil + return func(d *Differ) { + d.nilSlicesAreEmpty = b } } // WithNilMapsAreEmpty causes a nil map to be equal to an empty map. func WithNilMapsAreEmpty(b bool) Opt { - return func(differ *Differ) error { - differ.nilMapsAreEmpty = b - return nil + return func(d *Differ) { + d.nilMapsAreEmpty = b } } // WithNilPointersAreZero causes a nil pointer to be equal to a zero value. func WithNilPointersAreZero(b bool) Opt { - return func(differ *Differ) error { - differ.nilPointersAreZero = b - return nil + return func(d *Differ) { + d.nilPointersAreZero = b } } @@ -96,43 +87,39 @@ func WithNilPointersAreZero(b bool) Opt { // like []T where T is a struct, are undefined because Equal does not // recurse into the slice value when this flag is enabled. func WithIgnoreSliceOrder(b bool) Opt { - return func(differ *Differ) error { - differ.ignoreSliceOrder = b - return nil + return func(d *Differ) { + d.ignoreSliceOrder = b } } type Differ struct { compareFunctions bool compareUnexportedFields bool - floatPrecision int + floatPrecision uint ignoreSliceOrder bool logErrors bool - maxDepth int - maxDiff int + maxDepth uint + maxDiff uint nilMapsAreEmpty bool nilPointersAreZero bool nilSlicesAreEmpty bool } -func New(opts ...Opt) (d Differ, err error) { - d = Differ{ +func NewDiffer(opts ...Opt) Differ { + d := Differ{ // options where zero-value equals default value are omitted floatPrecision: 10, maxDiff: 10, } for opt := range opts { - err = opts[opt](&d) - if err != nil { - return d, fmt.Errorf("invalid option: %w", err) - } + opts[opt](&d) } - return d, nil + return d } -type Delta []string +type Diff []string -func (d Delta) Equal(other Delta) bool { +func (d Diff) Equal(other Diff) bool { if len(d) != len(other) { return false } @@ -144,7 +131,7 @@ func (d Delta) Equal(other Delta) bool { return true } -func (d Delta) ToSlice() []string { +func (d Diff) ToSlice() []string { return d } @@ -158,7 +145,7 @@ func (d Delta) ToSlice() []string { // // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. -func (d Differ) Compare(a, b any) Delta { +func (d Differ) Diff(a, b any) Diff { aVal := reflect.ValueOf(a) bVal := reflect.ValueOf(b) c := &cmp{ diff --git a/differ_test.go b/differ_test.go new file mode 100644 index 0000000..42a32ac --- /dev/null +++ b/differ_test.go @@ -0,0 +1,52 @@ +package deep_test + +import ( + "testing" + + "github.com/go-test/deep" +) + +func TestDiffer(t *testing.T) { + d := deep.NewDiffer() + + diff1 := d.Diff("foo", "bar") + if diff1 == nil { + t.Error("no diff") + } + + s := diff1.ToSlice() + if len(s) != 1 { + t.Errorf("got slice len %d, expected 1", len(s)) + } + + diff2 := d.Diff("foo", "bar") + if diff2 == nil { + t.Error("no diff") + } + + // diff1 and diff2 are equal (i.e. same diff: foo != bar) + eq := diff1.Equal(diff2) + if eq != true { + t.Error("diffs not equal") + } + + // diff1 and diff3 are NOT equal + diff3 := d.Diff("a", "b") + if diff3 == nil { + t.Error("should not be equal") + } + eq = diff1.Equal(diff3) + if eq == true { + t.Error("diffs should not be equal") + } + + // diff4 isn't a diff, so it's not equal to diff1 + diff4 := d.Diff("ok", "ok") + if diff4 != nil { + t.Error("should be equal") + } + eq = diff1.Equal(diff4) + if eq == true { + t.Error("should not be equal") + } +} diff --git a/test/coverage b/test/coverage index e328944..ae5376c 100755 --- a/test/coverage +++ b/test/coverage @@ -1,3 +1,5 @@ #!/bin/sh +export GTK_MODULES="" # avoid Ubuntu/Firefox warning go test -coverprofile=coverage.out "$@" -go tool cover -html=coverage.out +go tool cover -html=coverage.out -o coverage.html +open coverage.html From 7dc67712d19cc0025eea87bc4436705e69e636d0 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 18:16:55 -0400 Subject: [PATCH 5/8] Add inline testing with Differ --- differ.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++---- differ_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/differ.go b/differ.go index d4dcffa..793b6fe 100644 --- a/differ.go +++ b/differ.go @@ -92,6 +92,32 @@ func WithIgnoreSliceOrder(b bool) Opt { } } +// WithInlineTest causes Differ.Diff to call T.Error when diffs are found. +// It also calls T.Helper to make the error report where it's called in the +// unit test, not within Differ.Diff. This option replaces a common pattern: +// +// if diff := deep.Equal("foo", "bar"); diff != nil { +// t.Error(diff) +// } +// +// Instead, where d is a Differ with inline testing: +// +// d.Diff("foo", "bar") +// +// With inline testing, Differ.Diff still returns a Diff (nil or non-nil). +func WithInlineTest(i InlineTest) Opt { + return func(d *Differ) { d.inline = i } +} + +// InlineTest is the value and options passed to WithInlineTest. Only T is +// required, and notice: it's the deep.T interface, and standard library +// testing.T satisfies this interface. (The deep.T interface is necessary +// to test the testing.) +type InlineTest struct { + T T + Fatal bool // call T.Error by default +} + type Differ struct { compareFunctions bool compareUnexportedFields bool @@ -103,6 +129,7 @@ type Differ struct { nilMapsAreEmpty bool nilPointersAreZero bool nilSlicesAreEmpty bool + inline InlineTest } func NewDiffer(opts ...Opt) Differ { @@ -146,6 +173,11 @@ func (d Diff) ToSlice() []string { // When comparing a struct, if a field has the tag `deep:"-"` then it will be // ignored. func (d Differ) Diff(a, b any) Diff { + t := d.inline.T + if t != nil { + t.Helper() + } + aVal := reflect.ValueOf(a) bVal := reflect.ValueOf(b) c := &cmp{ @@ -154,20 +186,38 @@ func (d Differ) Diff(a, b any) Diff { buff: []string{}, floatFormat: fmt.Sprintf("%%.%df", d.floatPrecision), } + + // Can bypass full diff if both or only one operand is nil if a == nil && b == nil { return nil } else if a == nil && b != nil { c.saveDiff("", b) + goto ret } else if a != nil && b == nil { c.saveDiff(a, "") + goto ret } - if len(c.diff) > 0 { - return c.diff - } - c.equals(aVal, bVal, 0) + c.equals(aVal, bVal, 0) // full diff + +ret: if len(c.diff) > 0 { + if t != nil { + if d.inline.Fatal { + t.Fatal(c.diff) + } else { + t.Error(c.diff) + } + } return c.diff // diffs } + return nil // no diffs } + +// T is the subset of standard library testing.T that option WithInlineTest needs. +type T interface { + Error(args ...any) + Fatal(args ...any) + Helper() +} diff --git a/differ_test.go b/differ_test.go index 42a32ac..ba3fb46 100644 --- a/differ_test.go +++ b/differ_test.go @@ -50,3 +50,57 @@ func TestDiffer(t *testing.T) { t.Error("should not be equal") } } + +type mockT struct { + calledError bool + calledFatal bool + calledHelper bool +} + +func (t *mockT) Error(args ...any) { t.calledError = true } +func (t *mockT) Fatal(args ...any) { t.calledFatal = true } +func (t *mockT) Helper() { t.calledHelper = true } + +func TestDiffer_WithInlineTest(t *testing.T) { + // No diff, no calls to T except Helper + m := &mockT{} + d := deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "foo") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and by default Error, not Fatal, is called + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError != true { + t.Errorf("did not call T.Error") + } + if m.calledFatal == true { + t.Errorf("called T.Fatal") + } + + // Diff and Fatal + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m, Fatal: true})) + d.Diff("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + if m.calledError == true { + t.Errorf("called T.Error") + } + if m.calledFatal != true { + t.Errorf("did not call T.Fatal") + } +} From 4e599ffb6e847121102dd8b42cf2adbb131be55d Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 5 Oct 2026 18:36:31 -0400 Subject: [PATCH 6/8] Add blog link about reflect.TypeFor --- deep.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deep.go b/deep.go index 1f4efdc..ccd6baa 100644 --- a/deep.go +++ b/deep.go @@ -80,7 +80,7 @@ type cmp struct { conf Differ } -var errorType = reflect.TypeFor[error]() +var errorType = reflect.TypeFor[error]() // https://blog.carlana.net/post/2024/golang-reflect-type-for/ // Equal compares variables a and b, recursing into their structure up to // MaxDepth levels deep (if greater than zero), and returns a list of differences, From 60f6defea3c01af4ddbb91dd83a7a4f4555aa70f Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Tue, 13 Oct 2026 12:19:03 -0400 Subject: [PATCH 7/8] Fix branch name in test matrix --- .github/workflows/go-test-matrix.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go-test-matrix.yml b/.github/workflows/go-test-matrix.yml index 8c490ed..3ed7800 100644 --- a/.github/workflows/go-test-matrix.yml +++ b/.github/workflows/go-test-matrix.yml @@ -2,9 +2,9 @@ name: "Go Test Matrix" on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: test: From 4235085bcb2e998f0f20878d8beba50350562288 Mon Sep 17 00:00:00 2001 From: Daniel Nichter Date: Mon, 26 Oct 2026 19:34:42 -0400 Subject: [PATCH 8/8] Add deep.Testing, Differ.Equal, Differ.Equalf; remove Diff.Equal; rename InlineTest to InlineTesting; update docs and README --- README.md | 110 ++++++++++++++++++++++-------- deep.go | 15 ++-- differ.go | 182 +++++++++++++++++++++++++++++++++++-------------- differ_test.go | 105 ++++++++++++++++------------ 4 files changed, 279 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index b4f723c..7fff6cb 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,106 @@ # Deep Variable Equality for Humans -[![Coverage Status](https://coveralls.io/repos/github/go-test/deep/badge.svg?branch=master)](https://coveralls.io/github/go-test/deep?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/go-test/deep/badge.svg?branch=main)](https://coveralls.io/github/go-test/deep?branch=main) [![Go Reference](https://pkg.go.dev/badge/github.com/go-test/deep.svg)](https://pkg.go.dev/github.com/go-test/deep) -This package provides a single function: `deep.Equal`. It's like [reflect.DeepEqual](https://pkg.go.dev/reflect#DeepEqual) but friendlier to humans for two reason: - -* `deep.Equal` returns a list of differences -* `deep.Equal` does not compare unexported fields (by default) - -`reflect.DeepEqual` is good (like all things Golang!), but it's a game of [Hunt the Wumpus](https://en.wikipedia.org/wiki/Hunt_the_Wumpus). For large maps, slices, and structs, finding the difference is difficult. - -`deep.Equal` doesn't play games with you, it lists the differences: +This package provides deep variable equality testing for tired humans and indefatigable AI. +Whereas [reflect.DeepEqual](https://pkg.go.dev/reflect#DeepEqual) says only true or false, go-test/deep says where deeply nested values differ. ```go package main_test import ( - "testing" - "github.com/go-test/deep" + "testing" + "github.com/go-test/deep" ) type T struct { - Name string - Numbers []float64 + Name string + Numbers []float64 } func TestDeepEqual(t *testing.T) { - // Can you spot the difference? - t1 := T{ - Name: "Isabella", - Numbers: []float64{1.13459, 2.29343, 3.010100010}, - } - t2 := T{ - Name: "Isabella", - Numbers: []float64{1.13459, 2.29843, 3.010100010}, - } - - if diff := deep.Equal(t1, t2); diff != nil { - t.Error(diff) - } + a := T{ + Name: "Isabella", + Numbers: []float64{1.13459, 2.29343, 3.010100010}, + } + b := T{ + Name: "Isabella", + Numbers: []float64{1.13459, 2.29843, 3.010100010}, + } + d := deep.Testing(t) + d.Equal(a, b) } ``` +Run `go test` and you get: ``` -$ go test --- FAIL: TestDeepEqual (0.00s) - main_test.go:25: [Numbers.slice[1]: 2.29343 != 2.29843] + main_test.go:23: [Numbers.slice[1]: 2.29343 != 2.29843] ``` The difference is in `Numbers.slice[1]`: the two values aren't equal using Go `==`. + +## New Usage (v1.2) + +As of go-test/deep v1.2, use the `Differ` type for parallel test runs and better future-proofing: + +```go +import "github.com/go-test/deep" + +func compare() { + d := deep.NewDiffer() + a := "foo" + b := "bar" + if diff := d.Equal(a, b); diff != nil { + // do something + } +} +``` + +But since go-test/deep is almost always used in unit tests, there's a simpler way: + +```go +func TestFoo(t *testing.T) { + d := deep.Testing(t) + a := "foo" + b := "bar" + d.Equal(a, b) +} +``` + +`deep.Testing` is just a wrapper around `deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{T: t}))`. +When `d.Equal` finds a diff, inline testing reports it at that line using `t.Errorf`. +To add context to the failure message, use `d.Equalf(a, b, "test case %d failed: {diff}", i)`. + +## Classic Usage + +Before v1.2 this package exported only one function: `deep.Equal`. +It still does for backward compatibility. + +```go +import "github.com/go-test/deep" + +func compare() { + a := "foo" + b := "bar" + if diff := deep.Equal(a, b); diff != nil { + // do something + } +} +``` + +The package function `deep.Equal` and a default `Differ` (no options) are functionally equivalent, but prefer `Differ`. + +## Contributing + +Human and AI contributions welcome, just be sure to: + +1. Make minimal, focused, tactical changes +2. Add test cases to `deep_test.go` (or `differ_test.go` if specific to `Differ`) +3. Maintain 100% test coverage +4. Preserve API compatibility +5. Document the "what and why" + +As always with free open source projects, issues and PRs do not guarantee adoption, and those that are merged might be tweaked and changed before release. diff --git a/deep.go b/deep.go index e4c6019..44254ea 100644 --- a/deep.go +++ b/deep.go @@ -90,16 +90,11 @@ type cmp struct { var errorType = reflect.TypeFor[error]() // https://blog.carlana.net/post/2024/golang-reflect-type-for/ -// Equal compares variables a and b, recursing into their structure up to -// MaxDepth levels deep (if greater than zero), and returns a list of differences, -// or nil if there are none. Some differences may not be found if an error is -// also returned. +// Equal returns the set of differences between a and b, or nil if none found. // -// If a type has an Equal method, like time.Equal, it is called to check for -// equality. -// -// When comparing a struct, if a field has the tag `deep:"-"` then it will be -// ignored. +// This is the original package function that, as of v1.2, has been superseded +// by Differ. It's functionally equivalent to a Differ with default values (no +// options). func Equal(a, b any, flags ...any) []string { // error ignored to preserve API return NewDiffer( @@ -113,7 +108,7 @@ func Equal(a, b any, flags ...any) []string { WithNilMapsAreEmpty(NilMapsAreEmpty), WithNilPointersAreZero(NilPointersAreZero), WithNilSlicesAreEmpty(NilSlicesAreEmpty), - ).Diff(a, b) + ).diff(a, b) } func hasFlag(flags []any, flag byte) bool { diff --git a/differ.go b/differ.go index 793b6fe..94e2bdb 100644 --- a/differ.go +++ b/differ.go @@ -1,11 +1,17 @@ -// Package deep provides function deep.Equal which is like reflect.DeepEqual but -// returns a list of differences. This is helpful when comparing complex types -// like structures and maps. +// Package deep provides function deep.Equal which is like reflect.DeepEqual +// but returns a descriptive set of differences. This surfaces differences in +// deeply nested data structures. It also has options for pragmatic equality, +// such as treating empty and nil slices as equal. And it works with testing.T +// to test and report diffs in a single line. +// +// As of v1.2, Differ is canonical usage rather than package function Equal. +// See the Differ type docs for usage. package deep import ( "fmt" "reflect" + "strings" ) type Opt func(*Differ) @@ -92,32 +98,60 @@ func WithIgnoreSliceOrder(b bool) Opt { } } -// WithInlineTest causes Differ.Diff to call T.Error when diffs are found. +// WithInlineTesting makes Equal and Equalf call T.Errorf when diffs are found. // It also calls T.Helper to make the error report where it's called in the -// unit test, not within Differ.Diff. This option replaces a common pattern: +// unit test, not within Equal/Equalf. This option replaces a common pattern: // // if diff := deep.Equal("foo", "bar"); diff != nil { // t.Error(diff) // } // -// Instead, where d is a Differ with inline testing: +// Instead, when d is a Differ with inline testing: // -// d.Diff("foo", "bar") +// d.Equal("foo", "bar") // -// With inline testing, Differ.Diff still returns a Diff (nil or non-nil). -func WithInlineTest(i InlineTest) Opt { +// Or use Equalf to format the test failure output. +func WithInlineTesting(i InlineTesting) Opt { return func(d *Differ) { d.inline = i } } -// InlineTest is the value and options passed to WithInlineTest. Only T is -// required, and notice: it's the deep.T interface, and standard library -// testing.T satisfies this interface. (The deep.T interface is necessary -// to test the testing.) -type InlineTest struct { - T T - Fatal bool // call T.Error by default +// InlineTesting is the value passed to WithInlineTesting. Only T is required, +// and notice: it's the deep.T interface, and standard library testing.T +// satisfies this interface. (The deep.T interface is necessary to test the +// testing.) +// +// If specially formatted test failure output is not needed, deep.Testing +// quickly instantiates an inline testing Differ. +type InlineTesting struct { + T T // *testing.T (required) + Fatal bool // call T.Errorf by default + DiffFormatter func(Diff) string // replaces DiffPlaceholder } +// DiffPlaceholder is replaced by the formatted diff in the Equalf format. +const DiffPlaceholder = `{diff}` + +// Differ diffs two values and returns a Diff if any are found, else nil. +// The easiest usage is with inline testing: +// +// func TestFoo(t *testing.T) { +// d := deep.Testing(t) +// a := "foo" +// b := "bar" +// d.Equal(a, b) +// } +// +// That unit test will t.Error at the d.Equal line because "foo" != "bar". +// For greater control of the Differ, call NewDiffer with options. If the old +// style (before v1.2) is preferred but parallel Differ are required: +// +// d := deep.NewDiffer() +// if diff := d.Equal(a, b); diff != nil { +// t.Error(diff) +// } +// +// That is functionally equivalent to using the original package function: +// deep.Equal(a, b), which is preserved because it's still widely used. type Differ struct { compareFunctions bool compareUnexportedFields bool @@ -129,9 +163,11 @@ type Differ struct { nilMapsAreEmpty bool nilPointersAreZero bool nilSlicesAreEmpty bool - inline InlineTest + inline InlineTesting } +// NewDiffer creates a new Differ with default values if no options are given. +// Differ defaults are equivalent to package function Equal. func NewDiffer(opts ...Opt) Differ { d := Differ{ // options where zero-value equals default value are omitted @@ -144,40 +180,91 @@ func NewDiffer(opts ...Opt) Differ { return d } +// Testing returns an inline testing Differ. It's a convenience wrapper for: +// +// d := deep.NewDiffer( +// deep.WithInlineTesting(deep.InlineTesting{ +// T: t, +// }), +// ) +func Testing(t T) Differ { + return NewDiffer(WithInlineTesting(InlineTesting{T: t})) +} + +// Diff is a set of differences between a and b, or nil if no diffs were found. type Diff []string -func (d Diff) Equal(other Diff) bool { - if len(d) != len(other) { - return false - } - for i := range d { - if d[i] != other[i] { - return false - } +// Equal compares a and b and returns a Diff if any are found, else nil. +// With inline testing, the diff is reported. Without inline testing, the +// caller must check if diff != nil and handle if true (e.g. cause a test +// failure with t.Error). +// +// With default options, it recurses without limit and compares every exported +// field, but it stops early after finding WithMaxDiff diffs (default: 10). +// +// When comparing a struct, fields tagged `deep:"-"` are ignored. +// +// If a type has an Equal() bool method, like time.Equal, it's called to check +// for equality. +// +// Some differences may not be found if an error occurs. +func (d Differ) Equal(a, b any) Diff { + if d.inline.T != nil { + d.inline.T.Helper() } - return true -} - -func (d Diff) ToSlice() []string { - return d + return d.Equalf(a, b, DiffPlaceholder) } -// Compare compares variables a and b, recursing into their structure up to -// MaxDepth levels deep (if greater than zero), and returns a list of differences, -// or nil if there are none. Some differences may not be found if an error is -// also returned. +// Equalf is Equal with custom error message formatting. The format and args +// are passed to T.Errorf/T.Fatalf with only one change: DiffPlaceholder is +// replaced with the formatted diff. For example: // -// If a type has an Equal method, like time.Equal, it is called to check for -// equality. +// d.Equalf(a, b, "test case %d failed: {diff}", i) // -// When comparing a struct, if a field has the tag `deep:"-"` then it will be -// ignored. -func (d Differ) Diff(a, b any) Diff { +// Values a and b are not args to the format; they're the values being tested. +// The resulting diff, if any, is represented in the format by DiffPlaceholder. +// Use InlineTesting.DiffFormatter to format the diff before interpolation. +func (d Differ) Equalf(a, b any, format string, args ...any) Diff { + diff := d.diff(a, b) + if diff == nil { + return nil + } + t := d.inline.T - if t != nil { - t.Helper() + if t == nil { + return diff } + // Format the diff then interpolate with placeholder. Must also s/%/%%/g + // in the diff string so they're literal % in Errorf/Fatalf rather than + // direcctives. + var s string + if d.inline.DiffFormatter != nil { + s = d.inline.DiffFormatter(diff) + } else { + s = fmt.Sprintf("%s", diff) + } + format = strings.ReplaceAll(format, DiffPlaceholder, strings.ReplaceAll(s, `%`, `%%`)) + + // Report diff at caller's line number + t.Helper() + if d.inline.Fatal { + t.Fatalf(format, args...) + } else { + t.Errorf(format, args...) + } + + return diff +} + +// diff returns the set of differences between a and b, or nil if none found. +// With default options, it recurses without limit and compares every exported +// field, but it stops early after finding WithMaxDiff diffs (default: 10). +// +// When comparing a struct, fields tagged `deep:"-"` are ignored. +// +// Some differences may not be found if an error occurs. +func (d Differ) diff(a, b any) Diff { aVal := reflect.ValueOf(a) bVal := reflect.ValueOf(b) c := &cmp{ @@ -202,22 +289,15 @@ func (d Differ) Diff(a, b any) Diff { ret: if len(c.diff) > 0 { - if t != nil { - if d.inline.Fatal { - t.Fatal(c.diff) - } else { - t.Error(c.diff) - } - } return c.diff // diffs } return nil // no diffs } -// T is the subset of standard library testing.T that option WithInlineTest needs. +// T is the subset of standard library testing.T that WithInlineTesting needs. type T interface { - Error(args ...any) - Fatal(args ...any) + Errorf(format string, args ...any) + Fatalf(format string, args ...any) Helper() } diff --git a/differ_test.go b/differ_test.go index ba3fb46..c6b6028 100644 --- a/differ_test.go +++ b/differ_test.go @@ -1,6 +1,7 @@ package deep_test import ( + "fmt" "testing" "github.com/go-test/deep" @@ -9,63 +10,39 @@ import ( func TestDiffer(t *testing.T) { d := deep.NewDiffer() - diff1 := d.Diff("foo", "bar") + diff1 := d.Equal("foo", "bar") if diff1 == nil { t.Error("no diff") } - s := diff1.ToSlice() - if len(s) != 1 { - t.Errorf("got slice len %d, expected 1", len(s)) - } - - diff2 := d.Diff("foo", "bar") - if diff2 == nil { - t.Error("no diff") - } - - // diff1 and diff2 are equal (i.e. same diff: foo != bar) - eq := diff1.Equal(diff2) - if eq != true { - t.Error("diffs not equal") - } - // diff1 and diff3 are NOT equal - diff3 := d.Diff("a", "b") + diff3 := d.Equal("a", "b") if diff3 == nil { t.Error("should not be equal") } - eq = diff1.Equal(diff3) - if eq == true { - t.Error("diffs should not be equal") - } - - // diff4 isn't a diff, so it's not equal to diff1 - diff4 := d.Diff("ok", "ok") - if diff4 != nil { - t.Error("should be equal") - } - eq = diff1.Equal(diff4) - if eq == true { - t.Error("should not be equal") - } } type mockT struct { calledError bool calledFatal bool calledHelper bool + errFormat string + errArgs []any } -func (t *mockT) Error(args ...any) { t.calledError = true } -func (t *mockT) Fatal(args ...any) { t.calledFatal = true } -func (t *mockT) Helper() { t.calledHelper = true } +func (t *mockT) Errorf(format string, args ...any) { + t.calledError = true + t.errFormat = format + t.errArgs = args +} +func (t *mockT) Fatalf(format string, args ...any) { t.calledFatal = true } +func (t *mockT) Helper() { t.calledHelper = true } -func TestDiffer_WithInlineTest(t *testing.T) { +func TestDiffer_WithInlineTesting(t *testing.T) { // No diff, no calls to T except Helper m := &mockT{} - d := deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) - d.Diff("foo", "foo") + d := deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{T: m})) + d.Equal("foo", "foo") if m.calledHelper == false { t.Errorf("did not call T.Helper") } @@ -76,10 +53,10 @@ func TestDiffer_WithInlineTest(t *testing.T) { t.Errorf("called T.Fatal") } - // Diff and by default Error, not Fatal, is called + // Equal and by default Error, not Fatal, is called m = &mockT{} - d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m})) - d.Diff("foo", "bar") + d = deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{T: m})) + d.Equal("foo", "bar") if m.calledHelper == false { t.Errorf("did not call T.Helper") } @@ -90,10 +67,10 @@ func TestDiffer_WithInlineTest(t *testing.T) { t.Errorf("called T.Fatal") } - // Diff and Fatal + // Equal and Fatal m = &mockT{} - d = deep.NewDiffer(deep.WithInlineTest(deep.InlineTest{T: m, Fatal: true})) - d.Diff("foo", "bar") + d = deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{T: m, Fatal: true})) + d.Equal("foo", "bar") if m.calledHelper == false { t.Errorf("did not call T.Helper") } @@ -103,4 +80,44 @@ func TestDiffer_WithInlineTest(t *testing.T) { if m.calledFatal != true { t.Errorf("did not call T.Fatal") } + + // Convenience wrapper + m = &mockT{} + d = deep.Testing(m) + d.Equal("foo", "bar") + if m.calledHelper == false { + t.Errorf("did not call T.Helper") + } + + // Test DiffFormatter and verify that literal "%" in the diff string stay + // literal by being replaced with %% in Errorf/Fatalf format + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{ + T: m, + DiffFormatter: func(diff deep.Diff) string { + return fmt.Sprintf("<%s>", diff) // <-- + }, + })) + d.Equalf("%d", "1%s", "foo %d: {diff}", 9) + errfmt := `foo %d: <[%%d != 1%%s]>` // %% = literal % in Errorf/Fatalf + if m.errFormat != errfmt { + t.Errorf("got error format '%s', expected '%s'", m.errFormat, errfmt) + } + if len(m.errArgs) != 1 || m.errArgs[0].(int) != 9 { + t.Errorf("got erorr args %v, expected [9]", m.errArgs) + } + + // Equalf should work without DiffPlaceholder {diff} + m = &mockT{} + d = deep.NewDiffer(deep.WithInlineTesting(deep.InlineTesting{ + T: m, + })) + errfmt = `goodbye, diff` + d.Equalf("a", "b", errfmt) + if m.errFormat != errfmt { + t.Errorf("got error format '%s', expected '%s'", m.errFormat, errfmt) + } + if len(m.errArgs) != 0 { + t.Errorf("got error args, expected none: %v", m.errArgs) + } }