Testing in Go: Unit, Integration, E2E at an Advanced Level

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: Why Advanced Testing is a Must-Have in Go



Go (Golang) is renowned for its simplicity and built-in testing tools. However, when a project grows beyond "Hello, World", the standard go test is no longer sufficient. At an advanced level, developers face questions: how to properly isolate tests, how to test database interactions, how to mock external services, and how to organize end-to-end verification of the entire application.



In this article, we will break down three levels of testing — Unit, Integration, and E2E — using real-world examples. You will learn how to use mocks, containers for integration tests, and how to build a reliable CI/CD pipeline. The material is intended for developers who are already familiar with the basics of Go and want to take the quality of their code to the next level.



1. Unit Tests: Isolation, Mocks, and the Table-Driven Tests Pattern



Unit tests verify the work of a single module (function, method) in complete isolation from external dependencies. In Go, interfaces and mocking libraries such as testify/mock or gomock are often used for this purpose.



1.1. Interfaces as the Foundation of Testing



To replace a real service with a mock, the code must depend on an interface, not a concrete implementation. Let's look at an example of a notification service.



// notifier.gotype Notifier interface {    Send(to string, message string) error}

type UserService struct { Notifier Notifier}

func (s *UserService) NotifyUser(userID string) error { // logic to get user email email := userID + "@example.com" return s.Notifier.Send(email, "Welcome!") }


1.2. Creating a Mock with testify/mock



Install the library: go get github.com/stretchr/testify.



// notifier_mock.go (in package user_test)type NotifierMock struct {    mock.Mock}

func (m *NotifierMock) Send(to string, message string) error { args := m.Called(to, message) return args.Error(0)}


1.3. Table-Driven Tests



This is a standard pattern in Go for testing multiple cases. It improves readability and simplifies adding new tests.



// user_service_test.gofunc TestNotifyUser(t *testing.T) {    tests := []struct {        name      string        userID    string        mockError error        expectErr bool    }{        {"success", "user1", nil, false},        {"fail_send", "user2", errors.New("send error"), true},    }

for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockNotifier := new(NotifierMock) service := &UserService{Notifier: mockNotifier}

mockNotifier.On("Send", tt.userID+"@example.com", "Welcome!").Return(tt.mockError)

err := service.NotifyUser(tt.userID) if tt.expectErr { assert.Error(t, err) } else { assert.NoError(t, err) } mockNotifier.AssertExpectations(t) }) }}


Best practice: Use t.Run for subtests — this allows you to run each case separately and see which one failed.



2. Integration Tests: Working with Databases and External Services



Integration tests verify the interaction of several components: Go code with a database, an API server with a cache, etc. The main challenge is managing the environment state. Here, Testcontainers come to the rescue.



2.1. Testcontainers for Go



Testcontainers allow you to spin up Docker containers directly from tests. This provides a clean, isolated environment for each run.



Installation: go get github.com/testcontainers/testcontainers-go



2.2. Example: Testing

Blogs

Book Recommendations