[Go] Nested Call Isolation in Unit Tests

I’m sure I won’t reveal anything new to most of those who have been using Go for a long time at work. But, it often turns out that people are not aware of this and it will be easier for me to send them by link than to repeat the same thing over and over again. At the same time, it may be useful to someone else.

The point is this.

Let’s say we have a structure with methods A, B, C. But suddenly we have to call C from B, and even better, if method D appears and the sequence of calls becomes D-> A + D-> B-> C in one vial. In general, nested calls.

If nested calls are not isolated, then the tests will become noticeably longer and we will be testing the same thing in tests of different methods.

Situation in code:

package example

import (
	"github.com/google/uuid"
)

//go:generate mockgen -source example.go -destination gomocks/example.go -package gomocks

type Dependency interface {
	DoSomeWork(id uuid.UUID)
	DoAnotherWork(id uuid.UUID)
	DoAnotherWorkAgain(id uuid.UUID)
}

type X struct {
	dependency Dependency
}

func NewX(dependency Dependency) *X {
	return &X{dependency: dependency}
}

func (x *X) A(id uuid.UUID) {
	x.dependency.DoSomeWork(id)
}

func (x *X) B(id uuid.UUID) {
	x.dependency.DoAnotherWork(id)
	x.C(id)
}

func (x *X) C(id uuid.UUID) {
	x.dependency.DoAnotherWorkAgain(id)
}

func (x *X) D(id uuid.UUID) {
	x.A(id)
	x.B(id)
}

Pay attention to method D. It generates long chains of calls.

Now let’s imagine what a test of method D might look like:

package example_test

import (
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/google/uuid"
	"github.com/stretchr/testify/suite"

	"example"
	"example/gomocks"
)

func TestX(t *testing.T) {
	suite.Run(t, new(XTestSuite))
}

type XTestSuite struct {
	suite.Suite
	ctrl       *gomock.Controller
	dependency *gomocks.MockDependency
	x          *example.X
}

func (s *XTestSuite) SetupTest() {
	s.ctrl = gomock.NewController(s.T())
	s.dependency = gomocks.NewMockDependency(s.ctrl)
	s.x = example.NewX(s.dependency)
}

func (s *XTestSuite) TestD() {
	var id = uuid.MustParse("c73d6461-f461-4462-b1fe-0aa9b500f928")

	// Мы тестируем правильность работы не совсем тех методов, которые
	// мы тестируем сейчас, но и всех остальных методов. Мы прогоняем
	// всю логику насквозь. В ситуации, когда методы содержат десятки
	// вызовов и более-менее сложную логику, это становится похоже
	// на нетестируемый код из-за слишком высокой цикломатики.
	s.dependency.EXPECT().DoSomeWork(id)
	s.dependency.EXPECT().DoAnotherWork(id)
	s.dependency.EXPECT().DoAnotherWorkAgain(id)

	s.x.D(id)
}

There is a simple way out of this situation. What if we isolate X methods from themselves?

Let’s add some improvements to our code:

package example

import (
	"github.com/google/uuid"
)

//go:generate mockgen -source example.go -destination gomocks/example.go -package gomocks

type (
	Dependency interface {
		DoSomeWork(id uuid.UUID)
		DoAnotherWork(id uuid.UUID)
		DoAnotherWorkAgain(id uuid.UUID)
	}
	This interface {
		A(id uuid.UUID)
		B(id uuid.UUID)
	}
)

type X struct {
	dependency Dependency
	this       This
}

type Option func(x *X)

func WithThisMock(this This) Option {
	return func(x *X) {
		x.this = this
	}
}

func NewX(dependency Dependency, opts ...Option) *X {
	x := &X{dependency: dependency}

	for _, f := range opts {
		f(x)
	}

	if x.this == nil {
		x.this = x
	}

	return x
}

func (x *X) A(id uuid.UUID) {
	x.dependency.DoSomeWork(id)
}

func (x *X) B(id uuid.UUID) {
	x.dependency.DoAnotherWork(id)
	x.C(id)
}

func (x *X) C(id uuid.UUID) {
	x.dependency.DoAnotherWorkAgain(id)
}

func (x *X) D(id uuid.UUID) {
	// Изолировали вложенные вызовы.
	x.this.A(id)
	x.this.B(id)
}

What have we done here? We have isolated method calls of type X from its own methods. Now we can write a test of method D testing only the logic of method D.

Let’s look at the test:

package example_test

import (
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/google/uuid"
	"github.com/stretchr/testify/suite"

	"example"
	"example/gomocks"
)

func TestX(t *testing.T) {
	suite.Run(t, new(XTestSuite))
}

type XTestSuite struct {
	suite.Suite
	ctrl       *gomock.Controller
	dependency *gomocks.MockDependency
	this       *gomocks.MockThis
	x          *example.X
}

func (s *XTestSuite) SetupTest() {
	s.ctrl = gomock.NewController(s.T())
	s.dependency = gomocks.NewMockDependency(s.ctrl)
	s.this = gomocks.NewMockThis(s.ctrl)
	// В рабочем коде мы можем использовать
	// конструктор как example.NewX(realDependency).
	s.x = example.NewX(s.dependency, example.WithThisMock(s.this))
}

func (s *XTestSuite) TestD() {
	var id = uuid.MustParse("c73d6461-f461-4462-b1fe-0aa9b500f928")

	// Теперь мы тестируем только метод D.
	s.this.EXPECT().A(id)
	s.this.EXPECT().B(id)

	s.x.D(id)
}

Everything has been simplified, right?

I hope this will be useful to myself and I will no longer have to repeat it in words, as well as to someone else who is not yet in the subject. 🙂

Concerning the very word this. It’s probably not quite idiomatic, but any other word can be used, like self or watewa.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *