Урок 21: Тестирование — модульные тесты, testify, gomock

Урок 21. Тестирование — модульные тесты, testify, gomock

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-testing && cd go-testing
go mod init go-testing

# Устанавливаем зависимости
go get github.com/stretchr/testify
go get github.com/golang/mock/gomock
go get github.com/google/uuid
go mod tidy

# Устанавливаем mockgen (генератор моков)
go install github.com/golang/mock/mockgen@latest

# Создаём структуру
mkdir -p internal/user
mkdir -p internal/service
mkdir -p mocks

💻 Файл: internal/user/user.go

package user

import (
    "context"
    "errors"
    "fmt"
    "strings"
)

// User — доменная модель
type User struct {
    ID    string
    Email string
    Name  string
}

// Repository — ИНТЕРФЕЙС (его будем мокать)
type Repository interface {
    Create(ctx context.Context, u *User) error
    GetByID(ctx context.Context, id string) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
}

// Ошибки
var (
    ErrNotFound      = errors.New("user not found")
    ErrAlreadyExists = errors.New("user already exists")
    ErrEmptyEmail    = errors.New("email must not be empty")
    ErrEmptyName     = errors.New("name must not be empty")
)

💻 Файл: internal/service/user_service.go

package service

import (
    "context"
    "fmt"
    "strings"

    "go-testing/internal/user"
    "github.com/google/uuid"
)

// UserService — бизнес-логика (её будем тестировать!)
type UserService struct {
    repo user.Repository
}

func NewUserService(repo user.Repository) *UserService {
    return &UserService{repo: repo}
}

// Register — регистрация пользователя
func (s *UserService) Register(ctx context.Context, email, name string) (*user.User, error) {
    // Валидация
    if strings.TrimSpace(email) == "" {
        return nil, user.ErrEmptyEmail
    }
    if !strings.Contains(email, "@") {
        return nil, fmt.Errorf("invalid email format")
    }
    if strings.TrimSpace(name) == "" {
        return nil, user.ErrEmptyName
    }

    // Проверка на дубликат
    existing, err := s.repo.GetByEmail(ctx, email)
    if err != nil && !errors.Is(err, user.ErrNotFound) {
        return nil, fmt.Errorf("check email: %w", err)
    }
    if existing != nil {
        return nil, user.ErrAlreadyExists
    }

    // Создаём пользователя
    u := &user.User{
        ID:    uuid.New().String(),
        Email: email,
        Name:  name,
    }

    if err := s.repo.Create(ctx, u); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }

    return u, nil
}

// GetUser — получение пользователя
func (s *UserService) GetUser(ctx context.Context, id string) (*user.User, error) {
    if id == "" {
        return nil, fmt.Errorf("id must not be empty")
    }
    return s.repo.GetByID(ctx, id)
}

// IsValidEmail — чистая функция (легко тестировать)
func IsValidEmail(email string) bool {
    return strings.Contains(email, "@") && len(email) > 5
}

import "errors"

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

# Генерируем мок для интерфейса user.Repository
mockgen -source=internal/user/user.go \
  -destination=mocks/mock_user_repository.go \
  -package=mocks

# Или по import-пути:
# mockgen go-testing/internal/user Repository > mocks/mock_user_repository.go

💻 Файл: internal/service/user_service_test.go

package service_test

import (
    "context"
    "errors"
    "testing"

    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "go-testing/internal/service"
    "go-testing/internal/user"
    "go-testing/mocks"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  1. ТЕСТ: ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ                     ║
// ╚══════════════════════════════════════════════════════════╝

// TestIsValidEmail — тест чистой функции (без моков)
func TestIsValidEmail(t *testing.T) {
    // Table-driven test — идиоматичный паттерн Go
    tests := []struct {
        name     string // Имя тест-кейса
        email    string // Входные данные
        expected bool   // Ожидаемый результат
    }{
        {"valid email", "alice@example.com", true},
        {"valid with plus", "alice+tag@example.com", true},
        {"no at sign", "aliceexample.com", false},
        {"empty string", "", false},
        {"short email", "a@b", false}, // len = 3, меньше 5
        {"only at", "@", false},
    }

    for _, tt := range tests {
        // t.Run создаёт подтест (видно в выводе)
        t.Run(tt.name, func(t *testing.T) {
            result := service.IsValidEmail(tt.email)
            // testify/assert — мягкая проверка (тест продолжается)
            assert.Equal(t, tt.expected, result,
                "IsValidEmail(%q) should be %v", tt.email, tt.expected)
        })
    }
}

// ╔══════════════════════════════════════════════════════════╗
// ║  2. ТЕСТ: РЕГИСТРАЦИЯ С МОКАМИ                         ║
// ╚══════════════════════════════════════════════════════════╝

func TestUserService_Register_Success(t *testing.T) {
    // Arrange — подготавливаем моки
    ctrl := gomock.NewController(t)
    defer ctrl.Finish() // Проверяет, что ВСЕ ожидания выполнены

    mockRepo := mocks.NewMockRepository(ctrl)
    svc := service.NewUserService(mockRepo)
    ctx := context.Background()

    // Настраиваем ожидания:
    // 1. Проверка email — пользователь не найден
    mockRepo.EXPECT().
        GetByEmail(ctx, "alice@example.com").
        Return(nil, user.ErrNotFound) // Нет такого — можно создавать

    // 2. Создание пользователя
    mockRepo.EXPECT().
        Create(ctx, gomock.Any()). // gomock.Any() — любой аргумент
        DoAndReturn(func(ctx context.Context, u *user.User) error {
            // Проверяем, что поля заполнены
            assert.Equal(t, "alice@example.com", u.Email)
            assert.Equal(t, "Alice", u.Name)
            assert.NotEmpty(t, u.ID)
            return nil
        })

    // Act — выполняем тестируемый метод
    u, err := svc.Register(ctx, "alice@example.com", "Alice")

    // Assert — проверяем результат
    require.NoError(t, err) // Жёсткая проверка (если ошибка — тест прерывается)
    assert.NotNil(t, u)
    assert.Equal(t, "alice@example.com", u.Email)
    assert.Equal(t, "Alice", u.Name)
    assert.NotEmpty(t, u.ID)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  3. ТЕСТ: ОШИБКА ВАЛИДАЦИИ                             ║
// ╚══════════════════════════════════════════════════════════╝

func TestUserService_Register_ValidationErrors(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    svc := service.NewUserService(mockRepo)
    ctx := context.Background()

    tests := []struct {
        name      string
        email     string
        userName  string
        expectErr error
    }{
        {"empty email", "", "Alice", user.ErrEmptyEmail},
        {"empty name", "alice@example.com", "", user.ErrEmptyName},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Для валидационных ошибок мок не должен вызываться
            // Если вызовется — gomock упадёт (нет ожиданий)

            u, err := svc.Register(ctx, tt.email, tt.userName)

            assert.Error(t, err)
            assert.True(t, errors.Is(err, tt.expectErr),
                "expected %v, got %v", tt.expectErr, err)
            assert.Nil(t, u)
        })
    }
}

// ╔══════════════════════════════════════════════════════════╗
// ║  4. ТЕСТ: ДУБЛИКАТ EMAIL                               ║
// ╚══════════════════════════════════════════════════════════╝

