---
title: "Тесты: mocks и тестовая база"
description: "Тесты кода с mongo в Go: фейк репозитория без базы, живая тестовая база с Ping и Drop, replica set для транзакций и пропуск тестов без стенда."
course: "MongoDB в Go: официальный драйвер mongo-driver v2"
url: "https://golangmaster.ru/courses/mongo-driver/lessons/23-testy-mongo/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «MongoDB в Go: официальный драйвер mongo-driver v2», урок 23 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/mongo-driver/lessons/23-testy-mongo/*

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

Сервис регистрации прошёл ревью и неделю держался в проде, пока два параллельных запроса не вставили один 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 и дату делает дубликат невозможным. Для тестов этого курса проще всего первые два пути.

> **Живое место: зелёные на ноутбуке, красные в CI.** Код использует WithTransaction, у каждого разработчика mongod поднят как реплика rs0 — тесты зелёные. В CI тот же mongod стоит standalone, и весь пакет падает с ошибкой «транзакции недоступны», хотя код ни разу не менялся. Если тестам нужны транзакции, то стенд обязан быть replica set даже из одного нода: имя реплики зашивается в URI хелпера, а стенд CI собирается тем же скриптом, что и у разработчиков. И обратное правило: не тащите транзакции туда, где хватает двух вставок, — иначе вырастите цену всего стенда ради одного теста.

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

Фейк — слепок контракта репозитория, и честен он ровно настолько, насколько контракт тонкий. Проверить ветку «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 полезен в обратную сторону: прогон пакета несколько раз подряд быстро вскрывает тесты, которые полагаются на порядок или на остатки данных. Правило простое: юнит-уровень обязан быть мгновенным и безусловным, интеграционный — дешёвым для отключения, но честным, когда включён.

> **Живое место: фейк принял то, что база отвергла бы.** Валидацию email вынесли на слой схемы — документы без адреса база с валидацией из урока про JSON Schema отвергает. Фейк же принимает всё подряд, и юнит-тесты зелёные даже для невалидных пользователей. Если подделка не повторяет ограничений схемы, то ветку записи стоит повторить на живой базе — короткий интеграционный тест с заведомо плохим документом держит оба мира в согласии, а фейк помечайте в комментарии: что он не проверяет.

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

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

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

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

- Только в юнит-тесте на фейке: фейк вернёт ErrEmailTaken
- Только в браузерной консоли на продовой базе
- ✅ На живой базе: код 11000 порождает сервер, подделка его не повторит
- Нигде: драйвер сам обрабатывает дубликаты

Код 11000 и структуру WriteException выдаёт сервер при нарушении уникального индекса, а фейк возвращает лишь заготовленную ошибку. Живой тест с двумя одинаковыми вставками и IsDuplicateKeyError подтверждает ветку целиком, от драйвера до доменного решения.

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

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

- Ping ускоряет подключение за счёт повторных попыток
- ✅ Connect не делает сетевых обращений и не вернёт ошибку без базы: без Ping тест висел бы до сервер-селекшна
- Ping обязателен по контракту драйвера после Connect
- Без Ping база не создаст коллекции по запросу

В v2 Connect проверяет только опции и возвращается мгновенно, даже если сервер недоступен. Первым живое обращение делает Ping; с дедлайном он превращает отсутствие стенда в пропуск за секунды, а не в тридцать секунд ожидания сервер-селекшна.

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

```go
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))
```

**Что выведет программа?**

- true true
- ✅ false true
- false false
- true false

Первый Create сохраняет документ и возвращает nil, второй находит email в map фейка и возвращает ErrEmailTaken. err не nil, и errors.Is подтверждает доменную ошибку — ровно та пара значений, которую живой репозиторий отдаёт на дубликате.

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

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

```go
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())
}
```

Подсказка: Подключение делает Connect, доступность подтверждает Ping, а тестовая база стирается методом Drop.

Правильные ответы в пропусках: `Connect`, `Ping`, `Drop`

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

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

Правильный порядок строк:

1. `func TestIntegrationDuplicateEmail(t *testing.T) {`
2. `  db, cleanup := newTestDB(t)`
3. `  defer cleanup()`
4. `  users := db.Collection("users")`
5. `  doc := bson.D{{Key: "email", Value: "a@b.c"}}`
6. `  _, err := users.InsertOne(context.Background(), doc)`
7. `  if err != nil { t.Fatalf("insert: %v", err) }`
8. `  _, err = users.InsertOne(context.Background(), doc)`
9. `  if !mongo.IsDuplicateKeyError(err) {`
10. `    t.Fatalf("хотели duplicate key, получили: %v", err)`
11. `  }`
12. `}`

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

- **Фейк репозитория** — Вторая реализация интерфейса со слоя моделей: map вместо коллекции, доменные ошибки вместо кодов сервера. Гоняется за миллисекунды и проверяет ветки логики сервиса.
- **mtest** — Каркас интеграционных тестов из драйвера v1. В v2 не перенесён: пакета нет в дереве, а тестовые утилиты драйвера живут в internal-каталогах и импортировать их нельзя.
- **Ping в тест-хелпере** — Connect в v2 не делает сетевых обращений. Ping с дедлайном в пару секунд подтверждает доступность; на провале t.Skip — без стенда тест пропускается, а не висит.
- **replicaSet=rs0 в тестах** — Одиночный mongod, запущенный как реплика из одного нода: открывает транзакции и Change Streams, недоступные на standalone. Имя реплики зашивается в URI хелпера.
- **Drop и изоляция** — Тестовая база удаляется через Drop до и после прогона. Параллельные тесты разводятся по коллекциям или уникальным значениям, чтобы не конкурировать за документы.
- **TestMain и -run** — Один клиент на пакет в TestMain с m.Run экономит пулы и рукопожатия; префикс TestIntegration и флаг -run отрезают интеграционные тесты на машинах без стенда.

Каркас собран: интерфейс из [урока про слой репозитория](/courses/mongo-driver/lessons/21-modeli-sloj/) получил вторую реализацию — фейк на map, а живая база подтверждает драйверные контракты, которые подделка не умеет. Транзакционные тесты потребуют реплику — механика сессий описана в [уроке про транзакции](/courses/mongo-driver/lessons/18-tranzakcii/), а коды ошибок, которые тест проверяет на дубликате, классифицированы в [уроке про ошибки драйвера](/courses/mongo-driver/lessons/20-oshibki-drivera/) теми же предикатами, что и в проде. Хорошая последовательность внедрения: сначала накройте фейками ветки сервиса — это час работы и мгновенный регресс-щит, затем добавьте один интеграционный тест на дубликат и постепенно расширяйте набор. Песочница сайта не исполняет драйвер, поэтому стенд соберите на своей машине: mongod в Docker с именем реплики rs0, хелпер из урока, два документа — и тестовый пакет перестанет быть лотереей, какой была ручная проверка через curl.

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

- Тесты с драйвером делятся на два уровня: фейк репозитория для логики и живая база для серверных контрактов.
- 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-индекс проверяется полем со сроком в прошлом — очистка сработает на ближайшем проходе сервера, а ждать минуты не нужно. Для остального время инжектируется в сервис как зависимость, и фейк подставляет фиксированный момент — база при этом проверяет только сохранение значения, не его течение.
