Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 23 из 25 ~18 мин +50 XP

Тесты: mocks и тестовая база

Тесты mongodb go-кода распадаются на два уровня: фейк вместо базы, который гоняется за миллисекунды, и живая тестовая база, которая ловит то, что подделка не умеет. В этом уроке соберём оба: mongo mock как написанная руками реализация репозитория, хелпер подключения к mongod с пропуском тестов без стенда, очистку через Drop и проверку дубликатов на настоящем сервере. Заодно выясним, куда пропал mtest из первой версии драйвера, зачем в строке подключения replicaSet=rs0 и в какой момент фейк начинает врать. К концу у вас будет каркас тестового пакета, зелёный на ноутбуке и честный в CI.

Содержание урока · 10 разделов

Сервис регистрации прошёл ревью и неделю держался в проде, пока два параллельных запроса не вставили один email. Тестов у кода не было вовсе: разработчик проверял руками через curl, а гонка и дубликат в ручную проверку не попадали никогда. История типовая, и лечится она не дисциплиной, а каркасом: код с драйвером тестируется так же, как любой другой Go-код, — только вместо моков сетевых клиентов здесь подменяется репозиторий, а вместо моков базы нужна настоящая MongoDB на минимальном наборе документов. Двух уровней хватает на всё: подделка проверяет логику веток мгновенно и без внешних зависимостей, живая база подтверждает контракты драйвера — коды ошибок, уникальные индексы, транзакции. В этом уроке мы соберём оба уровня, свяжем их с интерфейсом репозитория и разберём места, где тесты молча перестают отражать прод. По пути зафиксируем границы: что тестировать первым, сколько стоит живой коннект и почему один и тот же сценарий проверяется дважды — быстро и честно.

Два уровня и судьба mtest#

Начнём с карты. Первый уровень — юнит-тесты сервиса: репозиторий подменяется фейком, база не участвует, пакет гоняется за миллисекунды. Здесь проверяется логика: ветка «пользователь найден» против «не найден», повторная регистрация, превращение ошибки репозитория в доменное решение. Второй уровень — интеграционные тесты на живом mongod: только настоящая база вернёт код 11000 на дубликате, выполнит транзакцию и отсчитает TTL-индекс. Ошибку из первого уровня подделать легко, поведение второго — невозможно, поэтому ключевые ветки дублируются на обоих уровнях. Цена асимметрична, и это подсказывает порядок: фейки пишутся и меняются за минуты, а живой стенд собирается один раз и потом только обслуживается, поэтому каждый интеграционный тест должен окупать себя — проверять контракт сервера, а не дублировать то, что уже прикрыто фейком. Теперь о mtest: в первой версии драйвера был пакет mtest — каркас интеграционных тестов с запуском replica set и проверкой топологии. Во второй версии его не перенесли: пакета mtest в дереве mongo-driver v2 нет, а собственные тестовые утилиты драйвера живут в internal-каталогах, которые язык запрещает импортировать. Это не дыра, а развилка: каркас из стандартной библиотеки testing плюс два помощника — написанный руками mongo mock и живой mongod на стенде. Оба помощника мы и построим.

Фейк репозитория: вторая реализация контракта

GO
type UserRepo interface {
        ByEmail(ctx context.Context, email string) (User, error)
        Create(ctx context.Context, u User) error
}

var (
        ErrNotFound   = errors.New("user not found")
        ErrEmailTaken = errors.New("email taken")
)

type fakeUserRepo struct{ users map[string]User }

func newFakeUserRepo() *fakeUserRepo {
        return &fakeUserRepo{users: make(map[string]User)}
}

func (f *fakeUserRepo) ByEmail(_ context.Context, email string) (User, error) {
        u, ok := f.users[email]
        if !ok {
                return User{}, ErrNotFound
        }
        return u, nil
}

func (f *fakeUserRepo) Create(_ context.Context, u User) error {
        if _, ok := f.users[u.Email]; ok {
                return ErrEmailTaken
        }
        f.users[u.Email] = u
        return nil
}

Интерфейс UserRepo описан на слое моделей, и mongo-реализация из урока про репозиторий удовлетворяет ему наравне с fakeUserRepo. Фейк хранит документы в map и возвращает те же доменные ошибки, что и живой репозиторий: ErrNotFound на промахе, ErrEmailTaken на дубликате. Сервис тестируется против интерфейса и не подозревает, какая реализация под ним стоит.

Живая тестовая база: подключение и пропуск#

Интеграционный тест начинается с подключения, и здесь спрятана ловушка второй версии: mongo.Connect не принимает контекст и не делает сетевых обращений — он проверяет только корректность опций и возвращается мгновенно, даже если mongod не поднялся. Доступность базы подтверждает Ping: зовите его с коротким дедлайном и на провале вызывайте t.Skip — тест честно помечается пропущенным, а не висит тридцать секунд сервер-селекшна и не падает красным без причины. Строку подключения берите с replicaSet=rs0: одиночный mongod, запущенный как реплика из одного нода, открывает транзакции из урока про сессии и Change Streams — всё то, что на standalone-сервере недоступно вовсе. Сама база — не продовая и не общая: заводите отдельное имя вроде shop_test, чтобы тесты не смешивались с данными мануалов. MongoDB для стенда поднимается в Docker одной командой из второго урока курса, и после этого хелпер newTestDB становится самой вызываемой функцией пакета.

Что проверять на каком уровне#

ПоведениеУровеньПочему
ветки сервиса: найден, не найден, занятфейкмиллисекунды, детерминизм, база не нужна
классификация доменных ошибокфейкфейк возвращает заготовленную ошибку, тест проверяет реакцию
дубликат по уникальному индексу, код 11000живая базакод ошибки порождает сервер, подделка его не повторит
транзакции и откатживая база с rs0транзакции требуют replica set даже из одного нода
агрегации и декодирование типовживая базанакопители и BSON-типы считаются сервером, $avg приезжает float64

Очистка и изоляция: Drop как ритуал#

Тест, который на что-то опирается с прошлого прогона, — бомба замедленного действия: сегодня коллекция пустая и ветка срабатывает, завтра в ней остались документы от упавшего теста, и ассерты начинают врать. Лечится ритуалом очистки: перед прогоном и после него тестовая база удаляется через Drop — метод есть и у коллекции, и у базы целиком, и на тестовом имени он безопасен. Держите Drop в хелпере: база удаляется до выдачи её тесту и в отложенной очистке, и никакой порядок прогонов не влияет на результат. Начинать тест лучше с чистого листа, а не с накопленных остатков: так сценарий описывается самодостаточно — вставил то, что нужно, проверил, отдал. С изоляцией между тестами внутри пакета сложнее. t.Parallel разрешает горутинам гонять тесты одновременно, и два теста на одной коллекции начинают конкурировать за одни и те же документы: один стирает то, что второй только вставил. Выходов три: не включать параллельность для тестов с общими данными, разводить тесты по разным коллекциям или делать значения уникальными — email с добавкой времени через ObjectID и дату делает дубликат невозможным. Для тестов этого курса проще всего первые два пути.

Контракт фейка: где подделка врёт#

Фейк — слепок контракта репозитория, и честен он ровно настолько, насколько контракт тонкий. Проверить ветку «email занят» фейк может, а вот повторить поведение драйвера — нет: код 11000 и структуру WriteException порождает сервер, ErrNoDocuments отдаёт FindOne по пустой выборке, декодирование приводит $avg к float64 даже для целых входов, bson-теги решают, какие поля доедут до документа. Подделка, в которую вы нальёте документы, невозможные в проде, сделает тесты лживыми: сервис проверен на данных, которые база никогда не приняла бы. Поэтому пара держится двумя ногами: фейк отвечает за скорость и ветки логики, а на живой базе сидит короткий интеграционный тест на минимальном наборе из двух-трёх документов — вставили, прочитали, нарушили уникальность, удалили. Такой тест стоит миллисекунды после первого коннекта и ловит расползание контракта. Ошибки в обоих мирах классифицируются одинаково — предикатами драйвера из урока про ошибки, без разбора строк сообщений.

Интеграционный тест дубликата на живой базе

GO
func newTestDB(t *testing.T) (*mongo.Database, func()) {
        t.Helper()

        client, err := mongo.Connect(options.Client().
                ApplyURI("mongodb://localhost:27017/?replicaSet=rs0"))
        if err != nil {
                t.Fatalf("connect: %v", err)
        }
        ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
        defer cancel()
        if err := client.Ping(ctx, nil); err != nil {
                _ = client.Disconnect(context.Background())
                t.Skipf("mongod недоступен: %v", err)
        }
        db := client.Database("shop_test")
        cleanup := func() {
                _ = db.Drop(context.Background())
                _ = client.Disconnect(context.Background())
        }
        return db, cleanup
}

Connect в v2 возвращает ошибку только на плохих опциях, поэтому доступность подтверждает Ping с дедлайном в три секунды. Нет стенда — тест пропускается через t.Skip, а не падает. Cleanup удаляет тестовую базу Drop и разрывает соединение; хелпер помечен t.Helper, чтобы трассировки падений указывали на строки самого теста.

Каркас пакета: TestMain и экономия коннектов#

Хелпер из прошлого раздела создаёт клиент на каждый тест. Для десятка тестов это нормально, но каждый новый клиент — новый пул соединений и новые рукопожатия, и на сотне тестов пакет начинает жить своей жизнью. Экономнее завести один клиент на весь пакет через TestMain: в нём выполняется подключение, вызов m.Run запускает все тесты, а после — Disconnect. Тесты получают клиент и базу через переменные пакета, Drop база по-прежнему делает в cleanup каждого теста, чтобы прогон начинался с чистого листа. Вторая ось — фильтры запуска: интеграционным тестам даётся специальный префикс в имени вроде TestIntegration, и на машинах без стенда их отрезают флагом -run, а юнит-тесты на фейках гоняются всегда и везде. Стандартный testing.Short тоже подходит: в начале интеграционного теста проверяйте testing.Short() и пропускайте тяжёлый сценарий. Флаг -count полезен в обратную сторону: прогон пакета несколько раз подряд быстро вскрывает тесты, которые полагаются на порядок или на остатки данных. Правило простое: юнит-уровень обязан быть мгновенным и безусловным, интеграционный — дешёвым для отключения, но честным, когда включён.

Правила тестов с драйвером одним списком#

  • Логика веток — на фейке: миллисекунды, детерминизм, никаких внешних зависимостей.
  • Коды сервера, уникальные индексы, транзакции, агрегации — только на живой базе.
  • Connect не проверяет доступность: Ping с коротким дедлайном и t.Skip без стенда.
  • replicaSet=rs0 в URI тестового стенда: транзакции и Change Streams требуют реплику.
  • Drop тестовой базы до и после прогона; порядок тестов не должен иметь значения.
  • Фейк и база дублируют ключевые ветки: расползание контракта ловится на двух документах.

Проверка: где тестировать дубликат

Где проверять ветку обработки duplicate key на вставке пользователя?

Проверка: зачем Ping в хелпере

Почему хелпер тестовой базы зовёт Ping и делает t.Skip, если mongod не поднялся?

Что напечатает тест фейка?

Прочитайте код и выберите, что он выведет:

repo := newFakeUserRepo()
_ = repo.Create(ctx, User{Email: "a@b.c"})
err := repo.Create(ctx, User{Email: "a@b.c"})

fmt.Println(err == nil, errors.Is(err, ErrEmailTaken))

Соберите хелпер тестовой базы

Восстановите три вызова: подключение, проверка доступности и очистка.

client, err := mongo.(options.Client(). ApplyURI("mongodb://localhost:27017/?replicaSet=rs0")) if err != nil { t.Fatalf("connect: %v", err) } if err := client.(ctx, nil); err != nil { t.Skipf("mongod недоступен: %v", err) } cleanup := func() { _ = db.(context.Background()) _ = client.Disconnect(context.Background()) }

Соберите интеграционный тест дубликата

Расставьте строки теста по порядку: стенд, вставка, вторая попытка, проверка кода.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: тесты с драйвером

Термин

Фейк репозитория

нажмите, чтобы перевернуть
Определение

Вторая реализация интерфейса со слоя моделей: map вместо коллекции, доменные ошибки вместо кодов сервера. Гоняется за миллисекунды и проверяет ветки логики сервиса.

1 / 6

Каркас собран: интерфейс из урока про слой репозитория получил вторую реализацию — фейк на map, а живая база подтверждает драйверные контракты, которые подделка не умеет. Транзакционные тесты потребуют реплику — механика сессий описана в уроке про транзакции, а коды ошибок, которые тест проверяет на дубликате, классифицированы в уроке про ошибки драйвера теми же предикатами, что и в проде. Хорошая последовательность внедрения: сначала накройте фейками ветки сервиса — это час работы и мгновенный регресс-щит, затем добавьте один интеграционный тест на дубликат и постепенно расширяйте набор. Песочница сайта не исполняет драйвер, поэтому стенд соберите на своей машине: mongod в Docker с именем реплики rs0, хелпер из урока, два документа — и тестовый пакет перестанет быть лотереей, какой была ручная проверка через curl.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Тесты с драйвером делятся на два уровня: фейк репозитория для логики и живая база для серверных контрактов.
  • mtest из v1 в v2 не перенесён: каркасом служит стандартный testing плюс свои хелперы.
  • Connect в v2 не делает сетевых обращений: доступность подтверждает Ping с дедлайном, без стенда — t.Skip.
  • replicaSet=rs0 в URI стенда открывает транзакции и Change Streams даже на одном ноде.
  • Тестовая база стирается через Drop до и после прогона; порядок тестов не должен влиять на результат.
  • Фейк возвращает доменные ошибки, живой тест на двух документах дублирует ключевые ветки.
  • Один клиент на пакет через TestMain экономит пулы; -run и testing.Short отрезают интеграционные тесты.
  • Фейк не повторяет поведение драйвера: код 11000, ErrNoDocuments и $avg проверяются только на живой базе.

Домашнее задание

Соберите каркас из урока: опишите интерфейс репозитория, напишите фейк на map и два юнит-теста сервиса — успешную регистрацию и повторный email. Затем поднимите mongod с именем реплики rs0, добавьте хелпер newTestDB и интеграционный тест дубликата: две одинаковые вставки, IsDuplicateKeyError, cleanup с Drop. Запустите пакет дважды — со стоящим стендом и без него — и убедитесь, что в первом случае всё зелёное, а во втором интеграционный тест помечен пропущенным за секунды, а не красным за полминуты. Напоследок прогоните пакет с флагом -count 3 и проверьте, что результат не зависит от порядка и остатков данных.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · Тесты: mocks и тестовая база)

Вопросы и ответы по уроку

Нужна ли библиотека вроде mockery или gomock для фейка?

Нет. Репозиторий из курса — маленький интерфейс на два-три метода, и написанный руками фейк на map читается и правится быстрее, чем сгенерированный. Генераторы окупаются на десятках интерфейсов и разрастающихся контрактах; если всё же пойдёте по этому пути, доменные ошибки всё равно придётся заготливать вручную — они часть контракта, а не механики.

Почему не использовать продовую базу для интеграционных тестов?

Тесты стирают и создают данные, ломают индексы нарочно и заполняют коллекции мусором — на проде это катастрофа. Отдельная тестовая база с Drop до и после прогона бесплатна и делает прогон детерминированным: тест не зависит от того, что кто-то записал в общую коллекцию вчера. Отдельно стоит стоимость: тесты, которые хочется гонять параллельно, на проде пришлось бы выстраивать по очереди, чтобы не мешать живому трафику.

Как тестировать код, который зависит от времени: TTL, даты, сроки?

Не завязывайте тест на реальные паузы. TTL-индекс проверяется полем со сроком в прошлом — очистка сработает на ближайшем проходе сервера, а ждать минуты не нужно. Для остального время инжектируется в сервис как зависимость, и фейк подставляет фиксированный момент — база при этом проверяет только сохранение значения, не его течение.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

zerolog25 уроков~12 чпрактика25 уроков уже доступны

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос