From d80b91ee8d057e27aabe1e5656ecbbf75976c365 Mon Sep 17 00:00:00 2001 From: marrow16 Date: Sat, 26 Aug 2023 10:05:38 +0100 Subject: [PATCH] Add spy mock --- README.md | 6 + examples/{example1 => basic}/main_test.go | 0 examples/{example2 => generator}/main_test.go | 2 +- .../{example2 => generator}/stuff/thing.go | 0 examples/spy/main_test.go | 49 +++++++ examples/spy/thing.go | 21 +++ mock_methods.go | 138 ++++++++++++++---- mock_methods_test.go | 21 ++- spy_mock.go | 27 ++++ spy_mock_test.go | 128 ++++++++++++++++ 10 files changed, 354 insertions(+), 38 deletions(-) rename examples/{example1 => basic}/main_test.go (100%) rename examples/{example2 => generator}/main_test.go (88%) rename examples/{example2 => generator}/stuff/thing.go (100%) create mode 100644 examples/spy/main_test.go create mode 100644 examples/spy/thing.go create mode 100644 spy_mock.go create mode 100644 spy_mock_test.go diff --git a/README.md b/README.md index e45c463..7b680dd 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ Things to notice: * use `.OnAllMethods()` to mock all methods (optionally making all return an error) * use `mmock.As()` generic function in your mocked methods to return correct types +## Spy Mocks +Mmock also provides for 'spy mocks' - where an actual underlying implementation is supplied to the mock. +If methods on the mock are called but have not been mocked (using `.On()` or `.OnMethod()`) then the underlying method is called - but you can still assert that method was called. + +See [example](https://github.com/go-andiamo/mmock/tree/main/examples/spy) + ## Mock generator Mmock comes with a programmatic mock generator, e.g. ```go diff --git a/examples/example1/main_test.go b/examples/basic/main_test.go similarity index 100% rename from examples/example1/main_test.go rename to examples/basic/main_test.go diff --git a/examples/example2/main_test.go b/examples/generator/main_test.go similarity index 88% rename from examples/example2/main_test.go rename to examples/generator/main_test.go index a5e1fc1..94ec732 100644 --- a/examples/example2/main_test.go +++ b/examples/generator/main_test.go @@ -2,7 +2,7 @@ package main import ( "github.com/go-andiamo/mmock" - "github.com/go-andiamo/mmock/examples/example2/stuff" + "github.com/go-andiamo/mmock/examples/generator/stuff" "github.com/stretchr/testify/assert" "os" "testing" diff --git a/examples/example2/stuff/thing.go b/examples/generator/stuff/thing.go similarity index 100% rename from examples/example2/stuff/thing.go rename to examples/generator/stuff/thing.go diff --git a/examples/spy/main_test.go b/examples/spy/main_test.go new file mode 100644 index 0000000..de1d0bb --- /dev/null +++ b/examples/spy/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "github.com/go-andiamo/mmock" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSpyMockedThingy_MethodNotMockedCallsUnderlying(t *testing.T) { + // call a method that hasn't been set-up... + underlying := &ActualThing{} + spy := mmock.NewSpyMockOf[MockThing, Thingy](underlying) + err := spy.DoSomething() + assert.NoError(t, err) + assert.Equal(t, 1, underlying.calls) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 1) + err = spy.DoSomething() + assert.Error(t, err) + assert.Equal(t, 2, underlying.calls) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) +} + +func TestSpyMockedThingy_MethodMocked(t *testing.T) { + // call a method that has been set-up... + underlying := &ActualThing{} + spy := mmock.NewSpyMockOf[MockThing, Thingy](underlying) + spy.OnMethod(spy.DoSomething).Return(nil) + err := spy.DoSomething() + assert.NoError(t, err) + assert.Equal(t, 0, underlying.calls) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 1) + err = spy.DoSomething() + assert.NoError(t, err) + assert.Equal(t, 0, underlying.calls) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) +} + +type MockThing struct { + mmock.MockMethods +} + +func (m *MockThing) DoSomething() error { + args := m.Called() + return args.Error(0) +} diff --git a/examples/spy/thing.go b/examples/spy/thing.go new file mode 100644 index 0000000..83fcd13 --- /dev/null +++ b/examples/spy/thing.go @@ -0,0 +1,21 @@ +package main + +import "errors" + +type Thingy interface { + DoSomething() error +} + +var _ Thingy = &ActualThing{} + +type ActualThing struct { + calls int +} + +func (a *ActualThing) DoSomething() error { + a.calls++ + if a.calls > 1 { + return errors.New("can't do something more than once") + } + return nil +} diff --git a/mock_methods.go b/mock_methods.go index 3d30cf1..98c4253 100644 --- a/mock_methods.go +++ b/mock_methods.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stretchr/testify/mock" "reflect" + "regexp" "runtime" "strings" "testing" @@ -22,12 +23,20 @@ import ( // myMock := NewMock[MockedSomething]() func NewMock[T any]() *T { r := new(T) - if !setMockMethodsMockOf(r) { - panic(fmt.Sprintf("type '%s' is not MockMethods (add field Mocks.MockMethods)", reflect.TypeOf(r).Elem().String())) + if !setMockOf(r) { + panic(fmt.Sprintf("type '%s' is not MockMethods (add field mmock.MockMethods)", reflect.TypeOf(r).Elem().String())) } return r } +func setMockOf(mocked any) (ok bool) { + msu, ok := mocked.(mockSetup) + if ok { + msu.setMockOf(mocked) + } + return +} + // NewMockOf creates a new mock of a specified type // // same as NewMock except that it also checks that the specified type implements the interface specified by the second generic arg (and panics if it does not!) @@ -52,44 +61,93 @@ func NewMockOf[T any, I any]() *T { return r } -func setMockMethodsMockOf(mocked any) (ok bool) { - mv := reflect.ValueOf(mocked).Elem() - for i := 0; !ok && i < mv.NumField(); i++ { - ok = setMockOfField(mocked, mv.Field(i)) - } - return +type Spying interface { + // SetSpyOf sets the mock to be a spy mock + // + // The wrapped arg is implementation to be spied on - any methods that are called on the mock + // but have not been expected (by using On or OnMethod) will call this underlying - but you can still assert + // that the method has been called + SetSpyOf(wrapped any) } -var mockMethodsType = reflect.TypeOf(MockMethods{}) +type mockSetup interface { + Spying + setMockOf(mocked any) +} -func setMockOfField(mocked any, fld reflect.Value) (ok bool) { - if ft := fld.Type(); ft == mockMethodsType { - for i := 0; !ok && i < fld.NumField(); i++ { - if ft.Field(i).Name == mockOfFieldName { - fld.Field(i).Set(reflect.ValueOf(mocked)) - ok = true - } - } - } - return +func (mm *MockMethods) setMockOf(mocked any) { + mm.mockOf = mocked } -const mockOfFieldName = "MockOf" +func (mm *MockMethods) SetSpyOf(wrapped any) { + mm.wrapped = wrapped +} +// MockMethods is the replacement for mock.Mock type MockMethods struct { mock.Mock - MockOf any + mockOf any + wrapped any +} + +func (mm *MockMethods) Called(arguments ...interface{}) mock.Arguments { + pc, _, _, ok := runtime.Caller(1) + if !ok { + panic("could not retrieve caller information") + } + methodName := parseMethodName(runtime.FuncForPC(pc).Name()) + return mm.MethodCalled(methodName, arguments...) +} + +func (mm *MockMethods) MethodCalled(methodName string, arguments ...interface{}) (result mock.Arguments) { + if mm.wrapped != nil { + defer func() { + if r := recover(); r != nil { + // assuming that panic was raised by testify Mock.MethodCalled? + if msg, ok := r.(string); ok && strings.Contains(msg, "mock:") { + result = mm.callWrapped(methodName, arguments...) + } else { + panic(r) // some other panic? + } + } + }() + } + result = mm.Mock.MethodCalled(methodName, arguments...) + return +} + +func (mm *MockMethods) callWrapped(methodName string, arguments ...interface{}) (result mock.Arguments) { + ul := reflect.ValueOf(mm.wrapped) + m := ul.MethodByName(methodName) + if !m.IsValid() { + panic(fmt.Sprintf("spy mock .Wrapped does not implement method '%s'", methodName)) + } + // simulate mock method called by first ensuring that .On() has been set-up... + // this is so that methods that weren't mocked using .On but called directly into wrapped can still be asserted to have been called + outs := make([]any, m.Type().NumOut()) // don't care about the actual return args because they'll never get used + mm.Mock.On(methodName, arguments...).Once().Return(outs...) + mm.Mock.MethodCalled(methodName, arguments...) + // now call the actual underlying wrapped... + argVs := make([]reflect.Value, len(arguments)) + for i, v := range arguments { + argVs[i] = reflect.ValueOf(v) + } + rArgs := m.Call(argVs) + for _, ra := range rArgs { + result = append(result, ra.Interface()) + } + return } // OnAllMethods setups expected calls on every method of the mock // // Use the errs arg to specify that methods that return an error should return an error when called func (mm *MockMethods) OnAllMethods(errs bool) { - if mm.MockOf == nil { + if mm.mockOf == nil { panic("cannot mock all methods") } exms := excludeMethods() - to := reflect.TypeOf(mm.MockOf) + to := reflect.TypeOf(mm.mockOf) for i := to.NumMethod() - 1; i >= 0; i-- { method := to.Method(i) if !exms[method.Name] { @@ -194,25 +252,41 @@ func (mm *MockMethods) getMethodNameAndNumArgs(method any) (string, int) { to := reflect.TypeOf(method) if to.Kind() == reflect.String { methodName := method.(string) - if mm.MockOf == nil { + if mm.mockOf == nil { return methodName, -1 } - if m, ok := reflect.TypeOf(mm.MockOf).MethodByName(methodName); ok { + if m, ok := reflect.TypeOf(mm.mockOf).MethodByName(methodName); ok { return methodName, m.Type.NumIn() - 1 } panic(fmt.Sprintf("method '%s' does not exist", methodName)) } else if to.Kind() != reflect.Func { panic("not a method") } - fn := runtime.FuncForPC(reflect.ValueOf(method).Pointer()).Name() - fn = fn[strings.LastIndex(fn, ".")+1:] - if i := strings.Index(fn, "-"); i != -1 { - fn = fn[:i] - } - if mm.MockOf != nil { - if _, ok := reflect.TypeOf(mm.MockOf).MethodByName(fn); !ok { + + fn := parseMethodName(runtime.FuncForPC(reflect.ValueOf(method).Pointer()).Name()) + if mm.mockOf != nil { + if _, ok := reflect.TypeOf(mm.mockOf).MethodByName(fn); !ok { panic(fmt.Sprintf("method '%s' does not exist", fn)) } } return fn, to.NumIn() } + +var gccRegex = regexp.MustCompile("\\.pN\\d+_") + +func parseMethodName(methodName string) string { + // Code from original testify mock... + // Next four lines are required to use GCCGO function naming conventions. + // For Ex: github_com_docker_libkv_store_mock.WatchTree.pN39_github_com_docker_libkv_store_mock.Mock + // uses interface information unlike golang github.com/docker/libkv/store/mock.(*Mock).WatchTree + // With GCCGO we need to remove interface information starting from pN
. + if gccRegex.MatchString(methodName) { + methodName = gccRegex.Split(methodName, -1)[0] + } + parts := strings.Split(methodName, ".") + methodName = parts[len(parts)-1] + if i := strings.Index(methodName, "-"); i != -1 { + methodName = methodName[:i] + } + return methodName +} diff --git a/mock_methods_test.go b/mock_methods_test.go index f1470c6..0ecbd82 100644 --- a/mock_methods_test.go +++ b/mock_methods_test.go @@ -28,7 +28,7 @@ func TestMockedMethods(t *testing.T) { func TestMockedMethods_PanicsOnUnknownMethod(t *testing.T) { mocked := new(mockedMy) - mocked.MockOf = &mockedMy{} + mocked.mockOf = &mockedMy{} oth := &anotherMock{} assert.Panics(t, func() { @@ -57,7 +57,7 @@ func TestMockedMethods_ByNameString(t *testing.T) { // now with method name string but without having to specify args... mocked = new(mockedMy) - mocked.MockOf = &mockedMy{} + mocked.mockOf = &mockedMy{} mocked.OnMethod("DoSomething").Return(&SomeStruct{SomeValue: "a"}, nil) r, err = mocked.DoSomething("x", 1) @@ -93,7 +93,7 @@ func TestMockedMethods_Panics(t *testing.T) { func TestMockedMethods_OnAllMethods(t *testing.T) { mocked := new(mockedMy) - mocked.MockOf = &mockedMy{} + mocked.mockOf = &mockedMy{} mocked.OnAllMethods(false) r, err := mocked.DoSomething("a", 1) @@ -106,7 +106,7 @@ func TestMockedMethods_OnAllMethods(t *testing.T) { // all errors... mocked = new(mockedMy) - mocked.MockOf = &mockedMy{} + mocked.mockOf = &mockedMy{} mocked.OnAllMethods(true) _, err = mocked.DoSomething("a", 1) assert.Error(t, err) @@ -147,16 +147,27 @@ func TestNewMockOf(t *testing.T) { assert.Nil(t, r) assert.Error(t, err) m.AssertMethodCalled(t, m.DoSomething) +} +func TestNewMockOf__PanicsWithBadMockImpl(t *testing.T) { type otherMockedImpl struct { MockMethods } assert.Panics(t, func() { // panics because OtherMockedImpl does not implement my - NewMockOf[otherMockedImpl, my]() + _ = NewMockOf[otherMockedImpl, my]() }) } +func TestParseMethodName(t *testing.T) { + mn := parseMethodName("github.com/go-andiamo/mmock.(*mockedMy).DoSomething-fm") + assert.Equal(t, "DoSomething", mn) + mn = parseMethodName("github.com/go-andiamo/mmock.(*mockedMy).DoSomething") + assert.Equal(t, "DoSomething", mn) + mn = parseMethodName("github_com_go_andiamo_mmock.DoSomething.pN01_github_com_go_andiamo_mmock.mockedMy") + assert.Equal(t, "DoSomething", mn) +} + type SomeStruct struct { SomeValue string } diff --git a/spy_mock.go b/spy_mock.go new file mode 100644 index 0000000..f8aeb40 --- /dev/null +++ b/spy_mock.go @@ -0,0 +1,27 @@ +package mmock + +import ( + "fmt" + "reflect" +) + +// NewSpyMockOf creates a new spy mock of a specified type and provides the underling wrapped implementation +// +// The wrapped arg is implementation to be spied on - any methods that are called on the mock +// but have not been expected (by using On or OnMethod) will call this underlying - but you can still assert +// that the method has been called +func NewSpyMockOf[T any, I any](wrapped I) *T { + r := NewMock[T]() + if _, ok := interface{}(r).(I); !ok { + i := new(I) + panic(fmt.Sprintf("type '%s' does not implement interface '%s'", reflect.TypeOf(r).Elem().String(), reflect.TypeOf(i).Elem().Name())) + } + setSpyOf(r, wrapped) + return r +} + +func setSpyOf(mocked any, wrapped any) { + if msu, ok := mocked.(mockSetup); ok { + msu.SetSpyOf(wrapped) + } +} diff --git a/spy_mock_test.go b/spy_mock_test.go new file mode 100644 index 0000000..5e90c58 --- /dev/null +++ b/spy_mock_test.go @@ -0,0 +1,128 @@ +package mmock + +import ( + "errors" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewSpyMockOf(t *testing.T) { + underlying := &underlyingFull{ + calls: map[string]int{}, + } + spy := NewSpyMockOf[mockedMy, my](underlying) + _, err := spy.DoSomething("x", 1) + assert.Error(t, err) + _, err = spy.DoSomething("x", 1) + assert.Error(t, err) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) + assert.Equal(t, 2, underlying.calls["DoSomething"]) +} + +func TestNewSpyMockOf_PanicsWithBadMockImpl(t *testing.T) { + underlying := &underlyingFull{ + calls: map[string]int{}, + } + type otherMockedImpl struct { + MockMethods + } + assert.Panics(t, func() { + // panics because OtherMockedImpl does not implement my + _ = NewSpyMockOf[otherMockedImpl, my](underlying) + }) +} + +func TestSpyMock(t *testing.T) { + underlying := &underlyingMin{ + calls: map[string]int{}, + } + spy := NewMockOf[mockedMy, my]() + spy.SetSpyOf(underlying) + //spy.OnMethod(spy.DoSomething).Return(nil, nil) + _, err := spy.DoSomething("x", 1) + assert.Error(t, err) + _, err = spy.DoSomething("x", 1) + assert.Error(t, err) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) + assert.Equal(t, 2, underlying.calls["DoSomething"]) +} + +func TestSpyMock_Once(t *testing.T) { + underlying := &underlyingMin{ + calls: map[string]int{}, + } + spy := NewMockOf[mockedMy, my]() + spy.SetSpyOf(underlying) + spy.OnMethod(spy.DoSomething).Once().Return(nil, nil) + _, err := spy.DoSomething("x", 1) + assert.NoError(t, err) + _, err = spy.DoSomething("x", 1) + assert.Error(t, err) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) + assert.Equal(t, 1, underlying.calls["DoSomething"]) +} + +func TestSpyMock_MatchingArgs(t *testing.T) { + underlying := &underlyingMin{ + calls: map[string]int{}, + } + spy := NewMockOf[mockedMy, my]() + spy.SetSpyOf(underlying) + spy.OnMethod(spy.DoSomething, "x").Return(nil, nil) + _, err := spy.DoSomething("x", 1) + assert.NoError(t, err) + _, err = spy.DoSomething("y", 1) + assert.Error(t, err) + spy.AssertMethodCalled(t, spy.DoSomething) + spy.AssertNumberOfMethodCalls(t, spy.DoSomething, 2) + assert.Equal(t, 1, underlying.calls["DoSomething"]) +} + +func TestSpyMock_PanicsWithNonImplementedMethod(t *testing.T) { + underlying := &underlyingMin{ + calls: map[string]int{}, + } + spy := NewMockOf[mockedMy, my]() + spy.SetSpyOf(underlying) + assert.Panics(t, func() { + _, _ = spy.DoSomethingElse("", 0) + }) + + spy.OnMethod(spy.DoSomethingElse).Return(SomeStruct{}, nil) + _, err := spy.DoSomethingElse("", 0) + assert.NoError(t, err) + assert.Equal(t, 0, underlying.calls["DoSomethingElse"]) +} + +type underlyingMin struct { + calls map[string]int +} + +func (um *underlyingMin) DoSomething(s string, i int) (*SomeStruct, error) { + um.calls["DoSomething"] = um.calls["DoSomething"] + 1 + return nil, errors.New("foo") +} + +/* Don't implement this method to ensure calling it panics +func (um *underlyingMin) DoSomethingElse(s string, i int) (SomeStruct, error) { + um.calls["DoSomethingElse"] = um.calls["DoSomethingElse"] + 1 + return SomeStruct{}, nil +} +*/ + +type underlyingFull struct { + calls map[string]int +} + +func (um *underlyingFull) DoSomething(s string, i int) (*SomeStruct, error) { + um.calls["DoSomething"] = um.calls["DoSomething"] + 1 + return nil, errors.New("foo") +} + +func (um *underlyingFull) DoSomethingElse(s string, i int) (SomeStruct, error) { + um.calls["DoSomethingElse"] = um.calls["DoSomethingElse"] + 1 + return SomeStruct{}, nil +}