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

Ответ

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

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), чтобы тесты не влияли друг на друга.

Ответ 18+ 🔞

А, ну вот, опять про тестирование с этой ёбнутой базой данных! Слушай, тут, блядь, как обычно, три пути, и все они ведут в пизду, но по-разному. Выбирай, на чём подорваться.

1. Настоящая база, но в банке (Testcontainers)

Представь, ты для каждого теста, как дурак, запускаешь целый докер-контейнер с постгресом. Это как приглашать на чай экскаватор — мощно, но дохуя мороки.

  • Чем хорошо, блядь:
    • Всё по-чесноку: Тестируешься на той же самой хуйне, что и в бою. Никаких сюрпризов.
    • Чистота, ёпта: Каждый тест начинает с девственной базы. Никаких чужих соплей.
  • Чем пиздецово:
    • Медленно, как черепаха в сиропе: Ждать, пока контейнер встанет, — это отдельная медитация.
    • Зависишь от докера: Нет докера — нихуя не протестируешь.
// Вот так это выглядит, сука
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) // После теста — в утиль

    // ... дальше подключаешься и тестишь как с настоящей...
}

2. Показательные выступления (Моки)

А это уже для юнит-тестов, где надо проверить логику, а не реальную базу. Ты создаёшь интерфейс для своего репозитория, а потом в тестах подсовываешь муляж, который делает вид, что он база.

  • Плюсы, ёбана:
    • Быстрее ветра: Всё летает в оперативке, никаких тормозов.
    • Король положения: Заставишь мок вернуть любую хуйню или упасть с ошибкой — полный контроль.
  • Минусы, пиздец:
    • SQL не проверяется вообще: Ты можешь написать в коде SELECT * FROM users WHERE хуй = 1, и мок это проглотит, а в продакшене всё ебнется.
    • Хрупкое говно: Изменил сигнатуру метода в реальном коде — иди и переписывай все моки, блядь.
// Пример с 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"}
    // Настраиваем спектакль: "Когда спросят юзера с ID=1, верни вот этого"
    mockRepo.On("GetUserByID", 1).Return(user, nil)

    service := NewUserService(mockRepo)
    // ... и тестируем сервис, который думает, что ходит в базу...
}

3. База-пустышка (In-memory, типа SQLite)

Компромисс, ёпта. Берёшь SQLite, запускаешь её в памяти и гоняешь на ней тесты. Быстро и запросы настоящие.

  • Чем прикольно:
    • Шустро: Быстрее контейнеров, не надо ждать.
    • SQL работает: Запросы-то выполняются, синтаксис проверяется.
  • Чем опасно:
    • Диалекты, блядь: SQLite — это не PostgreSQL. То, что работает на SQLite, может нахуй послать тебя на продакшене с его постгресом. Идеальный способ получить ложное чувство безопасности.

Так как же жить-то, ёпта?

  • Двумя руками, блядь: Моки — для быстрых юнит-тестов твоей бизнес-логики (сервисов, хендлеров). Testcontainers — для медленных, но честных интеграционных тестов, которые проверяют, что твой репозиторий вообще умеет разговаривать с реальной базой.
  • Дели на слои: Всю работу с базой засунь в отдельный слой (репозиторий). Его будет легко замокать для всего, что выше.
  • Убирай за собой: В интеграционных тестах оборачивай всё в транзакцию и делай ROLLBACK в конце. Чтобы тесты не насрали друг другу в базу и не начали ругаться.