Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Мой подход к тестированию кода с зависимостью от базы данных
Тестирование кода, работающего с базой данных, требует особого внимания к изоляции тестов, воспроизводимости результатов и скорости выполнения. Вот комплексный подход, который я применяю в своих проектах.
Уровни тестирования с участием БД
Я использую многоуровневую стратегию тестирования:
- Модульные тесты (Unit tests) - изолированное тестирование отдельных компонентов
- Интеграционные тесты (Integration tests) - тестирование взаимодействия с реальной БД
- Контрактные тесты (Contract tests) - проверка соответствия интерфейсов
Ключевые инструменты и техники
1. Использование тестовых двойников (Test Doubles)
Для модульных тестов я заменяю реальные зависимости на заглушки. В Go это часто реализуется через интерфейсы:
// Определяем интерфейс для работы с БД
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, user *User) error
}
// Реальная реализация
type PostgreSQLUserRepo struct {
db *sql.DB
}
// Mock реализация для тестов
type MockUserRepo struct {
users map[int]*User
mu sync.RWMutex
}
func (m *MockUserRepo) FindByID(ctx context.Context, id int) (*User, error) {
m.mu.RLock()
defer m.mu.RUnlock()
user, exists := m.users[id]
if !exists {
return nil, sql.ErrNoRows
}
return user, nil
}
2. Интеграционные тесты с тестовой базой данных
Для интеграционных тестов я использую несколько подходов:
Docker-контейнеры для изоляции тестов:
func TestUserRepository_Integration(t *testing.T) {
// Запускаем тестовую БД в Docker
pool, resource := startTestPostgreSQL(t)
defer pool.Purge(resource)
// Подключаемся к тестовой БД
db, err := connectToTestDB(resource)
require.NoError(t, err)
// Создаём схему и тестовые данные
setupTestSchema(t, db)
// Выполняем тесты
repo := NewUserRepository(db)
user, err := repo.FindByID(context.Background(), 1)
assert.NoError(t, err)
assert.NotNil(t, user)
}
Транзакционные тесты:
func TestWithTransaction(t *testing.T) {
db := getTestDB()
tx, err := db.Begin()
require.NoError(t, err)
// Все изменения в рамках транзакции
repo := NewRepository(tx)
defer func() {
// Откатываем транзакцию, чтобы не влиять на другие тесты
tx.Rollback()
}()
// Выполняем тестовые операции
err = repo.Save(&User{Name: "Test"})
assert.NoError(t, err)
}
3. Миграции и фикстуры
Для управления схемой БД в тестах я использую:
- Миграции (golang-migrate, goose) для создания структуры БД
- Фикстуры (testcontainers-go) для предзагрузки тестовых данных
- Фабрики данных (go-factory) для генерации реалистичных тестовых объектов
Практические паттерны, которые я применяю
Паттерн "Repository"
Это позволяет абстрагировать работу с БД и легко подменять реализацию:
// Тест бизнес-логики без реальной БД
func TestUserService_CreateUser(t *testing.T) {
mockRepo := new(MockUserRepository)
service := NewUserService(mockRepo)
// Настраиваем ожидания мока
mockRepo.On("Save", mock.Anything, mock.Anything).
Return(nil).
Once()
err := service.CreateUser(context.Background(), "test@example.com")
assert.NoError(t, err)
mockRepo.AssertExpectations(t)
}
Паттерн "Test Suite"
Организация тестов в сьютах с общей настройкой:
type UserRepoTestSuite struct {
suite.Suite
db *sql.DB
repo UserRepository
container testcontainers.Container
}
func (s *UserRepoTestSuite) SetupSuite() {
// Запускаем контейнер с БД один раз для всего сьюта
s.container = startTestDBContainer()
s.db = connectToContainer(s.container)
s.repo = NewUserRepository(s.db)
}
func (s *UserRepoTestSuite) TearDownSuite() {
s.db.Close()
s.container.Terminate()
}
func (s *UserRepoTestSuite) TestFindByID() {
user, err := s.repo.FindByID(context.Background(), 1)
s.NoError(err)
s.NotNil(user)
}
Специфические подходы для Go-проектов
- sqlmock для тестирования SQL-запросов:
func TestGetUser(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer db.Close()
// Ожидаем конкретный SQLWe can use sqlmock to expect a specific SQL query
rows := sqlmock.NewRows([]string{"id", "name"}).
AddRow(1, "John Doe")
mock.ExpectQuery("SELECT (.+) FROM users WHERE id = ?").
WithArgs(1).
WillReturnRows(rows)
repo := NewUserRepository(db)
user, err := repo.FindByID(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, "John Doe", user.Name)
assert.NoError(t, mock.ExpectationsWereMet())
}
- Изоляция тестов через схемы (для PostgreSQL):
// Создаём уникальную схему для каждого теста
func createTestSchema(db *sql.DB) string {
schema := fmt.Sprintf("test_%d", time.Now().UnixNano())
_, err := db.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
if err != nil {
panic(err)
}
return schema
}
- Параллельное выполнение тестов с раздельными БД:
func TestParallel(t *testing.T) {
t.Parallel()
// Каждый параллельный тест получает свою БД
db := getUniqueTestDB(t)
// ... тестовый код
}
Best practices из моего опыта
-
Скорость тестов:
- Использую in-memory БД (SQLite) для быстрых тестов
- Реальные БД в Docker — только для интеграционных тестов
- Кеширую Docker. We can use Docker layer caching for faster container startup
-
Надёжность:
- Всегда очищаю состояние после тестов
- Использую транзакции для изоляции
- Добавляю retry-логику для флакки тестов
-
Поддержка:
- Документирую, как запускать тесты с БД
- Использую makefile или скрипты для настройки тестового окружения
- Интегрирую в CI/CD с поэтапным выполнением (сначала unit, потом integration)
-
Покрытие:
- Тестирую happy path и edge cases
- Проверяю ошибки БД (таймауты, коннекшн рефьюзэл)
- Тестирую транзакционное поведение (роллбэки, deadlocks)
Этот подход позволяет мне поддерживать качество кода, работающего с БД, обеспечивая при этом высокую скорость разработки и надёжность приложений в production.