Skip to content

adamconnelly/kelpie

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

56 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Kelpie

What is Kelpie

Kelpie is the most magical mock generator for Go. Kelpie aims to be easy to use, and generates fully type-safe mocks for Go interfaces.

Project Status

At the moment Kelpie is very much in development, and there are missing features and some pretty rough edges. You're of course welcome to use Kelpie, but just be prepared to hit problems and raise issues or PRs!

The following is a list of known-outstanding features and known issues:

  • Add support for embedded interfaces.

Quickstart

Install Kelpie:

go install github.com/adamconnelly/kelpie/cmd/kelpie@latest

Add a go:generate marker to the interface you want to mock:

//go:generate kelpie generate --package github.com/someorg/some/package --interfaces EmailService
type EmailService interface {
	Send(sender, recipient, body string) (cost float64, err error)
}

Use the mock:

emailServiceMock := emailservice.NewMock()
emailServiceMock.Setup(
	emailservice.Send("[email protected]", "[email protected]", kelpie.Any[string]()).
	Return(100.54, nil)
)
emailServiceMock.Setup(
	emailservice.Send("[email protected]", "[email protected]", kelpie.Any[string]()).
	Return(0, errors.New("that domain is forbidden!"))
)

service := emailServiceMock.Instance()

service.Send("[email protected]", "[email protected]", "Amazing message")
// Returns 100.54, nil

service.Send("[email protected]", "[email protected]", "Hello")
// Returns 0, errors.New("that domain is forbidden!)

Using Kelpie

Mock Generation

There are two main ways to generate your mocks:

  1. Using go:generate comments.
  2. Using a kelpie.yaml file.

Using go:generate comments is simple - take a look at the Quickstart for an example.

The other option is to add a kelpie.yaml file to your repo. The advantage of this is that all of your mocks are defined in one place, and mock generation can be significantly quicker than the go:generate approach because it avoids unnecessary duplicate parsing.

To do this, add a kelpie.yaml file to the root of your repo like this:

version: 1
packages:
  # You can mock packages that aren't part of your repo. To do this just specify the package
  # name as normal:
  - package: io
    # When mocking packages outside your source tree, remember to specify the directory the
    # mocks should be generated in.
    directory: examples/mocks
    mocks:
      - interface: Reader
  - package: github.com/adamconnelly/kelpie/examples
    # Mocks defines the interfaces within your package that you want to generate mocks for.
    mocks:
      - interface: Maths
      - interface: RegistrationService
        generation:
          # Package sets the package name generated for the mock. By default the package name
          # is the lower-cased interface name.
          package: regservice

To generate the mocks, just run kelpie generate:

$ kelpie generate
Kelpie mock generation starting - preparing to add some magic to your code-base!

Parsing package 'io' for interfaces to mock.
  - Generating a mock for 'Reader'.

Parsing package 'github.com/adamconnelly/kelpie/examples' for interfaces to mock.
  - Generating a mock for 'Maths'.
  - Generating a mock for 'RegistrationService'.

Mock generation complete!

Default Behaviour

No setup, no big deal. Kelpie returns the default values for method calls instead of panicking:

mock := emailservice.NewMock()
mock.Instance().Send("[email protected]", "[email protected]", "Hello world")
// Returns 0, nil

Overriding an Expectation

Kelpie always uses the most recent expectation when trying to match a method call. That way you can easily override behaviour. This is really useful if you want to for example specify a default behaviour, and then later test an error condition:

mock := emailservice.NewMock()

// Setup an initial behaviour
mock.Setup(
	emailservice.Send(kelpie.Any[string](), kelpie.Any[string](), kelpie.Any[string]()).
	Return(200, nil)
)

service := mock.Instance()

cost, err := service.Send("[email protected]", "[email protected]", "Hello world")
t.Equal(200, cost)
t.NoError(err)

// We override the mock, to allow us to test an error condition
mock.Setup(
	emailservice.Send(kelpie.Any[string](), kelpie.Any[string](), kelpie.Any[string]()).
	Return(0, errors.New("no way!"))
)

cost, err := service.Send("[email protected]", "[email protected]", "Hello world")
t.Equal(0, cost)
t.ErrorEqual(err, "no way!")

Argument Matching

Exact Matching

By default Kelpie uses exact matching, and any parameters in a method call need to exactly match those specified in the setup:

emailServiceMock.Setup(
	emailservice.Send("[email protected]", "[email protected]", "Hello world").
	Return(100.54, nil)
)

Any

You can match against any possible values of a particular parameter using kelpie.Any[T]():

emailServiceMock.Setup(
	emailservice.Send(kelpie.Any[string](), "[email protected]", "Hello world").
	Return(100.54, nil)
)

Custom Matching

You can add custom argument matching functionality using kelpie.Match[T](isMatch):

emailServiceMock.Setup(
	emailservice.Send(
		kelpie.Match(func(sender string) bool {
			return strings.HasSuffix(sender, "@discounted-sender.com")
		}),
		"[email protected]",
		"Hello world!").
		Return(50, nil))

Setting Behaviour

Returning a Value

To return a specific value from a method call, use Return():

emailServiceMock.Setup(
	emailservice.Send("[email protected]", "[email protected]", "Hello world").
	Return(100.54, nil)
)

Panic

To panic, use Panic():

emailServiceMock.Setup(
	emailservice.Send("[email protected]", kelpie.Any[string](), "testing").
	Panic("Something has gone badly wrong!")
)

Custom Action

