From d5b543c5161d7d81bcacc8df6860c5077da64703 Mon Sep 17 00:00:00 2001 From: Owen Smith Date: Sun, 27 Oct 2024 11:47:18 +0000 Subject: [PATCH] Support optional diff formatter fn for eq --- README.md | 33 +++++++++++ gomock/call.go | 31 +++++++---- gomock/callset_test.go | 8 +-- gomock/controller.go | 21 ++++++- gomock/controller_test.go | 114 +++++++++++++++++++++++++++++++++++++- 5 files changed, 189 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b449eb2f..1c96e2b0 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,39 @@ Got: [0 1 1 2 3] Want: is equal to 1 ``` +### Custom diff formatter for unequal args + +You can provide a custom diff formatter function to the Controller, which will +be invoked instead of the default formatting when the `Eq` matcher fails. + +(`Eq` is the default matcher for expectations with arbitrary values). + +Other matchers are unaffected. (This includes `GotFormatter` implementations +and matchers wrapped in `WantFormatter` documented below). + +```go +myDiffer := func(expected, actual any) { + return fmt.Sprintf("My custom diff:\n- %v\n+ %v", expected, actual) + // or pass to another lib, like go-cmp cmp.Diff +} + +ctrl := gomock.NewController(t, gomock.WithDiffFormatter(myDiffer)) + +// ... + +mymock.EXPECT().Foo("my expected string") +mymock.Foo("my actual string") +``` + +``` +Unexpected call to *mymocks.MyMock.Foo([my actual string]) at ... because: +expected call at ... doesn't match the argument at index 0. +My custom diff: +- my expected string ++ my actual string +``` + + ### Modifying `Want` The `Want` value comes from the matcher's `String()` method. If the matcher's diff --git a/gomock/call.go b/gomock/call.go index e1fe222a..b1eb4766 100644 --- a/gomock/call.go +++ b/gomock/call.go @@ -42,11 +42,13 @@ type Call struct { // can set the return values by returning a non-nil slice. Actions run in the // order they are created. actions []func([]any) []any + + fmtDiff DiffFormatter } // newCall creates a *Call. It requires the method type in order to support // unexported methods. -func newCall(t TestHelper, receiver any, method string, methodType reflect.Type, args ...any) *Call { +func newCall(t TestHelper, fmtDiff DiffFormatter, receiver any, method string, methodType reflect.Type, args ...any) *Call { t.Helper() // TODO: check arity, types. @@ -78,6 +80,7 @@ func newCall(t TestHelper, receiver any, method string, methodType reflect.Type, return &Call{ t: t, receiver: receiver, method: method, methodType: methodType, args: mArgs, origin: origin, minCalls: 1, maxCalls: 1, actions: actions, + fmtDiff: fmtDiff, } } @@ -331,8 +334,8 @@ func (c *Call) matches(args []any) error { for i, m := range c.args { if !m.Matches(args[i]) { return fmt.Errorf( - "expected call at %s doesn't match the argument at index %d.\nGot: %v\nWant: %v", - c.origin, i, formatGottenArg(m, args[i]), m, + "expected call at %s doesn't match the argument at index %d.\n%s", + c.origin, i, c.formatArgMismatch(m, args[i]), ) } } @@ -354,8 +357,9 @@ func (c *Call) matches(args []any) error { if i < c.methodType.NumIn()-1 { // Non-variadic args if !m.Matches(args[i]) { - return fmt.Errorf("expected call at %s doesn't match the argument at index %s.\nGot: %v\nWant: %v", - c.origin, strconv.Itoa(i), formatGottenArg(m, args[i]), m) + return fmt.Errorf("expected call at %s doesn't match the argument at index %s.\n%s", + c.origin, strconv.Itoa(i), c.formatArgMismatch(m, args[i]), + ) } continue } @@ -398,8 +402,9 @@ func (c *Call) matches(args []any) error { // Got Foo(a, b, c, d, e) want Foo(matcherA, matcherB, matcherC, matcherD) // Got Foo(a, b, c) want Foo(matcherA, matcherB) - return fmt.Errorf("expected call at %s doesn't match the argument at index %s.\nGot: %v\nWant: %v", - c.origin, strconv.Itoa(i), formatGottenArg(m, args[i:]), c.args[i]) + return fmt.Errorf("expected call at %s doesn't match the argument at index %s.\n%s", + c.origin, strconv.Itoa(i), c.formatArgMismatch(m, args[i:]), + ) } } @@ -497,10 +502,14 @@ func (c *Call) addAction(action func([]any) []any) { c.actions = append(c.actions, action) } -func formatGottenArg(m Matcher, arg any) string { - got := fmt.Sprintf("%v (%T)", arg, arg) +func (c *Call) formatArgMismatch(m Matcher, actual any) string { + if eqm, ok := m.(eqMatcher); ok && c.fmtDiff != nil { + return c.fmtDiff(eqm.x, actual) + } + + got := fmt.Sprintf("%v (%T)", actual, actual) if gs, ok := m.(GotFormatter); ok { - got = gs.Got(arg) + got = gs.Got(actual) } - return got + return fmt.Sprintf("Got: %v\nWant: %v", got, m.String()) } diff --git a/gomock/callset_test.go b/gomock/callset_test.go index d8150c51..0fbe191c 100644 --- a/gomock/callset_test.go +++ b/gomock/callset_test.go @@ -30,7 +30,7 @@ func TestCallSetAdd(t *testing.T) { numCalls := 10 for i := 0; i < numCalls; i++ { - cs.Add(newCall(t, receiver, method, reflect.TypeOf(receiverType{}.Func))) + cs.Add(newCall(t, nil, receiver, method, reflect.TypeOf(receiverType{}.Func))) } call, err := cs.FindMatch(receiver, method, []any{}) @@ -47,13 +47,13 @@ func TestCallSetAdd_WhenOverridable_ClearsPreviousExpectedAndExhausted(t *testin var receiver any = "TestReceiver" cs := newOverridableCallSet() - cs.Add(newCall(t, receiver, method, reflect.TypeOf(receiverType{}.Func))) + cs.Add(newCall(t, nil, receiver, method, reflect.TypeOf(receiverType{}.Func))) numExpectedCalls := len(cs.expected[callSetKey{receiver, method}]) if numExpectedCalls != 1 { t.Fatalf("Expected 1 expected call in callset, got %d", numExpectedCalls) } - cs.Add(newCall(t, receiver, method, reflect.TypeOf(receiverType{}.Func))) + cs.Add(newCall(t, nil, receiver, method, reflect.TypeOf(receiverType{}.Func))) newNumExpectedCalls := len(cs.expected[callSetKey{receiver, method}]) if newNumExpectedCalls != 1 { t.Fatalf("Expected 1 expected call in callset, got %d", newNumExpectedCalls) @@ -100,7 +100,7 @@ func TestCallSetFindMatch(t *testing.T) { method := "TestMethod" args := []any{} - c1 := newCall(t, receiver, method, reflect.TypeOf(receiverType{}.Func)) + c1 := newCall(t, nil, receiver, method, reflect.TypeOf(receiverType{}.Func)) cs.exhausted = map[callSetKey][]*Call{ {receiver: receiver, fname: method}: {c1}, } diff --git a/gomock/controller.go b/gomock/controller.go index 674c3298..8bb7f1e2 100644 --- a/gomock/controller.go +++ b/gomock/controller.go @@ -36,6 +36,9 @@ type TestHelper interface { Helper() } +// DiffFormatter is a function to print custom diffs. See WithDiffFormatter. +type DiffFormatter func(expected, actual any) string + // cleanuper is used to check if TestHelper also has the `Cleanup` method. A // common pattern is to pass in a `*testing.T` to // `NewController(t TestReporter)`. In Go 1.14+, `*testing.T` has a cleanup @@ -75,6 +78,7 @@ type Controller struct { mu sync.Mutex expectedCalls *callSet finished bool + fmtDiff DiffFormatter } // NewController returns a new Controller. It is the preferred way to create a Controller. @@ -120,6 +124,21 @@ func (o overridableExpectationsOption) apply(ctrl *Controller) { ctrl.expectedCalls = newOverridableCallSet() } +type fmtDiffOption struct { + fmtDiff DiffFormatter +} + +// WithDiffFormatter allows customizing output format when args to a call don't +// match expectations. Note that this only applies when the default equality +// matcher is being used. +func WithDiffFormatter(fmtDiff DiffFormatter) fmtDiffOption { + return fmtDiffOption{fmtDiff: fmtDiff} +} + +func (o fmtDiffOption) apply(ctrl *Controller) { + ctrl.fmtDiff = o.fmtDiff +} + type cancelReporter struct { t TestHelper cancel func() @@ -182,7 +201,7 @@ func (ctrl *Controller) RecordCall(receiver any, method string, args ...any) *Ca func (ctrl *Controller) RecordCallWithMethodType(receiver any, method string, methodType reflect.Type, args ...any) *Call { ctrl.T.Helper() - call := newCall(ctrl.T, receiver, method, methodType, args...) + call := newCall(ctrl.T, ctrl.fmtDiff, receiver, method, methodType, args...) ctrl.mu.Lock() defer ctrl.mu.Unlock() diff --git a/gomock/controller_test.go b/gomock/controller_test.go index 7e0397d7..c32b8709 100644 --- a/gomock/controller_test.go +++ b/gomock/controller_test.go @@ -166,12 +166,12 @@ func assertEqual(t *testing.T, expected any, actual any) { } } -func createFixtures(t *testing.T) (reporter *ErrorReporter, ctrl *gomock.Controller) { +func createFixtures(t *testing.T, opts ...gomock.ControllerOption) (reporter *ErrorReporter, ctrl *gomock.Controller) { // reporter acts as a testing.T-like object that we pass to the // Controller. We use it to test that the mock considered tests // successful or failed. reporter = NewErrorReporter(t) - ctrl = gomock.NewController(reporter) + ctrl = gomock.NewController(reporter, opts...) return } @@ -817,6 +817,116 @@ func TestVariadicArgumentsGotFormatterTooManyArgsFailure(t *testing.T) { ctrl.Call(s, "VariadicMethod", 0, "1") } +func TestCustomDiff(t *testing.T) { + + diff := func(expected, actual any) string { + return fmt.Sprintf("EXPECT{%v} ACTUAL{%v}", expected, actual) + } + rep, ctrl := createFixtures(t, gomock.WithDiffFormatter(diff)) + defer rep.recoverUnexpectedFatal() + + s := new(Subject) + ctrl.RecordCall( + s, + "FooMethod", + gomock.Eq("aaa"), + ) + + rep.assertFatal(func() { + ctrl.Call(s, "FooMethod", "bbb") + }, "expected call to", "doesn't match the argument at index 0", + "EXPECT{aaa} ACTUAL{bbb}") +} + +func TestCustomDiff_RawExpectValue(t *testing.T) { + + diff := func(expected, actual any) string { + return fmt.Sprintf("EXPECT{%v} ACTUAL{%v}", expected, actual) + } + rep, ctrl := createFixtures(t, gomock.WithDiffFormatter(diff)) + defer rep.recoverUnexpectedFatal() + + s := new(Subject) + ctrl.RecordCall( + s, + "FooMethod", + "aaa", + ) + + rep.assertFatal(func() { + ctrl.Call(s, "FooMethod", "bbb") + }, "expected call to", "doesn't match the argument at index 0", + "EXPECT{aaa} ACTUAL{bbb}") +} + +func TestCustomDiff_defersToGotFormatter(t *testing.T) { + diff := func(expected, actual any) string { + return "this should lose" + } + rep, ctrl := createFixtures(t, gomock.WithDiffFormatter(diff)) + defer rep.recoverUnexpectedFatal() + + s := new(Subject) + ctrl.RecordCall( + s, + "FooMethod", + gomock.GotFormatterAdapter( + gomock.GotFormatterFunc(func(got any) string { return "this should win" }), + gomock.Eq("aaa"), + ), + ) + + rep.assertFatal(func() { + ctrl.Call(s, "FooMethod", "bbb") + }, "expected call to", "doesn't match the argument at index 0", + "Got: this should win\nWant:") +} + +func TestCustomDiff_defersToWantFormatter(t *testing.T) { + diff := func(expected, actual any) string { + return "this should lose" + } + rep, ctrl := createFixtures(t, gomock.WithDiffFormatter(diff)) + defer rep.recoverUnexpectedFatal() + + s := new(Subject) + ctrl.RecordCall( + s, + "FooMethod", + gomock.WantFormatter( + gomock.StringerFunc(func() string { return "this should win" }), + gomock.Eq("aaa"), + ), + ) + + rep.assertFatal(func() { + ctrl.Call(s, "FooMethod", "bbb") + }, "expected call to", "doesn't match the argument at index 0", + "Got: bbb (string)\nWant: this should win") +} + +func TestCustomDiff_WithVariadicArguments(t *testing.T) { + diff := func(expected, actual any) string { + return "this should lose" + } + rep, ctrl := createFixtures(t, gomock.WithDiffFormatter(diff)) + defer rep.recoverUnexpectedFatal() + + s := new(Subject) + ctrl.RecordCall( + s, + "VariadicMethod", + 0, + "1", + ) + + rep.assertFatal(func() { + ctrl.Call(s, "VariadicMethod", 0, "2", "3") + }, "expected call to", "doesn't match the argument at index 1", + "Got: [2 3] ([]interface {})\nWant: this should appear") + ctrl.Call(s, "VariadicMethod", 0, "1") +} + func TestNoHelper(t *testing.T) { ctrlNoHelper := gomock.NewController(NewErrorReporter(t))