func TestUserService_Register_DuplicateEmail(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    svc := service.NewUserService(mockRepo)
    ctx := context.Background()

    existingUser := &user.User{
        ID:    "existing-id",
        Email: "alice@example.com",
        Name:  "Existing Alice",
    }

    // Ожидаем: проверка email — пользователь НАЙДЕН
    mockRepo.EXPECT().
        GetByEmail(ctx, "alice@example.com").
        Return(existingUser, nil)

    u, err := svc.Register(ctx, "alice@example.com", "Alice")

    assert.Error(t, err)
    assert.True(t, errors.Is(err, user.ErrAlreadyExists))
    assert.Nil(t, u)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  5. ТЕСТ: ОШИБКА РЕПОЗИТОРИЯ                           ║
// ╚══════════════════════════════════════════════════════════╝

func TestUserService_Register_RepositoryError(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    svc := service.NewUserService(mockRepo)
    ctx := context.Background()

    dbError := errors.New("database connection lost")

    // Проверка email — не найден
    mockRepo.EXPECT().
        GetByEmail(ctx, "alice@example.com").
        Return(nil, user.ErrNotFound)

    // Создание — ОШИБКА БД
    mockRepo.EXPECT().
        Create(ctx, gomock.Any()).
        Return(dbError)

    u, err := svc.Register(ctx, "alice@example.com", "Alice")

    assert.Error(t, err)
    assert.Contains(t, err.Error(), "create user")
    assert.Contains(t, err.Error(), "database connection lost")
    assert.Nil(t, u)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  6. ТЕСТ: GetUser                                       ║
// ╚══════════════════════════════════════════════════════════╝

func TestUserService_GetUser(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockRepository(ctrl)
    svc := service.NewUserService(mockRepo)
    ctx := context.Background()

    t.Run("success", func(t *testing.T) {
        expected := &user.User{ID: "123", Email: "alice@example.com", Name: "Alice"}

        mockRepo.EXPECT().
            GetByID(ctx, "123").
            Return(expected, nil)

        u, err := svc.GetUser(ctx, "123")
        require.NoError(t, err)
        assert.Equal(t, expected, u)
    })

    t.Run("not found", func(t *testing.T) {
        mockRepo.EXPECT().
            GetByID(ctx, "999").
            Return(nil, user.ErrNotFound)

        u, err := svc.GetUser(ctx, "999")
        assert.Error(t, err)
        assert.True(t, errors.Is(err, user.ErrNotFound))
        assert.Nil(t, u)
    })

    t.Run("empty id", func(t *testing.T) {
        u, err := svc.GetUser(ctx, "")
        assert.Error(t, err)
        assert.Nil(t, u)
    })
}

💻 Файл: internal/service/user_service_httptest_test.go

package service_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  ТЕСТ HTTP-ОБРАБОТЧИКА (без поднятия сервера!)        ║
// ╚══════════════════════════════════════════════════════════╝

// Пример хендлера для тестирования
func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func TestHealthHandler(t *testing.T) {
    // Создаём фейковый HTTP-запрос
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    req.Header.Set("X-Request-ID", "test-123")

    // Создаём фейковый ResponseWriter (захватывает ответ)
    rec := httptest.NewRecorder()

    // Вызываем хендлер
    healthHandler(rec, req)

    // Проверяем ответ
    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))

    var body map[string]string
    err := json.NewDecoder(rec.Body).Decode(&body)
    require.NoError(t, err)
    assert.Equal(t, "ok", body["status"])
}

🚀 Запуск тестов

# Запуск всех тестов
go test ./...

# Запуск тестов с детальным выводом
go test -v ./...

# Запуск тестов конкретного пакета
go test -v ./internal/service/

# Запуск конкретного теста
go test -v -run TestIsValidEmail ./internal/service/

# Запуск конкретного подтеста
go test -v -run TestUserService_Register_Success ./internal/service/

# Покрытие кода
go test -cover ./...

# Покрытие с детальным профилем
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out  # Открыть в браузере

# Покрытие по функциям
go tool cover -func=coverage.out

# Гонка данных (ОБЯЗАТЕЛЬНО!)
go test -race ./...

📊 assert vs require (testify)

ФункцияПоведение при ошибкеКогда использовать
assert.Equal(t, a, b)Тест ПРОДОЛЖАЕТСЯНесколько независимых проверок
require.Equal(t, a, b)Тест ПРЕРЫВАЕТСЯБез этого результата дальнейшие проверки бессмысленны
assert.NoError(t, err)ПродолжаетсяОшибка не критична для следующих проверок
require.NoError(t, err)ПрерываетсяЕсли err != nil, дальнейшие проверки приведут к панике

📊 Библиотеки для тестирования в Go

БиблиотекаАналог в JSНазначение
testing (std)jest runnerЗапуск тестов, бенчмарки
testify/assertexpect().toBe()Проверки (assertions)
testify/requireassert + остановкаЖёсткие проверки
gomockjest.mock()Моки интерфейсов
httptest (std)supertestТестирование HTTP
testcontainers-gotestcontainersИнтеграционные тесты (урок 22)
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →