Unit Test mocking using Mockery

How to set up and use Mockery for unit testing in Nephio.

Introduction

This guide describes how to use testify and mockery for writing, mocking, and executing unit tests. This guide will help folks come up to speed on using testify and mockery.

How Mockery works

The mockery documentation describes why you would use and how to use Mockery. In a nutshell, Mockery generates mock implementations for interfaces in go, which you can then use instead of real implementations when unit testing.

Mockery support in Nephio make

The make files in Nephio repos containing go code have targets to support mockery.

The default-mockery.mk file in the root of Nephio repos is included in Nephio make runs.

There are two targets in default-mockery.mk:

1. install-mockery: Installs mockery in docker or locally if docker is not available
2. generate-mocks: Runs generation of the mocks for go interfaces

The targets above must be run explicitly.

Run make install-mockery to install mockery in your container runtime (docker, podman etc) or locally if you have no container runtime running. You need only run this target once unless you need to reinstall Mockery for whatever reason.

Run make generate-mocks to generate the mocked implementation of the go interfaces specified in ‘.mockery.yaml’ files. You need to run this target each time an interface that you are mocking changes or whenever you change the contents of a .mockery.yaml file. You can run make generate-mocks in the repo root to generate or re-generate all interfaces or in subdirectories containing a Makefile to generate or regenerate only the interfaces in that subdirectory and its children.

The generate-mocks target looks for .mockery.yaml files in the repo and it runs the mockery mock generator on each .mockery.yaml file it finds. This has the nice effect of allowing .mockery.yaml files to be in either the root of the repo or in subdirectories, so the choice of placement of .mockery.yaml files is left to the developer.

The .mockery.yaml file

The .mockery.yaml file specifies which mock implementations Mockery should generate and also controls how that generation is performed. Here we just give an overview of mockery.yaml. For full details consult the configuration section of the Mockery documentation.

Example 1

Here, we use the Nephio Controllers package .mockery.yaml file as an example:

1. packages:
2.   github.com/nephio-project/nephio/controllers/pkg/giteaclient:

We provide a list of the packages for which we want to generate mocks. In this example, we only have one package. Here we want to generate mocks for the GiteaClient interface so we provide the package path to the interface.

3.     interfaces:
4.      GiteaClient:
5.        config:
6.          dir: "{{.InterfaceDir}}"

We want mocks to be generated for the GiteaClient go interface (line 4). The {{.InterfaceDir}} parameter (line 6) asks Mockery to generate the mock file in the same directory as the interface is located.

Example 2

This example is a slightly more evolved version of the file used in Example 1 above.

1. with-expecter: true

Generate EXPECT() methods for your mocks, see the configuration section of the Mockery documentation.

2. packages:
3.   github.com/nephio-project/nephio/controllers/pkg/giteaclient:
4.    interfaces:
5.      GiteaClient:
6.        config:
7.          dir: "{{.InterfaceDir}}"

Lines 2 to 7 are as explained in Example 1 above.

8.  sigs.k8s.io/controller-runtime/pkg/client:

Generate mocks for the external package sigs.k8s.io/controller-runtime/pkg/client.

 9.    interfaces:
10.      Client:

Generate a mock implementation of the go interface Client in the external package sigs.k8s.io/controller-runtime/pkg/client.

11.        config:
12.          dir: "mocks/external/{{ .InterfaceName | lower }}"
13.          outpkg: "mocks"

Create the mocks for the Client interface in the mocks/external/client directory and cal the output package mocks.

The generated mock implementation

This mocked implementation of the GiteaClient interface was generated by mockery using the make generate-mocks make target.

We can treat this generated file as a black box and we do not have to know the details of the contents of this file to write unit tests.

The Mockery Utils package

The mockery utils package is a utility package that you can use to initialize your mocks and to define some common fields for your tests.

mockeryutils-types.go contains the MockHelper struct, which allows you to control the behaviour of a mock.

type MockHelper struct {
	MethodName string           // The mocked method name for which we want to supply configuration
	ArgType    []string         // The arguments we are supplying to the mocked method
	RetArgList []interface{}    // The arguments we want the mocked method to return to us
}

The MockHelper struct is used to configure a mocked method to expect and return a certain set of arguments. We pass instances of this struct to the mocked interface during tests.

mockeryutils.go contains the InitMocks function, which initializes your mocks for you before a test.

func InitMocks(mocked *mock.Mock, mocks []MockHelper)

For the given mocked interface, the function initializes the mocks as specified in the given MockHelper array.

Using the mock implementation in unit tests

The unit tests for the Repository Reconciler use the mocks generated above.

type fields struct {
	APIPatchingApplicator resource.APIPatchingApplicator
	giteaClient           giteaclient.GiteaClient
	finalizer             *resource.APIFinalizer
	l                     logr.Logger
}
type args struct {
	ctx         context.Context
	giteaClient giteaclient.GiteaClient
	cr          *infrav1alpha1.Repository
}
type repoTest struct {
	name    string
	fields  fields
	args    args
	mocks   []mockeryutils.MockHelper
	wantErr bool
}

The code above allows us to specify input data and the expected outcome for tests. Each test is specified as an instance of the repoTest struct. For each test, we specify its fields and arguments, and specify the mocking for the test.

func TestUpsertRepo(t *testing.T)

This method contains unit tests for the upsertRepo method written using mockery and testify.

tests := []repoTest{}

This is the specification of an array of tests that we will run.

{
	name:   "Create repo: cr fields not blank",
	fields: fields{resource.NewAPIPatchingApplicator(nil), nil, nil, log.FromContext(context.Background())},
	args: args{
		nil,
		nil,
		&infrav1alpha1.Repository{
			Spec: infrav1alpha1.RepositorySpec{
				Description:   &dummyString,
				Private:       &dummyBool,
				IssueLabels:   &dummyString,
				Gitignores:    &dummyString,
				License:       &dummyString,
				Readme:        &dummyString,
				DefaultBranch: &dummyString,
				TrustModel:    &dummyTrustModel,
			},
		},
	},
	mocks: []mockeryutils.MockHelper{
		{MethodName: "GetMyUserInfo", ArgType: []string{}, RetArgList: []interface{}{&gitea.User{UserName: "gitea"}, nil, nil}},
		{MethodName: "GetRepo", ArgType: []string{"string", "string"}, RetArgList: []interface{}{&gitea.Repository{}, nil, fmt.Errorf("repo does not exist")}},
		{MethodName: "CreateRepo", ArgType: []string{"gitea.CreateRepoOption"}, RetArgList: []interface{}{&gitea.Repository{}, nil, nil}},
	},
	wantErr: false,
}

The code above specifies a single test and is an instance of the tests array. We specify the fields, arguments, and mocks for the test. In this case, we mock three functions on our GiteaClient interface: GetMyUserInfo, GetRepo, and CreateRepo. We specify the arguments we expect for each function and specify what the function should return if it receives correct arguments. Of course, if the mocked function receives incorrect arguments, it will report an error. The wantErr value indicates if we expect the upsertRepo function being tested to succeed or fail.

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		r := &reconciler{
			APIPatchingApplicator: tt.fields.APIPatchingApplicator,
			giteaClient:           tt.fields.giteaClient,
			finalizer:             tt.fields.finalizer,
		}

		initMockeryMocks(&tt)

		if err := r.upsertRepo(tt.args.ctx, tt.args.giteaClient, tt.args.cr); (err != nil) != tt.wantErr {
			t.Errorf("upsertRepo() error = %v, wantErr %v", err, tt.wantErr)
		}
	})
}

The code above executes the tests. We run a reconciler r and initialize our tests using the local initMockeryTests() function. We then call the upsertRepo function to test it and check the result.

func initMockeryMocks(tt *repoTest) {
	mockGClient := new(giteaclient.MockGiteaClient)
	tt.args.giteaClient = mockGClient
	tt.fields.giteaClient = mockGClient
	mockeryutils.InitMocks(&mockGClient.Mock, tt.mocks)
}

The initMockeryMocks local function calls the mockeryutils.InitMocks to initialize the mocks for the tests.