Skip to content

Commit

Permalink
Merge pull request #1 from go-andiamo/spy-mock
Browse files Browse the repository at this point in the history
Add spy mock
  • Loading branch information
marrow16 authored Aug 26, 2023
2 parents cf43cea + d80b91e commit 596e2fb
Show file tree
Hide file tree
Showing 10 changed files with 354 additions and 38 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
File renamed without changes.
49 changes: 49 additions & 0 deletions examples/spy/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions examples/spy/thing.go
Original file line number Diff line number Diff line change
@@ -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
}
138 changes: 106 additions & 32 deletions mock_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"github.com/stretchr/testify/mock"
"reflect"
"regexp"
"runtime"
"strings"
"testing"
Expand All @@ -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!)
Expand All @@ -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] {
Expand Down Expand Up @@ -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<dd>.
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
}
21 changes: 16 additions & 5 deletions mock_methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions spy_mock.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 596e2fb

Please sign in to comment.