Содержание урока · 10 разделов
- Два уровня и судьба mtest
- Живая тестовая база: подключение и пропуск
- Что проверять на каком уровне
- Очистка и изоляция: Drop как ритуал
- Контракт фейка: где подделка врёт
- Каркас пакета: TestMain и экономия коннектов
- Правила тестов с драйвером одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервис регистрации прошёл ревью и неделю держался в проде, пока два параллельных запроса не вставили один email. Тестов у кода не было вовсе: разработчик проверял руками через curl, а гонка и дубликат в ручную проверку не попадали никогда. История типовая, и лечится она не дисциплиной, а каркасом: код с драйвером тестируется так же, как любой другой Go-код, — только вместо моков сетевых клиентов здесь подменяется репозиторий, а вместо моков базы нужна настоящая MongoDB на минимальном наборе документов. Двух уровней хватает на всё: подделка проверяет логику веток мгновенно и без внешних зависимостей, живая база подтверждает контракты драйвера — коды ошибок, уникальные индексы, транзакции. В этом уроке мы соберём оба уровня, свяжем их с интерфейсом репозитория и разберём места, где тесты молча перестают отражать прод. По пути зафиксируем границы: что тестировать первым, сколько стоит живой коннект и почему один и тот же сценарий проверяется дважды — быстро и честно.
Два уровня и судьба mtest#
Начнём с карты. Первый уровень — юнит-тесты сервиса: репозиторий подменяется фейком, база не участвует, пакет гоняется за миллисекунды. Здесь проверяется логика: ветка «пользователь найден» против «не найден», повторная регистрация, превращение ошибки репозитория в доменное решение. Второй уровень — интеграционные тесты на живом mongod: только настоящая база вернёт код 11000 на дубликате, выполнит транзакцию и отсчитает TTL-индекс. Ошибку из первого уровня подделать легко, поведение второго — невозможно, поэтому ключевые ветки дублируются на обоих уровнях. Цена асимметрична, и это подсказывает порядок: фейки пишутся и меняются за минуты, а живой стенд собирается один раз и потом только обслуживается, поэтому каждый интеграционный тест должен окупать себя — проверять контракт сервера, а не дублировать то, что уже прикрыто фейком. Теперь о mtest: в первой версии драйвера был пакет mtest — каркас интеграционных тестов с запуском replica set и проверкой топологии. Во второй версии его не перенесли: пакета mtest в дереве mongo-driver v2 нет, а собственные тестовые утилиты драйвера живут в internal-каталогах, которые язык запрещает импортировать. Это не дыра, а развилка: каркас из стандартной библиотеки testing плюс два помощника — написанный руками mongo mock и живой mongod на стенде. Оба помощника мы и построим.
Фейк репозитория: вторая реализация контракта
GOtype 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 и дату делает дубликат невозможным. Для тестов этого курса проще всего первые два пути.
Код использует WithTransaction, у каждого разработчика mongod поднят как реплика rs0 — тесты зелёные. В CI тот же mongod стоит standalone, и весь пакет падает с ошибкой «транзакции недоступны», хотя код ни разу не менялся. Если тестам нужны транзакции, то стенд обязан быть replica set даже из одного нода: имя реплики зашивается в URI хелпера, а стенд CI собирается тем же скриптом, что и у разработчиков. И обратное правило: не тащите транзакции туда, где хватает двух вставок, — иначе вырастите цену всего стенда ради одного теста.
Контракт фейка: где подделка врёт#
Фейк — слепок контракта репозитория, и честен он ровно настолько, насколько контракт тонкий. Проверить ветку «email занят» фейк может, а вот повторить поведение драйвера — нет: код 11000 и структуру WriteException порождает сервер, ErrNoDocuments отдаёт FindOne по пустой выборке, декодирование приводит $avg к float64 даже для целых входов, bson-теги решают, какие поля доедут до документа. Подделка, в которую вы нальёте документы, невозможные в проде, сделает тесты лживыми: сервис проверен на данных, которые база никогда не приняла бы. Поэтому пара держится двумя ногами: фейк отвечает за скорость и ветки логики, а на живой базе сидит короткий интеграционный тест на минимальном наборе из двух-трёх документов — вставили, прочитали, нарушили уникальность, удалили. Такой тест стоит миллисекунды после первого коннекта и ловит расползание контракта. Ошибки в обоих мирах классифицируются одинаково — предикатами драйвера из урока про ошибки, без разбора строк сообщений.
Интеграционный тест дубликата на живой базе
GOfunc 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 полезен в обратную сторону: прогон пакета несколько раз подряд быстро вскрывает тесты, которые полагаются на порядок или на остатки данных. Правило простое: юнит-уровень обязан быть мгновенным и безусловным, интеграционный — дешёвым для отключения, но честным, когда включён.
Валидацию email вынесли на слой схемы — документы без адреса база с валидацией из урока про JSON Schema отвергает. Фейк же принимает всё подряд, и юнит-тесты зелёные даже для невалидных пользователей. Если подделка не повторяет ограничений схемы, то ветку записи стоит повторить на живой базе — короткий интеграционный тест с заведомо плохим документом держит оба мира в согласии, а фейк помечайте в комментарии: что он не проверяет.
Правила тестов с драйвером одним списком#
- Логика веток — на фейке: миллисекунды, детерминизм, никаких внешних зависимостей.
- Коды сервера, уникальные индексы, транзакции, агрегации — только на живой базе.
- 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))Соберите хелпер тестовой базы
Восстановите три вызова: подключение, проверка доступности и очистка.
Соберите интеграционный тест дубликата
Расставьте строки теста по порядку: стенд, вставка, вторая попытка, проверка кода.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты с драйвером
Каркас собран: интерфейс из урока про слой репозитория получил вторую реализацию — фейк на 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 и проверьте, что результат не зависит от порядка и остатков данных.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужна ли библиотека вроде mockery или gomock для фейка?
Нет. Репозиторий из курса — маленький интерфейс на два-три метода, и написанный руками фейк на map читается и правится быстрее, чем сгенерированный. Генераторы окупаются на десятках интерфейсов и разрастающихся контрактах; если всё же пойдёте по этому пути, доменные ошибки всё равно придётся заготливать вручную — они часть контракта, а не механики.
Почему не использовать продовую базу для интеграционных тестов?
Тесты стирают и создают данные, ломают индексы нарочно и заполняют коллекции мусором — на проде это катастрофа. Отдельная тестовая база с Drop до и после прогона бесплатна и делает прогон детерминированным: тест не зависит от того, что кто-то записал в общую коллекцию вчера. Отдельно стоит стоимость: тесты, которые хочется гонять параллельно, на проде пришлось бы выстраивать по очереди, чтобы не мешать живому трафику.
Как тестировать код, который зависит от времени: TTL, даты, сроки?
Не завязывайте тест на реальные паузы. TTL-индекс проверяется полем со сроком в прошлом — очистка сработает на ближайшем проходе сервера, а ждать минуты не нужно. Для остального время инжектируется в сервис как зависимость, и фейк подставляет фиксированный момент — база при этом проверяет только сохранение значения, не его течение.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос