Skip to content

Commit

Permalink
feat: add support for a configuration file
Browse files Browse the repository at this point in the history
Added support for a kelpie.yaml file to specify the mocks to generate. The advantage of this is that we can avoid re-parsing the same package multiple times if we're generating more than one mock from it.

The existing `go:generate` method of generation is still supported - this will be up to personal preference.

I've also added some extra output to the generate command to make it easier to tell if the mocks have been generated correctly or not.
  • Loading branch information
adamconnelly committed Jun 9, 2024
1 parent 020011e commit c1bacd3
Show file tree
Hide file tree
Showing 21 changed files with 388 additions and 107 deletions.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/kelpie",
"cwd": "${workspaceFolder}",
"args": ["generate"]
}
]
}
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ install-go-tools: ## Install dev tools
.PHONY: generate
generate: install-go-tools ## Generates any Kelpie mocks
go generate ./...
go run ./cmd/kelpie -- generate

# prepare for code review
reviewable:
Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ Kelpie is the most magical mock generator for Go. Kelpie aims to be easy to use,

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:
The following is a list of known-outstanding features and known issues:

- [ ] Add the ability to customize the name, package and output folder of the generated mocks.
- [ ] Switch from `go:generate` to using a config file for more efficient mock generation in large code-bases.
- [ ] For some reason Kelpie is generating multiple mocks when specifying built-in interfaces. See the [Mock Generation](#mock-generation) section for an example.

## Quickstart

Expand Down Expand Up @@ -54,6 +53,58 @@ service.Send("[email protected]", "[email protected]", "Hello")

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

```yaml
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`:

```shell
$ 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'.
- 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:
Expand Down
48 changes: 48 additions & 0 deletions cmd/kelpie/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

// ConfigVersion defines the version of Kelpie's config file.
type ConfigVersion string

const (
// ConfigVersion1 is v1 of the config file.
ConfigVersion1 ConfigVersion = "1"
)

// Config represents Kelpie's generation config.
type Config struct {
// Version is the version of the config file.
Version ConfigVersion

// Packages contains the configuration of the packages to generate mocks from.
Packages []PackageConfig
}

// PackageConfig defines the configuration for a single package to mock.
type PackageConfig struct {
// PackageName is the full path of the package, for example github.com/adamconnelly/kelpie/examples.
PackageName string `yaml:"package"`

// Mocks contains the list of interfaces to mock.
Mocks []MockConfig

// Output directory is the directory to output generated mocks for this package. Defaults
// to a folder called "mocks" in the package directory if not specified.
OutputDirectory string `yaml:"directory"`
}

// MockConfig is configuration of an individual mock.
type MockConfig struct {
// InterfaceName is the name of the interface to mock, for example "Maths" or "SomeService.SomeRepository".
InterfaceName string `yaml:"interface"`

// GenerationOptions allows generation of the mock to be customized.
GenerationOptions MockGenerationOptions `yaml:"generation"`
}

// MockGenerationOptions allows generation of the mock to be customized.
type MockGenerationOptions struct {
// PackageName is the name of the generated package for the mock. Defaults to the lowercased
// interface name. For example an interface called EmailSender would generate a package
// called emailsender.
PackageName string `yaml:"package"`
}
114 changes: 105 additions & 9 deletions cmd/kelpie/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main
import (
_ "embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand All @@ -13,6 +14,7 @@ import (

"github.com/alecthomas/kong"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"

"github.com/adamconnelly/kelpie/parser"
"github.com/adamconnelly/kelpie/slices"
Expand All @@ -22,22 +24,102 @@ import (
var mockTemplate string

type generateCmd struct {
Package string `short:"p" required:"" help:"The Go package 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."`
ConfigFile string `name:"config-file" short:"c" help:"The path to Kelpie's configuration file."`
Package string `name:"package" short:"p" help:"The Go package containing the interface to mock."`
Interfaces []string `name:"interfaces" short:"i" help:"The names of the interfaces to mock."`
OutputDir string `name:"output-dir" short:"o" default:"mocks" help:"The directory to write the mock out to."`
}

func (g *generateCmd) Run() error {
filter := parser.IncludingInterfaceFilter{
InterfacesToInclude: g.Interfaces,
func (g *generateCmd) Run() (err error) {
if g.ConfigFile != "" && (g.Package != "" || len(g.Interfaces) > 0 || g.OutputDir != "") {
return errors.New("please either specify a Kelpie config file, or specify the -package, -interfaces and -output-dir options, but not both")
}

var config Config

if g.Package == "" {
configFile, err := g.tryOpenConfigFile(g.ConfigFile)
if err != nil {
return err
}
defer configFile.Close()

if err = yaml.NewDecoder(configFile).Decode(&config); err != nil {
return errors.Wrap(err, fmt.Sprintf("could not parse Kelpie's config file at '%s'", configFile.Name()))
}

if config.Version != ConfigVersion1 {
return fmt.Errorf("the only supported config version is '1', but '%s' was specified in the config file", config.Version)
}
} else {
config.Packages = []PackageConfig{
{
PackageName: g.Package,
OutputDirectory: g.OutputDir,
Mocks: slices.Map(g.Interfaces, func(interfaceName string) MockConfig {
return MockConfig{
InterfaceName: interfaceName,
}
}),
},
}
}

cwd, err := os.Getwd()
if err != nil {
return errors.Wrap(err, "could not get current working directory")
}

mockedInterfaces, err := parser.Parse(g.Package, cwd, &filter)
fmt.Printf("Kelpie mock generation starting - preparing to add some magic to your code-base!\n\n")

for _, pkg := range config.Packages {
if err = g.generatePackageMocks(cwd, pkg); err != nil {
return err
}
}

fmt.Printf("Mock generation complete!\n")

return nil
}

var defaultConfigFiles = []string{"kelpie.yaml", "kelpie.yml"}

func (g *generateCmd) tryOpenConfigFile(customFilename string) (*os.File, error) {
filenames := defaultConfigFiles
if customFilename != "" {
filenames = []string{customFilename}
}

for _, filename := range filenames {
// #nosec G304 -- We're opening a potentially user-supplied filename, so we have to pass the filename via a variable.
file, err := os.Open(filename)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}

return nil, errors.Wrap(err, fmt.Sprintf("could not open Kelpie configuration file at '%s'", filename))
}

return file, nil
}

if customFilename == "" {
return nil, fmt.Errorf("could not find Kelpie config file in any of the following locations: [%s]", strings.Join(filenames, ", "))
}

return nil, fmt.Errorf("could not find Kelpie config file in '%s'", customFilename)
}

func (g *generateCmd) generatePackageMocks(cwd string, pkg PackageConfig) error {
filter := parser.IncludingInterfaceFilter{
InterfacesToInclude: slices.Map(pkg.Mocks, func(m MockConfig) string { return m.InterfaceName }),
}

fmt.Printf("Parsing package '%s' for interfaces to mock.\n", pkg.PackageName)

parsedPackage, err := parser.Parse(pkg.PackageName, cwd, &filter)
if err != nil {
return errors.Wrap(err, "could not parse file")
}
Expand Down Expand Up @@ -70,9 +152,21 @@ func (g *generateCmd) Run() error {
}).
Parse(mockTemplate))

for _, i := range mockedInterfaces {
baseOutputDirectory := pkg.OutputDirectory
if baseOutputDirectory == "" {
baseOutputDirectory = filepath.Join(parsedPackage.PackageDirectory, "mocks")
}

for _, i := range parsedPackage.Mocks {
fmt.Printf(" - Generating a mock for '%s'.\n", i.Name)

mockConfig := slices.FirstOrPanic(pkg.Mocks, func(m MockConfig) bool { return m.InterfaceName == i.FullName })
if mockConfig.GenerationOptions.PackageName != "" {
i.PackageName = mockConfig.GenerationOptions.PackageName
}

err := func() error {
outputDirectoryName := filepath.Join(g.OutputDir, i.PackageName)
outputDirectoryName := filepath.Join(baseOutputDirectory, i.PackageName)
if _, err := os.Stat(outputDirectoryName); os.IsNotExist(err) {
if err := os.MkdirAll(outputDirectoryName, 0700); err != nil {
return errors.Wrap(err, "could not create directory for mock")
Expand All @@ -96,6 +190,8 @@ func (g *generateCmd) Run() error {
}
}

fmt.Println()

return nil
}

Expand Down
2 changes: 0 additions & 2 deletions examples/argument_matching_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/suite"
)

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces Maths
type Maths interface {
// Add adds a and b together and returns the result.
Add(a, b int) int
Expand All @@ -37,7 +36,6 @@ type Maths interface {
ParseInt(input string) (int, error)
}

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces Sender
type Sender interface {
SendMessage(title *string, message string) error
SendMany(details map[string]string) error
Expand Down
1 change: 0 additions & 1 deletion examples/called_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/adamconnelly/kelpie/examples/mocks/registrationservice"
)

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces RegistrationService
type RegistrationService interface {
// Register registers the item with the specified name.
Register(name string) error
Expand Down
2 changes: 0 additions & 2 deletions examples/external_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"github.com/stretchr/testify/suite"
)

//go:generate go run ../cmd/kelpie generate --package io --interfaces Reader

type ExternalTypesTests struct {
suite.Suite
}
Expand Down
1 change: 0 additions & 1 deletion examples/imported_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/adamconnelly/kelpie/examples/mocks/requester"
)

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces Requester
type Requester interface {
MakeRequest(r *Request) (io.Reader, error)
}
Expand Down
10 changes: 5 additions & 5 deletions examples/local_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/suite"

"github.com/adamconnelly/kelpie/examples/users"
"github.com/adamconnelly/kelpie/examples/users/mocks/userrepository"
"github.com/adamconnelly/kelpie/examples/users/mocks/userrepo"
)

type LocalTypesTests struct {
Expand All @@ -15,8 +15,8 @@ type LocalTypesTests struct {

func (t *LocalTypesTests) Test_CanReturnAValue() {
// Arrange
mock := userrepository.NewMock()
mock.Setup(userrepository.FindUserByUsername("[email protected]").Return(&users.User{ID: 123}, nil))
mock := userrepo.NewMock()
mock.Setup(userrepo.FindUserByUsername("[email protected]").Return(&users.User{ID: 123}, nil))

// Act
user, err := mock.Instance().FindUserByUsername("[email protected]")
Expand All @@ -29,8 +29,8 @@ func (t *LocalTypesTests) Test_CanReturnAValue() {

func (t *LocalTypesTests) Test_CanMatchOnALocalType() {
// Arrange
mock := userrepository.NewMock()
mock.Setup(userrepository.GetAllUsersOfType(users.UserTypeAdmin).Return([]users.User{{ID: 1}, {ID: 2}}, nil))
mock := userrepo.NewMock()
mock.Setup(userrepo.GetAllUsersOfType(users.UserTypeAdmin).Return([]users.User{{ID: 1}, {ID: 2}}, nil))

// Act
admins, err := mock.Instance().GetAllUsersOfType(users.UserTypeAdmin)
Expand Down
3 changes: 0 additions & 3 deletions examples/nested_interfaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import (
"github.com/stretchr/testify/suite"
)

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces ConfigService.Encrypter
//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces ConfigService.Storage
type ConfigService struct {
Encrypter interface {
Encrypt(value string) (string, error)
Expand Down Expand Up @@ -96,7 +94,6 @@ func (t *NestedInterfacesTests) Test_ConfigService_HandlesStorageFailures() {
t.ErrorContains(err, "could not store value")
}

//go:generate go run ../cmd/kelpie generate --package github.com/adamconnelly/kelpie/examples --interfaces DoubleNested.Internal.DoubleNestedService
type DoubleNested struct {
Internal struct {
DoubleNestedService interface {
Expand Down
Loading

0 comments on commit c1bacd3

Please sign in to comment.