Skip to content

Commit

Permalink
feat: improve matching empty variadic argument lists
Browse files Browse the repository at this point in the history
Expecting or verifying that a variadic method was called with no arguments passed was difficult because by not passing any arguments, it prevented the Go compiler from being able to infer the types. I've added a new `kelpie.None[T]()` helper to make this easier.
  • Loading branch information
adamconnelly committed Jun 6, 2024
1 parent 7b12766 commit f28119d
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 1 deletion.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ func (t *VariadicFunctionsTests) Test_Parameters_ExactMatch() {
}
```

#### Mixing exact and custom matching

Because of the way generics work, you can't mix exact matching with custom matching. So for example the following will work:

```go
Expand All @@ -242,6 +244,8 @@ mock.Setup(printer.Printf("Hello %s. This is %s, %s.", "Dolly", kelpie.Any[strin
Return("Hello Dolly. This is Louis, Dolly."))
```

#### Mixing argument types

If your variadic parameter is `...any` or `...interface{}`, and you try to pass in multiple different types of argument, the Go compiler can't infer the types for you. Here's an example:

```go
Expand All @@ -255,6 +259,30 @@ To fix this, just specify the type parameters:
mock.Called(printer.Printf[string, any]("Hello world!", "One", 2, 3.0))
```

#### Matching no arguments

If you want to match that a variadic function call is made with no arguments provided, you can use `kelpie.None[T]()`:

```go
mock.Setup(printer.Printf("Hello world", kelpie.None[any]()))
mock.Called(secrets.Get(kelpie.Any[context.Context](), kelpie.Any[string](), kelpie.None[any]()))
```

The reason for using `None` is that otherwise the Go compiler can't infer the type of the variadic parameter:

```go
// Fails with "cannot infer P1"
mock.Setup(printer.Printf("Nothing to say").Return("Nothing to say"))
```

Another option instead of using `None` is to specify the type arguments explicitly, but that can become very verbose, especially when using Kelpie's matching functions:

```go
secretsManagerMock.Called(
secretsmanagerapi.PutSecretValue[mocking.Matcher[context.Context], mocking.Matcher[*secretsmanager.PutSecretValueInput], func(*secretsmanager.Options)](
kelpie.Any[context.Context](), kelpie.Any[*secretsmanager.PutSecretValueInput]()))
```

### Interface parameters

Under the hood, Kelpie uses Go generics to allow either the actual parameter type or a Kelpie matcher to be passed in when setting up mocks or verifying expectations. For example, say we have the following method:
Expand Down
19 changes: 19 additions & 0 deletions examples/variadic_functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ func (t *VariadicFunctionsTests) Test_Parameters_AnyMatch() {
t.Equal("Hello Dolly. This is Louis, Dolly.", result)
}

func (t *VariadicFunctionsTests) Test_Parameters_NoneProvided() {
// Arrange
mock := printer.NewMock()

mock.Setup(printer.Printf("Nothing to say", kelpie.None[any]()).
Return("Nothing to say"))

// Act
result1 := mock.Instance().Printf("Nothing to say")
result2 := mock.Instance().Printf("Who are %s", "you")

// Assert
t.Equal("Nothing to say", result1)
t.Equal("", result2)
t.True(mock.Called(printer.Printf("Nothing to say", kelpie.None[any]())))
t.True(mock.Called(printer.Printf(kelpie.Any[string](), kelpie.None[any]())))
t.False(mock.Called(printer.Printf("Testing %d %d %d", 1, 2, 3)))
}

func (t *VariadicFunctionsTests) Test_Parameters_When() {
// Arrange
mock := printer.NewMock()
Expand Down
6 changes: 6 additions & 0 deletions kelpie.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ func Any[T any]() mocking.Matcher[T] {
func Match[T any](isMatch func(arg T) bool) mocking.Matcher[T] {
return mocking.Matcher[T]{MatchFn: isMatch}
}

// None is used when mocking methods that contain a variable parameter list to indicate that
// no parameters should be provided, for example: printMock.Setup(print.Printf("Testing 123", kelpie.None[any]())).
func None[T any]() mocking.Matcher[T] {
return mocking.None[T]()
}
28 changes: 27 additions & 1 deletion mocking/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,28 @@ import "reflect"
type ArgumentMatcher interface {
// IsMatch returns true when the value of the argument matches the expectation.
IsMatch(other any) bool

// IsNoneMatcher is used to check whether the matcher is being used to match against an
// empty argument list for a variadic function.
IsNoneMatcher() bool
}

// Matcher is used to match an argument in a method invocation.
type Matcher[T any] struct {
MatchFn func(input T) bool
MatchFn func(input T) bool
isNoneMatcher bool
}

// IsMatch returns true if other is a match to the expectation.
func (i Matcher[T]) IsMatch(other any) bool {
return i.MatchFn(other.(T))
}

// IsNoneMatcher returns true if this matcher matches against variadic function empty argument lists.
func (i Matcher[T]) IsNoneMatcher() bool {
return i.isNoneMatcher
}

type variadicMatcher struct {
matchers []ArgumentMatcher
}
Expand All @@ -28,6 +38,11 @@ func Variadic(matchers []ArgumentMatcher) ArgumentMatcher {
return &variadicMatcher{matchers: matchers}
}

// IsNoneMatcher always returns false for variadicMatcher.
func (v *variadicMatcher) IsNoneMatcher() bool {
return false
}

// IsMatch returns true if other is a match to the expectation.
func (v *variadicMatcher) IsMatch(other any) bool {
args, ok := other.([]any)
Expand All @@ -48,6 +63,10 @@ func (v *variadicMatcher) IsMatch(other any) bool {
}
}

if len(v.matchers) == 1 && v.matchers[0].IsNoneMatcher() {
return len(args) == 0
}

if len(args) != len(v.matchers) {
return false
}
Expand All @@ -61,6 +80,13 @@ func (v *variadicMatcher) IsMatch(other any) bool {
return true
}

// None is used to indicate that no arguments should be passed to a variadic function.
func None[T any]() Matcher[T] {
return Matcher[T]{
isNoneMatcher: true,
}
}

// MethodMatcher is used to match a method call to an expectation.
type MethodMatcher struct {
MethodName string
Expand Down
10 changes: 10 additions & 0 deletions mocking/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ func (t *MatcherTests) Test_VariadicMatcher_MatchesWhenParametersEmpty() {
inputs: []any{},
isMatch: true,
},
"None matcher matches empty input": {
matchers: []mocking.ArgumentMatcher{mocking.None[any]()},
inputs: []any{},
isMatch: true,
},
"None matcher does not match non-empty input": {
matchers: []mocking.ArgumentMatcher{mocking.None[any]()},
inputs: []any{"testing"},
isMatch: false,
},
"Matchers empty but arguments provided": {
matchers: []mocking.ArgumentMatcher{},
inputs: []any{"testing", 1, 2, 3},
Expand Down

0 comments on commit f28119d

Please sign in to comment.