To perform a custom action, use When():

emailServiceMock.Setup(
	emailservice.Send(kelpie.Any[string](), kelpie.Any[string](), kelpie.Any[string]()).
		When(func(sender, recipient, body string) (float64, error) {
			// Do something
			return 0, nil
		}))

Verifying Method Calls

You can verify that a method has been called using the mock.Called() method:

// 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:

// 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()))

Variable parameter lists (variadic functions)

You can mock methods that accept variable parameter lists, but there are some caveats to be aware of. Here's a simple example using exact matching:

type Printer interface {
	Printf(formatString string, args ...interface{}) string
}

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

	mock.Setup(printer.Printf("Hello %s. This is %s, %s.", "Dolly", "Louis", "Dolly").Return("Hello Dolly. This is Louis, Dolly."))

	// Act
	result := mock.Instance().Printf("Hello %s. This is %s, %s.", "Dolly", "Louis", "Dolly")

	// Assert
	t.Equal("Hello Dolly. This is Louis, Dolly.", result)
}

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:

mock.Setup(printer.Printf("Hello %s. This is %s, %s.", kelpie.ExactMatch("Dolly"), kelpie.Any[string](), kelpie.ExactMatch("Dolly")).
		Return("Hello Dolly. This is Louis, Dolly."))

But the following will not compile:

mock.Setup(printer.Printf("Hello %s. This is %s, %s.", "Dolly", kelpie.Any[string](), "Dolly").
		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:

// Fails with a "mismatched types untyped string and untyped int (cannot infer P1)" error
mock.Called(printer.Printf("Hello world!", "One", 2, 3.0))

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]():

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:

// 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:

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

Matching any arguments

Similar to the way that you can match against no parameters with kelpie.None[T](), you can match that any amount of parameters are passed to a variadic function using kelpie.AnyArgs[T]():

mock.Setup(printer.Printf("Don't panic!", kelpie.AnyArgs[any]()).Panic("Ok!"))

Nested Interfaces

Kelpie supports mocking interfaces defined as struct fields. This can be useful in situations where you want to define an interface to decouple and make testing easier, but that interface isn't used anywhere else.

To generate a mock for a nested interface, just use the format <Struct Name>.<Field Name> to reference the nested interface, like in the following example:

//go:generate kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces ConfigService.Encrypter
//go:generate kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces ConfigService.Storage
type ConfigService struct {
	Encrypter interface {
		Encrypt(value string) (string, error)
	}

	Storage interface {
		StoreConfigValue(key, value string) error
	}
}

func (c *ConfigService) StoreConfig(key, value string) error {
	encryptedValue, err := c.Encrypter.Encrypt(value)
	if err != nil {
		return err
	}

	return c.Storage.StoreConfigValue(key, encryptedValue)
}

We can then use the mocks like this:

func (t *NestedInterfacesTests) Test_ConfigService_StoresEncryptedValue() {
	// Arrange
	encrypterMock := encrypter.NewMock()
	storageMock := storage.NewMock()

	encrypterMock.Setup(encrypter.Encrypt("unencrypted").Return("encrypted", nil))

	configService := &ConfigService{
		Encrypter: encrypterMock.Instance(),
		Storage:   storageMock.Instance(),
	}

	// Act
	err := configService.StoreConfig("kelpie.testSecret", "unencrypted")

	// Assert
	t.NoError(err)
	t.True(storageMock.Called(storage.StoreConfigValue("kelpie.testSecret", "encrypted")))
}

If you need to mock an interface that's nested inside another struct, just specify the dot-separated path to the interface. For example MyStruct.NestedField.InterfaceToMock.

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:

Add(a, b int) int

Kelpie will generate the following method for configuring expectations on Add:

func Add[P0 int | mocking.Matcher[int], P1 int | mocking.Matcher[int]](a P0, b P1) *addMethodMatcher {

This is neat, because it allows each parameter to either be an int, or a Kelpie matcher, allowing you to write simple setups like this:

mock.Setup(maths.Add(10, 20).Return(30))

Unfortunately Go generics don't allow a union that contains a non-empty interface. Because of this if any of your parameters accept an interface, you need to use a Kelpie matcher. For example the following won't work:

var ctx context.Context
mock.Setup(secrets.Get(ctx, "MySecret").Return("SuperSecret"))

But the following will:

var ctx context.Context
mock.Setup(secrets.Get(kelpie.Any[context.Context](), "MySecret").Return("SuperSecret"))

Mocking an interface from an external package

Kelpie can happily mock interfaces that aren't part of your own source. You don't need to do anything special to mock an "external" interface - just specify the package and interface name you want to mock:

//go:generate kelpie generate --package io --interfaces Reader

func (t *ExternalTypesTests) Test_CanMockAnExternalType() {
	// Arrange
	var bytesRead []byte
	mock := reader.NewMock()
	mock.Setup(reader.Read(kelpie.Match(func(b []byte) bool {
		bytesRead = b
		return true
	})).Return(20, nil))

	// Act
	read, err := mock.Instance().Read([]byte("Hello World!"))

	// Assert
	t.NoError(err)
	t.Equal(20, read)
	t.Equal([]byte("Hello World!"), bytesRead)
}

FAQ

What makes Kelpie so magical

Kelpies are magical creatures from Scottish folk-lore that have shape-shifting abilities. This name seemed fitting for a mocking library, where generated mocks match the shape of interfaces that you want to simulate.

But other than that, there's nothing very magical about Kelpie.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published