Backend-разработка

Тестирование микросервисов на Go в 2026 году: пирамида тестов, моки, интеграционные тесты с Docker

Ruslan Ismailov Опубликовано 14 мин чтения
Т

Введение: почему тестировать микросервисы сложнее, чем монолит

Микросервисная архитектура даёт гибкость и масштабируемость, но одновременно многократно усложняет тестирование. В монолите вы можете поднять одну базу данных, запустить сервис и прогнать все тесты. В мире микросервисов каждый сервис взаимодействует с PostgreSQL, Redis, брокерами сообщений, сторонними REST API и другими сервисами. Любой из этих компонентов может стать источником нестабильности в тестах.

Именно здесь на помощь приходит пирамида тестов. Концепция проста: основание пирамиды — быстрые и дешёвые юнит-тесты, середина — интеграционные тесты, вершина — медленные end-to-end тесты. В контексте Go-микросервисов пирамида выглядит так:

  • Юнит-тесты — тестируют изолированную доменную логику без внешних зависимостей.
  • Интеграционные тесты — проверяют взаимодействие с реальными или контейнеризированными зависимостями (PostgreSQL, Redis).
  • End-to-end тесты — запускают весь стек и проверяют сценарии с точки зрения пользователя.

В этой статье мы пройдём весь путь: от написания table-driven тестов до поднятия PostgreSQL в Docker прямо из кода теста — и разберём, как интегрировать всё это в CI/CD-пайплайн.

Юнит-тесты в Go: testify и table-driven подход

Go поставляется с пакетом testing из стандартной библиотеки, которого часто достаточно для базовых тестов. Но в реальных проектах почти всегда используется testify — набор утилит для удобных assertions и suite-тестов.

Table-driven tests

Паттерн table-driven tests — идиоматический способ тестирования в Go. Вместо дублирования кода для каждого сценария вы описываете таблицу входных данных и ожидаемых результатов:

package calculator_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/calculator"
)

func TestCalculate(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        op       string
        expected int
        wantErr  bool
    }{
        {"сложение", 2, 3, "+", 5, false},
        {"вычитание", 10, 4, "-", 6, false},
        {"деление на ноль", 5, 0, "/", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := calculator.Calculate(tt.a, tt.b, tt.op)
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Тестирование доменной логики

Чистая доменная логика — лучший кандидат для юнит-тестов. Если ваш сервис корректно разделён по слоям (domain, usecase, repository), то тестирование бизнес-правил происходит без какого-либо I/O. Стремитесь к тому, чтобы доменные структуры не зависели от фреймворков и внешних пакетов — это делает тесты мгновенными и стабильными.

Моки и стабы: генерация с mockery

В микросервисах почти каждый usecase зависит от интерфейсов: репозитория для PostgreSQL, кэша Redis, HTTP-клиента внешнего API. Тестировать usecase с реальными зависимостями — дорого и нестабильно. Здесь нужны моки.

Генерация моков с mockery

Инструмент mockery автоматически генерирует mock-реализации Go-интерфейсов. Установка и базовое использование:

# Установка
go install github.com/vektra/mockery/v2@latest

# Генерация моков для всех интерфейсов в пакете
mockery --all --keeptree --output=./mocks

Предположим, у вас есть интерфейс репозитория пользователей:

// internal/domain/user.go
package domain

type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Save(ctx context.Context, user *User) error
}

После генерации mockery создаст структуру MockUserRepository. Тест usecase выглядит так:

package usecase_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/domain"
    "myapp/internal/usecase"
    "myapp/mocks"
)

func TestGetUser_Success(t *testing.T) {
    mockRepo := mocks.NewUserRepository(t)
    expected := &domain.User{ID: 1, Name: "Alice"}

    mockRepo.On("GetByID", context.Background(), int64(1)).
        Return(expected, nil)

    uc := usecase.NewUserUsecase(mockRepo)
    user, err := uc.GetUser(context.Background(), 1)

    assert.NoError(t, err)
    assert.Equal(t, expected.Name, user.Name)
    mockRepo.AssertExpectations(t)
}

Когда использовать стаб вместо мока

Стаб — более простой объект, который просто возвращает заготовленный ответ без верификации вызовов. Используйте стабы, когда вам важен только результат, и моки — когда нужно проверить, что зависимость была вызвана с конкретными аргументами определённое количество раз.

Интеграционные тесты с Docker: testcontainers-go

Моки хороши для юнит-тестов, но они не проверяют реальное взаимодействие с базой данных. SQL-запросы могут содержать ошибки, миграции могут не применяться, транзакции могут работать не так, как ожидается. Для проверки этого слоя нужны интеграционные тесты с реальными зависимостями.

Библиотека testcontainers-go позволяет поднимать Docker-контейнеры прямо из кода теста. Никаких docker-compose файлов, никакой ручной настройки — контейнер стартует перед тестом и останавливается после.

Интеграционный тест с PostgreSQL

package repository_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
    _ "github.com/lib/pq"
    "database/sql"
    "myapp/internal/repository"
)

func TestUserRepository_Save(t *testing.T) {
    ctx := context.Background()

    // Поднимаем контейнер PostgreSQL
    pgContainer, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("testuser"),
        postgres.WithPassword("testpass"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    require.NoError(t, err)
    defer pgContainer.Terminate(ctx)

    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    require.NoError(t, err)

    db, err := sql.Open("postgres", connStr)
    require.NoError(t, err)
    defer db.Close()

    // Применяем миграции
    _, err = db.Exec(`
        CREATE TABLE users (
            id SERIAL PRIMARY KEY,
            name VARCHAR(255) NOT NULL,
            email VARCHAR(255) UNIQUE NOT NULL
        )
    `)
    require.NoError(t, err)

    repo := repository.NewUserRepository(db)
    user := &domain.User{Name: "Bob", Email: "bob@example.com"}

    err = repo.Save(ctx, user)
    assert.NoError(t, err)
    assert.NotZero(t, user.ID)
}

Тестирование с Redis

Аналогично можно поднять Redis-контейнер для тестирования кэширования:

package cache_test

import (
    "context"
    "testing"
    "github.com/testcontainers/testcontainers-go/modules/redis"
    "github.com/testcontainers/testcontainers-go"
    "github.com/stretchr/testify/require"
)

func TestCacheRepository(t *testing.T) {
    ctx := context.Background()

    redisContainer, err := redis.RunContainer(ctx,
        testcontainers.WithImage("redis:7-alpine"),
    )
    require.NoError(t, err)
    defer redisContainer.Terminate(ctx)

    endpoint, err := redisContainer.Endpoint(ctx, "")
    require.NoError(t, err)

    // Создаём Redis-клиент и тестируем репозиторий кэша
    // ...
}

Ключевое преимущество testcontainers-go: тесты самодостаточны. Разработчик клонирует репозиторий, запускает go test ./... — и всё работает без предустановленных сервисов на машине.

Тестирование REST API: httptest и проверка контрактов

Тестирование HTTP-хендлеров в Go решается через стандартный пакет net/http/httptest. Он позволяет создавать in-memory HTTP-сервер без реальных сетевых соединений.

package handler_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
    "github.com/stretchr/testify/assert"
    "myapp/internal/handler"
    "myapp/mocks"
)

func TestCreateUserHandler(t *testing.T) {
    mockUsecase := mocks.NewUserUsecase(t)
    mockUsecase.On("CreateUser", mock.Anything, mock.AnythingOfType("*domain.User")).
        Return(nil)

    h := handler.NewUserHandler(mockUsecase)

    body := `{"name":"Alice","email":"alice@example.com"}`
    req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    h.CreateUser(w, req)

    res := w.Result()
    assert.Equal(t, http.StatusCreated, res.StatusCode)

    var response map[string]interface{}
    json.NewDecoder(res.Body).Decode(&response)
    assert.Equal(t, "Alice", response["name"])
}

Для более серьёзной проверки API-контрактов между сервисами рекомендуется рассмотреть инструменты вроде Pact, которые позволяют зафиксировать ожидания потребителя и верифицировать их на стороне провайдера.

Тестирование конкурентного кода

Go — язык, в котором конкурентность встроена на уровне синтаксиса. Горутины и каналы — это мощь, но одновременно и источник труднонаходимых багов. Для их поиска у Go есть встроенный race detector.

Race detector

Запустите тесты с флагом -race, и Go будет инструментировать код для обнаружения гонок данных:

go test -race ./...

Race detector работает в рантайме и замедляет выполнение примерно в 5–10 раз, но находит реальные гонки. Всегда включайте его в CI.

Тестирование воркер-пула

func TestWorkerPool_ProcessesAllJobs(t *testing.T) {
    pool := worker.NewPool(5) // 5 воркеров
    var processed int64

    for i := 0; i < 100; i++ {
        pool.Submit(func() {
            atomic.AddInt64(&processed, 1)
        })
    }

    pool.Wait()
    assert.Equal(t, int64(100), atomic.LoadInt64(&processed))
}

Использование sync/atomic и sync.WaitGroup в тестах конкурентного кода — обязательная практика.

Организация тестов в проекте

Правильная организация тестов в Go-проекте экономит время и нервы команды. Вот рекомендуемая структура:

  • Юнит-тесты располагаются рядом с тестируемым кодом: user_service_test.go в том же пакете или с суффиксом _test.
  • Интеграционные тесты выносятся в директорию internal/repository/integration/ или помечаются build-тегом.
  • E2E-тесты живут в отдельной директории test/e2e/.

Build-теги для разделения тестов

//go:build integration
// +build integration

package repository_test
// Интеграционные тесты с Docker запускаются только при наличии тега

Makefile для удобного запуска

.PHONY: test test-unit test-integration test-race

test-unit:
    go test ./... -short -count=1

test-integration:
    go test ./... -tags=integration -count=1 -timeout=120s

test-race:
    go test -race ./... -count=1

test-coverage:
    go test ./... -coverprofile=coverage.out
    go tool cover -html=coverage.out -o coverage.html

Интеграция в CI/CD

Тесты должны запускаться автоматически при каждом коммите. В 2026 году стандартом стал GitHub Actions или GitLab CI с параллельным запуском тестовых пакетов.

Пример GitHub Actions workflow

name: Go Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache: true
      - name: Run unit tests with race detector
        run: go test -race -short -coverprofile=coverage.out ./...
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.out

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache: true
      - name: Run integration tests
        run: go test -tags=integration -timeout=120s ./...
        env:
          TESTCONTAINERS_RYUK_DISABLED: "false"

Обратите внимание на использование кэша Go-модулей (cache: true) — это значительно ускоряет загрузку зависимостей. Testcontainers-go сам управляет Docker-контейнерами, поэтому в CI достаточно наличия Docker-daemon, который есть на всех стандартных раннерах GitHub Actions.

Параллелизм тестов

Используйте t.Parallel() в юнит-тестах, которые не разделяют состояние. Это существенно сокращает общее время выполнения:

func TestSomething(t *testing.T) {
    t.Parallel()
    // тест
}

Для интеграционных тестов с testcontainers параллельный запуск тоже возможен, но требует аккуратности: каждый тест должен поднимать свой контейнер или использовать общий контейнер через TestMain.

Типичные антипаттерны тестирования Go-сервисов

  • Тестирование реализации, а не поведения. Если тест проверяет, что конкретный метод репозитория был вызван 3 раза, он хрупкий. Тест должен проверять бизнес-результат.
  • Глобальное состояние в тестах. Использование глобальных переменных или синглтонов делает тесты зависимыми от порядка запуска. Всегда создавайте свежие экземпляры зависимостей в каждом тесте.
  • Слишком много моков. Если в тесте usecase замоканы 7 зависимостей — это сигнал, что usecase делает слишком много. Декомпозируйте логику.
  • Отсутствие тестов на граничные случаи. Нулевые значения, пустые срезы, очень длинные строки — именно здесь прячутся баги в production.
  • Игнорирование race detector. Запуск тестов без -race в CI — потенциальные data races в production. Это недопустимо для микросервисов с конкурентными воркерами.
  • Тесты, зависящие от внешней сети. Интеграционные тесты, обращающиеся к реальным внешним сервисам (AWS, Stripe), нестабильны. Используйте testcontainers или wiremock для их изоляции.
  • Слабое разделение юнит и интеграционных тестов. Без build-тегов или флага -short разработчики запускают медленные интеграционные тесты вместе с быстрыми юнит-тестами, теряя обратную связь.

Заключение

Комплексное тестирование микросервисов на Go — это не про 100% покрытие ради галочки. Это про уверенность в деплойменте: вы меняете код репозитория, CI запускает тесты, и через несколько минут знаете, что PostgreSQL-запросы работают, Redis-кэш инвалидируется корректно, а HTTP-хендлеры отдают правильные статус-коды.

Ключевые принципы, которые стоит взять в работу:

  1. Следуйте пирамиде тестов: большинство тестов — быстрые юнит-тесты, интеграционные — для критических путей.
  2. Используйте mockery для генерации моков интерфейсов и избегайте ручного написания фейков.
  3. Поднимайте реальные PostgreSQL и Redis через testcontainers-go — это устраняет целый класс багов, которые моки не поймают.
  4. Всегда запускайте race detector в CI/CD пайплайне.
  5. Разделяйте тесты тегами и Makefile-таргетами для быстрой обратной связи при разработке.

Инвестиции во вдумчивое тестирование окупаются сторицей: меньше инцидентов в production, быстрее онбординг новых разработчиков и уверенный рефакторинг без страха всё сломать.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →