Введение: Почему продвинутое тестирование — это must have в Go
Go (Golang) славится своей простотой и встроенными инструментами для тестирования. Однако, когда проект вырастает за рамки "Hello, World", стандартный go test перестаёт быть достаточным. На продвинутом уровне разработчики сталкиваются с вопросами: как правильно изолировать тесты, как тестировать работу с базой данных, как имитировать внешние сервисы и как организовать сквозную проверку всего приложения.
В этой статье мы разберём три уровня тестирования — Unit, Integration и E2E — на примерах из реальной практики. Вы узнаете, как использовать моки, контейнеры для интеграционных тестов и как построить надёжный pipeline для CI/CD. Материал рассчитан на разработчиков, которые уже знакомы с основами Go и хотят поднять качество своего кода на новый уровень.
1. Unit-тесты: изоляция, моки и паттерн Table-Driven Tests
Unit-тесты проверяют работу одного модуля (функции, метода) в полной изоляции от внешних зависимостей. В Go для этого часто используют интерфейсы и библиотеки-мокеры, например testify/mock или gomock.
1.1. Интерфейсы как основа тестирования
Чтобы заменить реальный сервис на мок, код должен зависеть от интерфейса, а не от конкретной реализации. Рассмотрим пример сервиса для отправки уведомлений.
// notifier.go
type Notifier interface {
Send(to string, message string) error
}
type UserService struct {
Notifier Notifier
}
func (s *UserService) NotifyUser(userID string) error {
// логика получения email пользователя
email := userID + "@example.com"
return s.Notifier.Send(email, "Welcome!")
}
1.2. Создание мока с testify/mock
Установим библиотеку: go get github.com/stretchr/testify.
// notifier_mock.go (в пакете 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
Это стандартный паттерн в Go для тестирования множества кейсов. Он улучшает читаемость и упрощает добавление новых тестов.
// user_service_test.go
func 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: Используйте t.Run для субтестов — это позволяет запускать каждый кейс отдельно и видеть, какой именно упал.
2. Интеграционные тесты: работа с БД и внешними сервисами
Интеграционные тесты проверяют взаимодействие нескольких компонентов: Go-кода с базой данных, API-сервера с кэшем и т.д. Главный вызов — управлять состоянием окружения. Здесь на помощь приходят Testcontainers.
2.1. Testcontainers для Go
Testcontainers позволяют поднимать Docker-контейнеры прямо из тестов. Это даёт чистую, изолированную среду для каждого запуска.
Установка: go get github.com/testcontainers/testcontainers-go
2.2. Пример: тестирование репозитория с PostgreSQL
// repo_test.go
func TestUserRepository_GetByID(t *testing.T) {
ctx := context.Background()
// 1. Поднимаем PostgreSQL контейнер
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
// 2. Получаем адрес подключения
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
// 3. Инициализируем БД (миграции)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)`)
require.NoError(t, err)
// 4. Вставляем тестовые данные
_, err = db.Exec(`INSERT INTO users (name) VALUES ('Alice')`)
require.NoError(t, err)
// 5. Тестируем репозиторий
repo := NewUserRepository(db)
user, err := repo.GetByID(ctx, 1)
require.NoError(t, err)
assert.Equal(t, "Alice", user.Name)
}
2.3. Флаги для разделения тестов
Unit-тесты должны быть быстрыми. Интеграционные — медленнее. Используйте build tags или флаги, чтобы разделять их.
// file: repo_test.go
//go:build integration
package repository_test
// ... тесты выше
Запуск: go test -tags=integration ./...
3. E2E-тесты: сквозная проверка HTTP API
End-to-end (E2E) тесты проверяют приложение целиком, как его видит пользователь. В веб-сервисах на Go это означает отправку HTTP-запросов к запущенному серверу и проверку ответов.
3.1. Запуск тестового сервера
Лучше не тестировать против продакшена, а поднимать отдельный экземпляр приложения. Используйте httptest.NewServer для быстрого старта.
// e2e_test.go
func TestE2E_CreateUser(t *testing.T) {
// Поднимаем тестовый сервер
handler := setupRouter() // ваша функция с роутами
ts := httptest.NewServer(handler)
defer ts.Close()
// Создаём пользователя через API
requestBody := `{"name":"Bob","