Какие подходы используются для тестирования кода, взаимодействующего с базой данных?

Ответ

Существует три основных подхода для тестирования кода с зависимостью от БД, каждый со своими плюсами и минусами:

1. Использование реальной БД в Docker (Testcontainers)

Это наиболее надежный подход для интеграционного тестирования. Библиотека testcontainers-go программно запускает Docker-контейнер с нужной БД (PostgreSQL, MySQL и т.д.) для каждого тестового набора.

  • Плюсы:
    • Максимальный реализм: Тесты выполняются на той же версии и типе БД, что и в продакшене.
    • Полная изоляция: Каждый тест (или пакет тестов) получает чистую, изолированную БД.
  • Минусы:
    • Медленно: Запуск контейнера занимает время.
    • Зависимость: Требуется установленный и запущенный Docker.
// Пример с testcontainers-go
func TestUserRepository_WithPostgres(t *testing.T) {
    ctx := context.Background()
    req := testcontainers.ContainerRequest{
        Image:        "postgres:13-alpine",
        ExposedPorts: []string{"5432/tcp"},
        Env: map[string]string{"POSTGRES_PASSWORD": "password"},
    }
    pgContainer, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{...})
    defer pgContainer.Terminate(ctx)

    // ... получаем connection string и пишем тесты ...
}

2. Использование Mock-объектов (Моки)

Этот подход используется для unit-тестирования. Вы создаете интерфейс для вашего слоя доступа к данным (например, UserRepository) и в тестах подменяете его mock-реализацией, которая имитирует поведение БД.

  • Плюсы:
    • Очень быстро: Никаких внешних зависимостей, тесты выполняются в памяти.
    • Полный контроль: Легко имитировать любые сценарии, включая ошибки БД.
  • Минусы:
    • Не тестирует SQL: Вы не проверяете корректность самих SQL-запросов и их совместимость с БД.
    • Хрупкость: Моки могут устареть, если реальная логика работы с БД изменится.
// Пример с testify/mock
type MockUserRepository struct {
    mock.Mock
}

func (m *MockUserRepository) GetUserByID(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}

func TestUserService(t *testing.T) {
    mockRepo := new(MockUserRepository)
    user := &User{ID: 1, Name: "Test"}
    // Настраиваем мок: при вызове GetUserByID(1) вернуть user и nil-ошибку
    mockRepo.On("GetUserByID", 1).Return(user, nil)

    service := NewUserService(mockRepo)
    // ... тестируем логику сервиса ...
}

3. Использование In-memory БД (например, SQLite)

Это компромиссный вариант. Для тестов используется легковесная, встраиваемая БД, которая работает в памяти.

  • Плюсы:
    • Быстро: Быстрее, чем Docker, не требует внешних зависимостей.
    • Тестирует SQL: Выполняются реальные SQL-запросы.
  • Минусы:
    • Несоответствие диалектов: SQL-диалект SQLite может отличаться от вашей основной БД (например, PostgreSQL), что может привести к тестам, проходящим локально, но падающим в продакшене.

Лучшие практики:

  • Комбинируйте подходы: Используйте моки для быстрых юнит-тестов бизнес-логики и testcontainers для интеграционных тестов, проверяющих реальное взаимодействие с БД.
  • Слой доступа к данным: Выделяйте всю работу с БД в отдельный слой (Repository Pattern), который легко мокировать.
  • Транзакции: Оборачивайте каждый интеграционный тест в транзакцию с последующим откатом (ROLLBACK), чтобы тесты не влияли друг на друга.