7

How to use mocks in your table-driven tests in Go

 1 year ago
source link: https://cbrgm.net/post/2022-12-05-go-table-driven-tests-testify/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

In this blog post I’d like to show you how to use Go’s table-driven tests with mocks to write powerful and flexible tests to simulate the behavior of depdendent components or modules of your structs.

About table-driven tests

Table-driven tests are a common testing pattern in Go. They are a way to organize and structure tests in a way that is easy to read, maintain, and extend. In Go, table-driven tests are typically implemented using a struct (i.e. a composite data type) that defines the different scenarios or test cases that need to be tested. Each test case is defined as a field in the struct, and the test case’s expected behavior is specified using one or more function arguments. This makes it easy to see at a glance what the test is testing and what the expected outcome is. Additionally, table-driven tests can be run in parallel, which can help to speed up the testing process.

Table-driven tests are a powerful and flexible way to write tests in Go, and they are widely used in the Go community.

So why should I use mocks?

Mocking is a technique used in software testing to simulate the behavior of dependent components or modules. This can be useful for several reasons. First, it allows tests to be run independently of the actual implementation of the dependent components, which makes the tests more portable and easier to run. Second, it allows tests to be run in isolation, which can help to prevent tests from interfering with each other and producing unreliable results. Third, mocking can be used to test how a system behaves in different scenarios, such as when a dependent component is unavailable or returns an error. This can help to identify and diagnose problems with the system. Overall, mocking is an important tool in software testing that can help to improve the reliability and robustness of a system.

Let’s take a look at a simple example

Let’s look at the following project structure:

.
├── README.md
├── engine
│   ├── engine.go
│   └── engine_mock.go
├── main.go
├── navigation
│   ├── navigation.go
│   └── navigation_mock.go
└── vehicle
    ├── vehicle.go
    └── vehicle_test.go

A vehicle has to components navigation and engine. Both dependencies must be mocked in order to test the vehicles VehicleDiagnostics() function.

package vehicle

import (
	"errors"

	"github.com/cbrgm/table-test-testify-mock/engine"
	"github.com/cbrgm/table-test-testify-mock/navigation"
)

var (
	errLowBattery            = errors.New("battery level is critical")
	errNoNetworkConnectivity = errors.New("network has connection issues")
)

type ElectricVehicle struct {
	engine     engine.Engine
	navigation navigation.Navigation
}

func NewElectricVehicle(engine engine.Engine, navigation navigation.Navigation) *ElectricVehicle {
	return &ElectricVehicle{
		engine:     engine,
		navigation: navigation,
	}
}

func (e *ElectricVehicle) VehicleDiagnostics(chargingThreshold int, isOffline bool) error {
	if e.engine.ChargeLevel() < chargingThreshold {
		return errLowBattery
	}

	if e.navigation.IsOffline() != isOffline {
		return errNoNetworkConnectivity
	}

	return nil
}

This code defines a Go package named vehicle that contains the implementation of an ElectricVehicle type. The ElectricVehicle type has two fields: engine and navigation. The engine field is of type engine.Engine, and the navigation field is of type navigation.Navigation. These types are defined in the engine and navigation packages, respectively.

The package also defines two global variables: errLowBattery and errNoNetworkConnectivity, which are errors that can be returned by the ElectricVehicle type’s methods.

The package defines a NewElectricVehicle function that can be used to create a new instance of the ElectricVehicle type. The function takes two arguments: an engine.Engine and a navigation.Navigation, and it returns a pointer to the newly created ElectricVehicle .

The ElectricVehicle type has a method named VehicleDiagnostics that takes two arguments: a chargingThreshold and a isOffline flag. The method returns an error if either the vehicle’s engine has a low charge level or the vehicle’s navigation is offline, and it returns nil otherwise.

Getting started with stretch/testify

In order to test our Vehicle’s VehicleDiagnostics function, we’ll use testify to mock our subcomponents. testify is a Go package that provides a set of tools for writing and running tests. It is a fork of the stretch package, which was developed by the same author. testify includes a number of useful features, such as the ability to assert the expected behavior of a function, the ability to mock (i.e. simulate) the behavior of dependent components, and the ability to generate test coverage reports. It is widely used in the Go community, and it is considered to be a popular and robust testing tool.

How do we mock the engine and navigation components of our Vehicle? Let’s take a look at the engine’s mock implementation first. The navigation component mocks works in a similar style

package engine

import (
	"github.com/stretchr/testify/mock"
)

type MockedEngine struct {
	mock.Mock
}

func (m *MockedEngine) ChargeLevel() int {
	args := m.Called()
	return args.Get(0).(int)
}

This code defines a Go package named engine that contains the implementation of a MockedEngine type, which is a mock implementation for our Engine interface. The MockedEngine type is a struct that embeds the mock.Mock type from the testify/mock package. This allows the MockedEngine type to inherit the behavior and functionality of the mock.Mock type.

The MockedEngine type defines a method named ChargeLevel that returns an integer. The implementation of the ChargeLevel method uses the Called method from the embedded mock.Mock type to retrieve the arguments that were passed to the method. It then uses the Get method to retrieve the first argument and convert it to an integer, which it returns. This allows the ChargeLevel method to be mocked (i.e. simulated) in tests, so that the behavior of the MockedEngine type can be controlled and tested.

Our navigation component is mocked in a similar way as you can see here:

package navigation

import "github.com/stretchr/testify/mock"

type MockedNavigation struct {
	mock.Mock
}

func (m *MockedNavigation) IsOffline() bool {
	args := m.Called()
	return args.Get(0).(bool)
}

Now let’s write some table-driven tests to verify the correctness of our Vehicle’s diagnostic function. Well start defining a simple test function in a file vehicle_test.go in the vehicle package.

// TestVehicleDiagnostics uses handwritten mocks for the vehicles subcomponents
func TestVehicleDiagnostics(t *testing.T) {
// let's get started...
}

We’ll start by introducing new structs which we’ll need for our table tests.

type mockedFields struct {
	navigation *navigation.MockedNavigation
	engine     *engine.MockedEngine
}

type args struct {
	lowBatteryThreshold int
	isOffline           bool
}

The code defines two new types: mockedFields and args. The mockedFields type is a struct (i.e. a composite data type) that has two fields: navigation and engine. The navigation field is a pointer to a MockedNavigation type, and the engine field is a pointer to a MockedEngine type. These types are defined in the navigation and engine packages, respectively.

The args type is also a struct, and it has two fields: lowBatteryThreshold and isOffline. The lowBatteryThreshold field is an integer, and the isOffline field is a boolean. These fields correspond to the arguments that are passed to the VehicleDiagnostics function. These two types are used later in the code to define the test cases for the VehicleDiagnostics function.

Let’s continue by defining some test cases

testCases := []struct {
		// test case description
		desc string
		// function parameters
		args *args
		// expected function return values
		// in this case error
		result error

		// initializations and assertions for mocked dependencies
		on     func(fields *mockedFields)
		assert func(t *testing.T, fields *mockedFields)
	}{
		{
			desc: "must pass battery level same as threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: nil,
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(10)      // mock Chargelevel
				fields.navigation.On("IsOffline").Return(false) // mock IsOffline
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 1)
			},
		},
		{
			desc: "must fail with error battery level below threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: errLowBattery, // we expect an error
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(1)       // mock Chargelevel
				fields.navigation.On("IsOffline").Return(false) // mock IsOffline
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 0)
			},
		},
		{
			desc: "must pass battery level above threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: nil,
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(55)
				fields.navigation.On("IsOffline").Return(false)
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 1)
			},
		},
	}

Let’s not go too much into details what values we’re passing as arguments in each test case, but let’s focus on the structure here for now.

This code defines a slice of anonymous structs named testCases. Each element in the slice represents a test case for the VehicleDiagnostics function.

Each test case has several fields that define different aspects of the test. The desc field contains a string that describes the test case. The args field contains a pointer to an args struct that defines the arguments that should be passed to the VehicleDiagnosticsfunction. The result field contains the expected return value of the VehicleDiagnostics function for this test case. The on field is a function that sets up the mocked dependencies (i.e. the navigation. MockedNavigation and engine. MockedEngine types) for the test case. The assert field is a function that checks that the mocked dependencies were used as expected during the test. The test cases are used later in the code to test the VehicleDiagnostics function.

Now we’re finally able to run our test cases

for _, tC := range testCases {

		// see: https://gist.github.com/posener/92a55c4cd441fc5e5e85f27bca008721
		tC := tC
		t.Run(tC.desc, func(t *testing.T) {
			t.Parallel()
			mocks := &mockedFields{
				&navigation.MockedNavigation{},
				&engine.MockedEngine{},
			}
			car := NewElectricVehicle(mocks.engine, mocks.navigation)
			if tC.on != nil {
				tC.on(mocks)
			}
			err := car.VehicleDiagnostics(tC.args.lowBatteryThreshold, tC.args.isOffline)
			if err != tC.result {
				t.Errorf("got %v, want %v", err, tC.result)
			}
			if tC.assert != nil {
				tC.assert(t, mocks)
			}
		})
	}

This code iterates over the testCases slice and runs each test case. For each test case, the code creates a new ElectricVehicle instance by calling the NewElectricVehicle function and passing it the mocked engine.MockedEngine and navigation. MockedNavigation types.

If the on field of the test case is not nil, it calls the on function to set up the mocked dependencies. Then, it calls the VehicleDiagnostics function on the ElectricVehicle instance and passes it the lowBatteryThreshold and isOffline values from the args struct of the test case.

The code then compares the returned error with the result value of the test case. If the values are not equal, it prints an error message using the t. Errorf function from the testing package. Finally, if the assert field of the test case is not nil, it calls the assert function to check the state of the mocked dependencies. Great isn’t it?

Here’s a full example of our table-driven tests

package vehicle

import (
	"testing"

	"github.com/cbrgm/table-test-testify-mock/engine"
	"github.com/cbrgm/table-test-testify-mock/navigation"
)

// TestVehicleDiagnostics uses handwritten mocks for the vehicles subcomponents
func TestVehicleDiagnostics(t *testing.T) {
	type mockedFields struct {
		navigation *navigation.MockedNavigation
		engine     *engine.MockedEngine
	}

	type args struct {
		lowBatteryThreshold int
		isOffline           bool
	}

	testCases := []struct {
		// test case description
		desc string
		// function parameters
		args *args
		// expected function return values
		// in this case error
		result error

		// initializations and assertions for mocked dependencies
		on     func(fields *mockedFields)
		assert func(t *testing.T, fields *mockedFields)
	}{
		{
			desc: "must pass battery level same as threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: nil,
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(10)      // mock Chargelevel
				fields.navigation.On("IsOffline").Return(false) // mock IsOffline
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 1)
			},
		},
		{
			desc: "must fail with error battery level below threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: errLowBattery, // we expect an error
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(1)       // mock Chargelevel
				fields.navigation.On("IsOffline").Return(false) // mock IsOffline
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 0)
			},
		},
		{
			desc: "must pass battery level above threshold",
			args: &args{
				lowBatteryThreshold: 10,    // set the threshold
				isOffline:           false, // setIsOffline
			},
			result: nil,
			on: func(fields *mockedFields) {
				fields.engine.On("ChargeLevel").Return(55)
				fields.navigation.On("IsOffline").Return(false)
			},
			assert: func(t *testing.T, fields *mockedFields) {
				fields.engine.AssertNumberOfCalls(t, "ChargeLevel", 1)
				fields.navigation.AssertNumberOfCalls(t, "IsOffline", 1)
			},
		},
	}
	for _, tC := range testCases {

		// see: https://gist.github.com/posener/92a55c4cd441fc5e5e85f27bca008721
		tC := tC
		t.Run(tC.desc, func(t *testing.T) {
			t.Parallel()
			mocks := &mockedFields{
				&navigation.MockedNavigation{},
				&engine.MockedEngine{},
			}
			car := NewElectricVehicle(mocks.engine, mocks.navigation)
			if tC.on != nil {
				tC.on(mocks)
			}
			err := car.VehicleDiagnostics(tC.args.lowBatteryThreshold, tC.args.isOffline)
			if err != tC.result {
				t.Errorf("got %v, want %v", err, tC.result)
			}
			if tC.assert != nil {
				tC.assert(t, mocks)
			}
		})
	}
}

Are you too lazy to write mocks?

You remember our mocks we wrote at the beginning of our example?

package navigation

import "github.com/stretchr/testify/mock"

type MockedNavigation struct {
	mock.Mock
}

func (m *MockedNavigation) IsOffline() bool {
	args := m.Called()
	return args.Get(0).(bool)
}

This is of course a very easy example and a mock for the interface is quickly implemented manually. But what if we want to mock larger interfaces? Do we have to write everything by hand? Of course not!

vektra/mockery is a tool for generating mock implementations of Go interfaces. It can be used to generate mocks that can be used in unit tests to simulate the behavior of real components. This allows you to test the behavior of your code without relying on the real components, which can be slow or difficult to set up.

By running mockery with the following command

mockery --dir navigation --name Navigation --output navigation/mocks --outpkg mocks --with-expecter

and passing our interface name Navigation for example

// go:generate mockery --dir navigation --name Navigation --output navigation/mocks --outpkg mocks --with-expecter
type Navigation interface {
	IsOffline() bool // whether to use offline navigation or not
}

mockery will auto-generate a mock using the stretchr/testify/mock package. A new mock package will be placed into the navigation package with a navigation.go file containing our generated code

// Code generated by mockery v2.15.0. DO NOT EDIT.

package mocks

import mock "github.com/stretchr/testify/mock"

// Navigation is an autogenerated mock type for the Navigation type
type Navigation struct {
	mock.Mock
}

//...

Let’s wrap it up

Table-driven tests are a powerful and flexible testing technique that can help you write more concise, maintainable, and effective tests for your Go code. By defining your test cases as a table of values, you can easily add new test cases and update existing ones without having to modify your test code. This can make it easier to add new test cases and maintain your tests over time.

Using mocks in your table-driven tests can also provide several benefits. By mocking the dependencies of the code you are testing, you can control the behavior of those dependencies and test how your code responds to different inputs and situations. This can help you test error cases or unusual behavior that might be difficult or impossible to test with the real dependencies. Additionally, using mocks can make your tests run faster and more reliably, since you don’t have to rely on external resources or external code. Overall, using table-driven tests with mocks can help you write more effective and efficient tests for your Go code.

With Go’s testing library in combination with stretch/testify and vektra/mockery we can write powerful table-driven tests with generated mocks for our components without a lot of effort.

You can find all example code on my GitHub profile here


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK