From 2d44b3dd1f1326f7f6b2d662e19cdd4a296df3d1 Mon Sep 17 00:00:00 2001 From: Adam Connelly Date: Fri, 1 Dec 2023 09:39:15 -0500 Subject: [PATCH 1/2] chore: enable revive comments linting I've updated the linter config to require exported methods and types to be commented, and also added all the missing comments. --- .golangci.yml | 18 ++--------- cmd/kelpie/main.go | 13 ++++---- matcher.go => kelpie.go | 1 + mocking/matcher.go | 7 +++++ mocking/{mock.go => mocking.go} | 53 ++++++++++++++++++++++++++------- nullable/nullable.go | 1 + parser/parser.go | 36 +++++++++++++++++++--- slices/slices.go | 1 + 8 files changed, 95 insertions(+), 35 deletions(-) rename matcher.go => kelpie.go (87%) rename mocking/{mock.go => mocking.go} (52%) diff --git a/.golangci.yml b/.golangci.yml index 61e157d..60970cb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -84,21 +84,6 @@ linters-settings: allow-leading-space: true # don't require machine-readable nolint directives (i.e. with no leading space) require-explanation: false # don't require an explanation for nolint directives require-specific: false # don't require nolint directives to be specific about which linter is being skipped - revive: - rules: - - name: blank-imports - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: increment-decrement - - name: var-declaration - - name: package-comments - - name: range - - name: time-naming - - name: errorf - - name: unreachable-code - - name: redefines-builtin-id staticcheck: go: "1.21" checks: [ "all", "-SA1019"] @@ -161,3 +146,6 @@ severity: - linters: - dupl severity: info + +issues: + exclude-use-default: false diff --git a/cmd/kelpie/main.go b/cmd/kelpie/main.go index 1780fa9..d0973e5 100644 --- a/cmd/kelpie/main.go +++ b/cmd/kelpie/main.go @@ -1,3 +1,4 @@ +// Package main contains the Kelpie code generator. package main import ( @@ -16,13 +17,13 @@ import ( //go:embed "mock.go.tmpl" var mockTemplate string -type GenerateCmd struct { +type generateCmd struct { SourceFile string `short:"s" required:"" env:"GOFILE" help:"The Go source file containing the interface to mock."` Interfaces []string `short:"i" required:"" help:"The names of the interfaces to mock."` OutputDir string `short:"o" required:"" default:"mocks" help:"The directory to write the mock out to."` } -func (g *GenerateCmd) Run() error { +func (g *generateCmd) Run() error { file, err := os.Open(g.SourceFile) if err != nil { return errors.Wrap(err, "could not open file for parsing") @@ -43,9 +44,11 @@ func (g *GenerateCmd) Run() error { err := func() error { outputDirectoryName := filepath.Join(g.OutputDir, i.PackageName) if _, err := os.Stat(outputDirectoryName); os.IsNotExist(err) { - os.MkdirAll(outputDirectoryName, 0700) + if err := os.MkdirAll(outputDirectoryName, 0700); err != nil { + return errors.Wrap(err, "could not create directory for mock") + } } - file, err := os.Create(filepath.Join(outputDirectoryName, fmt.Sprintf("%s.go", i.PackageName))) + file, err := os.Create(filepath.Clean(filepath.Join(outputDirectoryName, fmt.Sprintf("%s.go", i.PackageName)))) if err != nil { return errors.Wrap(err, "could not open output file") } @@ -67,7 +70,7 @@ func (g *GenerateCmd) Run() error { } var cli struct { - Generate GenerateCmd `cmd:"" help:"Generate a mock."` + Generate generateCmd `cmd:"" help:"Generate a mock."` } func main() { diff --git a/matcher.go b/kelpie.go similarity index 87% rename from matcher.go rename to kelpie.go index 4ef791b..f887056 100644 --- a/matcher.go +++ b/kelpie.go @@ -1,3 +1,4 @@ +// Package kelpie contains helpers for matching arguments when configuring a mock. package kelpie import "github.com/adamconnelly/kelpie/mocking" diff --git a/mocking/matcher.go b/mocking/matcher.go index e76111d..c994946 100644 --- a/mocking/matcher.go +++ b/mocking/matcher.go @@ -15,3 +15,10 @@ type Matcher[T comparable] struct { func (i Matcher[T]) IsMatch(other any) bool { return i.MatchFn(other.(T)) } + +// MethodMatcher is used to match a method call to an expectation. +type MethodMatcher struct { + MethodName string + ArgumentMatchers []ArgumentMatcher + Times *uint +} diff --git a/mocking/mock.go b/mocking/mocking.go similarity index 52% rename from mocking/mock.go rename to mocking/mocking.go index 64e6244..0953335 100644 --- a/mocking/mock.go +++ b/mocking/mocking.go @@ -1,3 +1,6 @@ +// Package mocking contains the mocking logic for Kelpie. This includes types for creating +// expectations as well as the Mock type which is used by the generated mocks to record method +// calls and verify expectations. package mocking import ( @@ -6,41 +9,67 @@ import ( "github.com/adamconnelly/kelpie/slices" ) -type MethodMatcher struct { - MethodName string - ArgumentMatchers []ArgumentMatcher - Times *uint -} - +// Expectation represents an expected method call. type Expectation struct { + // MethodMatcher contains the information needed to match a specific method call to an expectation. MethodMatcher *MethodMatcher - Returns []any - PanicArg any - ObserveFn any + + // Returns contains any arguments that should be returned from the method call. + Returns []any + + // PanicArg contains the argument passed to `panic()`. + PanicArg any + + // ObserveFn contains a function that should be used as the implementation of the mocked method. + ObserveFn any } +// MethodMatcherCreator is used to create a MethodMatcher. This is used to allow us to build +// the fluent API that ensures that only the details of an expected method call can be passed +// to `Called`, rather than allowing a full setup expression containing an action. type MethodMatcherCreator interface { CreateMethodMatcher() *MethodMatcher } +// ExpectationCreator creates an expected method call including the details needed to match +// the method, as well as the result that should occur (i.e. return something, panic or call +// a custom function). type ExpectationCreator interface { CreateExpectation() *Expectation } +// MethodCall is used to record a method that has been called. type MethodCall struct { + // MethodName is the name of the method that will be called. MethodName string - Args []any + + // Args contains the list of arguments passed to the method. + Args []any } +// Mock contains the main mocking logic. type Mock struct { + // Expectations contains the list of expected method calls that have been setup. Expectations []*Expectation - MethodCalls []*MethodCall + + // MethodCalls contains the method calls that have been recorded. + MethodCalls []*MethodCall } +// Setup is used to configure a method call for a mock. The setup contains the information +// needed to match a specific method call, as well as the action that should occur when the +// method is called. +// +// Examples: +// +// mock.Setup(calculator.Add(1, kelpie.Any[int]).Return(5)) +// mock.Setup(client.Request("abc").Times(3).Return(errors.New("request failed"))) func (m *Mock) Setup(creator ExpectationCreator) { m.Expectations = append([]*Expectation{creator.CreateExpectation()}, m.Expectations...) } +// Call records a method call, and returns an expectation if any can be found. If no expectations +// match the specified method call, nil will be returned. func (m *Mock) Call(methodName string, args ...any) *Expectation { m.MethodCalls = append(m.MethodCalls, &MethodCall{MethodName: methodName, Args: args}) @@ -62,6 +91,7 @@ func (m *Mock) Call(methodName string, args ...any) *Expectation { return nil } +// Called verifies whether a method matching the specified signature has been called. func (m *Mock) Called(creator MethodMatcherCreator) bool { methodMatcher := creator.CreateMethodMatcher() @@ -78,6 +108,7 @@ func (m *Mock) Called(creator MethodMatcherCreator) bool { }) } +// Reset clears the expectations and recorded method calls on the mock. func (m *Mock) Reset() { m.Expectations = nil m.MethodCalls = nil diff --git a/nullable/nullable.go b/nullable/nullable.go index 85d2f5c..f387a99 100644 --- a/nullable/nullable.go +++ b/nullable/nullable.go @@ -1,3 +1,4 @@ +// Package nullable contains helpers for helping wrap and unwrap types with pointers. package nullable // OfValue returns a pointer to the specified value. diff --git a/parser/parser.go b/parser/parser.go index d4039fb..8622334 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -1,3 +1,5 @@ +// Package parser contains the parser for reading Go files and building the interface definitions +// needed to generate mocks. package parser import ( @@ -11,45 +13,71 @@ import ( "github.com/adamconnelly/kelpie/slices" ) +// MockedInterface represents an interface that a mock should be generated for. type MockedInterface struct { - Name string + // Name contains the name of the interface. + Name string + + // PackageName contains the name of the package that the interface belongs to. PackageName string - Methods []MethodDefinition + + // Methods contains the list of methods in the interface. + Methods []MethodDefinition } +// MethodDefinition defines a method in an interface. type MethodDefinition struct { - Name string + // Name is the name of the method. + Name string + + // Parameters contains the parameters passed to the method. Parameters []ParameterDefinition - Results []ResultDefinition + + // Results contains the method results. + Results []ResultDefinition } +// ParameterDefinition contains information about a method parameter. type ParameterDefinition struct { + // Name is the name of the parameter. Name string + + // Type is the parameter's type. Type string } +// ResultDefinition contains information about a method result. type ResultDefinition struct { + // Name is the name of the method result. This can be empty if the result is not named. Name string + + // Type is the type of the result. Type string } //go:generate go run ../cmd/kelpie generate --interfaces InterfaceFilter + +// InterfaceFilter is used to decide which interfaces mocks should be generated for. type InterfaceFilter interface { // Include indicates that the specified interface should be included in the set of interfaces // to generate. Include(name string) bool } +// IncludingInterfaceFilter is an InterfaceFilter that works based on an allow-list of interface +// names. type IncludingInterfaceFilter struct { InterfacesToInclude []string } +// Include returns true if the specified interface should be mocked, false otherwise. func (f *IncludingInterfaceFilter) Include(name string) bool { return slices.Contains(f.InterfacesToInclude, func(n string) bool { return n == name }) } +// Parse parses the source contained in the reader. func Parse(reader io.Reader, filter InterfaceFilter) ([]MockedInterface, error) { var interfaces []MockedInterface diff --git a/slices/slices.go b/slices/slices.go index 97a3264..4ad57fa 100644 --- a/slices/slices.go +++ b/slices/slices.go @@ -1,3 +1,4 @@ +// Package slices contains generic functions for working with slices. package slices // FirstOrPanic returns the first item matching the closure, and panics if there are no matches. From 35a344449efb66efc364f4b022e22cc761f27bea Mon Sep 17 00:00:00 2001 From: Adam Connelly Date: Fri, 1 Dec 2023 11:20:49 -0500 Subject: [PATCH 2/2] docs: document `Called()` and `Times() Added documentation for verifying method calls as well as configuring the number of times a method can be called, and also added an example test case for `Called()`. --- README.md | 36 ++++ examples/called_test.go | 37 ++++ .../registrationservice.go | 170 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 examples/called_test.go create mode 100644 examples/mocks/registrationservice/registrationservice.go diff --git a/README.md b/README.md index 8effa7b..98bec6e 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,42 @@ emailServiceMock.Setup( })) ``` +### Verifying Method Calls + +You can verify that a method has been called using the `mock.Called()` method: + +```go +// Arrange +mock := registrationservice.NewMock() + +// Act +mock.Instance().Register("Mark") +mock.Instance().Register("Jim") + +// Assert +t.True(mock.Called(registrationservice.Register("Mark"))) +t.True(mock.Called(registrationservice.Register(kelpie.Any[string]()).Times(2))) +t.False(mock.Called(registrationservice.Register("Wendy"))) +``` + +### Times + +You can configure a method call to only match a certain number of times, or verify a method has been called a certain number of times using the `Times()`, `Once()` and `Never()` helpers: + +```go +// Arrange +mock := registrationservice.NewMock() + +// Act +mock.Instance().Register("Mark") +mock.Instance().Register("Jim") + +// Assert +t.True(mock.Called(registrationservice.Register("Mark").Once())) +t.True(mock.Called(registrationservice.Register(kelpie.Any[string]()).Times(2))) +t.True(mock.Called(registrationservice.Register("Wendy").Never())) +``` + ## FAQ ### What makes Kelpie so magical diff --git a/examples/called_test.go b/examples/called_test.go new file mode 100644 index 0000000..4eff35e --- /dev/null +++ b/examples/called_test.go @@ -0,0 +1,37 @@ +package examples + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/adamconnelly/kelpie" + "github.com/adamconnelly/kelpie/examples/mocks/registrationservice" +) + +//go:generate go run ../cmd/kelpie generate --interfaces RegistrationService +type RegistrationService interface { + Register(name string) error +} + +type CalledTests struct { + suite.Suite +} + +func (t *CalledTests) Test_Called_VerifiesWhetherMethodHasBeenCalled() { + // Arrange + mock := registrationservice.NewMock() + + // Act + mock.Instance().Register("Mark") + mock.Instance().Register("Jim") + + // Assert + t.True(mock.Called(registrationservice.Register("Mark"))) + t.True(mock.Called(registrationservice.Register(kelpie.Any[string]()).Times(2))) + t.False(mock.Called(registrationservice.Register("Wendy"))) +} + +func TestCalled(t *testing.T) { + suite.Run(t, new(CalledTests)) +} diff --git a/examples/mocks/registrationservice/registrationservice.go b/examples/mocks/registrationservice/registrationservice.go new file mode 100644 index 0000000..bad2cf6 --- /dev/null +++ b/examples/mocks/registrationservice/registrationservice.go @@ -0,0 +1,170 @@ +// Code generated by Kelpie. DO NOT EDIT. +package registrationservice + +import ( + "github.com/adamconnelly/kelpie" + "github.com/adamconnelly/kelpie/mocking" +) + +type Mock struct { + mocking.Mock + instance Instance +} + +func NewMock() *Mock { + mock := Mock{ + instance: Instance{}, + } + mock.instance.mock = &mock + + return &mock +} + +type Instance struct { + mock *Mock +} + +func (m *Instance) Register(name string) (r0 error) { + expectation := m.mock.Call("Register", name) + if expectation != nil { + if expectation.ObserveFn != nil { + observe := expectation.ObserveFn.(func(name string) error) + return observe(name) + } + + if expectation.PanicArg != nil { + panic(expectation.PanicArg) + } + + if expectation.Returns[0] != nil { + r0 = expectation.Returns[0].(error) + } + } + + return +} + +func (m *Mock) Instance() *Instance { + return &m.instance +} + +type RegisterMethodMatcher struct { + matcher mocking.MethodMatcher +} + +func (m *RegisterMethodMatcher) CreateMethodMatcher() *mocking.MethodMatcher { + return &m.matcher +} + +func Register[P0 string | mocking.Matcher[string]](name P0) *RegisterMethodMatcher { + result := RegisterMethodMatcher{ + matcher: mocking.MethodMatcher{ + MethodName: "Register", + ArgumentMatchers: make([]mocking.ArgumentMatcher, 1), + }, + } + + if matcher, ok := any(name).(mocking.Matcher[string]); ok { + result.matcher.ArgumentMatchers[0] = matcher + } else { + result.matcher.ArgumentMatchers[0] = kelpie.ExactMatch(any(name).(string)) + } + + return &result +} + +type RegisterTimes struct { + matcher *RegisterMethodMatcher +} + +// Times allows you to restrict the number of times a particular expectation can be matched. +func (m *RegisterMethodMatcher) Times(times uint) *RegisterTimes { + m.matcher.Times = × + + return &RegisterTimes{ + matcher: m, + } +} + +// Once specifies that the expectation will only match once. +func (m *RegisterMethodMatcher) Once() *RegisterTimes { + return m.Times(1) +} + +// Never specifies that the method has not been called. This is mainly useful for verification +// rather than mocking. +func (m *RegisterMethodMatcher) Never() *RegisterTimes { + return m.Times(0) +} + +// Return returns the specified results when the method is called. +func (t *RegisterTimes) Return(r0 error) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &t.matcher.matcher, + Returns: []any{r0}, + }, + } +} + +// Panic panics using the specified argument when the method is called. +func (t *RegisterTimes) Panic(arg any) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &t.matcher.matcher, + PanicArg: arg, + }, + } +} + +// When calls the specified observe callback when the method is called. +func (t *RegisterTimes) When(observe func(name string) error) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &t.matcher.matcher, + ObserveFn: observe, + }, + } +} + +func (t *RegisterTimes) CreateMethodMatcher() *mocking.MethodMatcher { + return &t.matcher.matcher +} + +// Return returns the specified results when the method is called. +func (m *RegisterMethodMatcher) Return(r0 error) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &m.matcher, + Returns: []any{r0}, + }, + } +} + +// Panic panics using the specified argument when the method is called. +func (m *RegisterMethodMatcher) Panic(arg any) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &m.matcher, + PanicArg: arg, + }, + } +} + +// When calls the specified observe callback when the method is called. +func (m *RegisterMethodMatcher) When(observe func(name string) error) *RegisterAction { + return &RegisterAction{ + expectation: mocking.Expectation{ + MethodMatcher: &m.matcher, + ObserveFn: observe, + }, + } +} + +type RegisterAction struct { + expectation mocking.Expectation +} + +func (a *RegisterAction) CreateExpectation() *mocking.Expectation { + return &a.expectation +}