---
title: "Модуль MongoDB: коллекции без моков"
description: "testcontainers mongodb: настоящий MongoDB в тесте. Run с опциями, ConnectionString, mongo.Connect, bson-теги, уникальный индекс, ReplicaSet и транзакции."
course: "Testcontainers в Go: интеграционные тесты с Docker"
url: "https://golangmaster.ru/courses/testcontainers/lessons/07-mongodb-module/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Модуль MongoDB: коллекции без моков

*Курс «Testcontainers в Go: интеграционные тесты с Docker», урок 7 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testcontainers/lessons/07-mongodb-module/*

mongo тесты go обычно подменяют базу моками: интерфейс репозитория, заглушка, проверка вызовов — и тест зелёный, хотя настоящий запрос к MongoDB так и не выполнен. Модуль testcontainers mongodb убирает эту дыру: он поднимает в Docker-контейнере настоящий mongod, дожидается готовности и отдаёт готовую строку подключения, куда дописаны учётные данные и реплика-сет, если вы их попросили. Дальше работает официальный драйвер: mongo.Connect с options.Client().ApplyURI, коллекции, InsertOne и FindOne, индексы и даже транзакции — всё на живой базе, а не на моках. В уроке соберём такой тест с нуля: запуск контейнера с опциями, подключение, структура с bson-тегами, уникальный индекс и грабли без ReplicaSet. Все шаги собираются в чек-лист в конце урока — по нему тест заводится с первого прогона.

Модуль MongoDB — первый документоориентированный модуль курса, и здесь схема хранилища живёт не в базе, а в коде: коллекция принимает любой документ. До сих пор контейнеры заводились голым GenericContainer, и для mongo этого мало по трём причинам. Первая — конфигурация: у mongod свои переменные окружения для пользователя и пароля, свой порт 27017 и своя специфика старта, и повторять это руками в каждом пакете утомительно. Вторая — готовность: примитивное ожидание открытого порта пропускает момент, когда сервер ещё не принимает соединения, а с реплика-сетом готовность наступает в два эшелона — сначала mongod, потом rs.initiate. Третья — удобство: после старта нужно собрать URI из маппированного порта и учётных данных, и это рутинный код без отношения к делу. Моки при этом врут вдвойне: проверяют вызовы интерфейса, а не поведение сервера, и пропускают всё, что MongoDB решает сама, — уникальность, индексы, типы полей. Модуль закрывает все три пункта: он знает про переменные образа, про двухступенчатый старт реплики и про формат mongodb://. Разберём, что он делает за кулисами, как собрать на нём тест и какие грабли он убирает с дороги.

### Почему testcontainers mongodb, а не GenericContainer

GenericContainer из ранних уроков умеет поднять что угодно, но про MongoDB не знает ничего: образ, переменные MONGO_INITDB_ROOT_USERNAME и MONGO_INITDB_ROOT_PASSWORD, проброс порта 27017, ожидание готовности — всё руками, и в каждом пакете заново. Модуль testcontainers mongodb закрывает это разом. Он открывает порт 27017, транслирует опции в переменные окружения и собирает готовую стратегию ожидания: лог Waiting for connections плюс слушающий порт — mongod печатает эту строку, когда готов принимать соединения. Взамен вы получаете типизированный MongoDBContainer с методом ConnectionString, который знает учётные данные и имя реплики, — а не россыпью строк, которую вы собираете и синхронизируете сами. Ничего магического здесь нет: внутри модуль пользуется тем же GenericContainer и кастомайзерами, но этот код написан и обкатан один раз в исходниках. С реплика-сетом разница заметнее всего: модуль сам передаёт mongod флаг --replSet, сам инициирует реплику после старта и сам ждёт её готовности. Устаревший RunContainer без явного образа поднимает mongo:6 — живой памятник дефолту, пользоваться им не нужно.

### Каркас: mongodb.Run с опциями

```go
import (
    "context"
    "log"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/mongodb"
)

func TestMain(m *testing.M) {
    ctx := context.Background()
    mg, err := mongodb.Run(ctx, "mongo:6",
        mongodb.WithUsername("app"),
        mongodb.WithPassword("secret"),
        mongodb.WithReplicaSet("rs0"),
    )
    if err != nil {
        log.Fatalf("mongo не поднялся: %v", err)
    }
    defer testcontainers.TerminateContainer(mg)

    m.Run()
}
```

mongodb.Run принимает образ первым аргументом и возвращает типизированный MongoDBContainer с ошибкой. WithUsername и WithPassword превращаются в переменные MONGO_INITDB_ROOT_USERNAME и MONGO_INITDB_ROOT_PASSWORD — образ создаст root-пользователя при первой инициализации. WithReplicaSet добавляет флаг --replSet и запускает инициализацию реплики после старта. TerminateContainer в defer уберёт контейнер, даже если тесты упадут. Образ — единственное, что вы обязаны выбрать сами.

### mongodb.Run: опции и учётные данные

Вход в модуль — mongodb.Run(ctx, образ, opts ...testcontainers.ContainerCustomizer): тот же принцип кастомайзеров, что у ядра testcontainers, плюс три собственные опции. WithUsername и WithPassword задают root-пользователя и его пароль через переменные образа; правило жёсткое — только парой: передали что-то одно, Run вернёт ошибку ещё до старта контейнера. Ничего не передали — mongod поднимется без авторизации, и URI получится без кредов. После старта модуль перечитывает переменные контейнера, чтобы ConnectionString знал актуальные значения. WithReplicaSet переводит одиночный узел в режим реплика-сета: к команде mongod добавляется --replSet, а после старта модуль сам выполняет rs.initiate с адресом контейнера и ждёт rs.status().ok с дедлайном в минуту. С парой кредов поднимается строже: модуль монтирует свой entrypoint-скрипт с keyfile и передаёт его mongod — без этого реплика-сет с авторизацией не соберётся. Отдельной опции «создать базу» нет и не нужно: MongoDB создаёт базы и коллекции лениво, при первой записи. Пользователь с паролем создаются при первой инициализации пустого каталога данных, как и у Postgres: на переиспользуемом контейнере со старым томом новые креды молча не применятся. Точка входа TestMain для фикстур пакета — тема урока про [Testify и TestMain](/courses/testcontainers/lessons/19-testify-suite/), здесь берём тот же каркас.

> **Живое место: с репликой готовность двухступенчатая.** База без реплики готова, когда mongod напечатал Waiting for connections и слушает порт — ровно это модуль ждёт по умолчанию. Реплика-сет добавляет второй эшелон: строка Waiting for connections появляется ещё до rs.initiate, поэтому модуль добавляет exec-ожидание rs.status().ok с дедлайном 60 секунд — если реплика не собралась, Run вернёт ошибку по дедлайну, а не зависнет навечно. Проверить себя легко: поднимите контейнер, выведите логи и найдите момент, когда mongod уже готов, а реплика ещё нет. Спорное мнение с условием: ждать один открытый порт — на Linux часто прощает себя, но на Mac и Windows Docker поднимает отдельный прокси, порт открывается раньше готовности, и тесты начинают флейкать; готовая сборка ожиданий снимает вопрос целиком.

### ConnectionString и подключение драйвера

```go
// mg — *mongodb.MongoDBContainer из TestMain
uri, err := mg.ConnectionString(ctx)
if err != nil {
    t.Fatalf("connection string: %v", err)
}

client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
    t.Fatalf("connect: %v", err)
}
defer func() {
    if err := client.Disconnect(ctx); err != nil {
        t.Logf("disconnect: %v", err)
    }
}()

if err := client.Ping(ctx, nil); err != nil {
    t.Fatalf("ping: %v", err)
}
```

ConnectionString собирает URI из маппированного порта 27017 и дописывает креды и replicaSet, если они заданы. mongo.Connect с options.Client().ApplyURI — каноничный вход в драйвер: клиент парсит строку и настраивает пул соединений. Ping — минимальная проверка живости: тест падает с ясной ошибкой, а не висит на первой операции. Disconnect в defer освобождает соединения до конца прогона.

### Строка подключения: порт, креды, replicaSet

MongoDBContainer.ConnectionString(ctx) возвращает mongodb://пользователь:пароль@хост:порт/ — хост и порт берутся из PortEndpoint по порту 27017/tcp, то есть из маппированного порта Docker. Выбирать порт руками не нужно и вредно: на занятой машине тест упадёт, а так Docker отдаёт свободный порт каждому контейнеру, и параллельные прогоны не дерутся. Креды попадают в URI только когда заданы оба — логин и пароль; имя реплики дописывается query-параметром: mongodb://app:secret@localhost:54321/?replicaSet=rs0. По этой строке драйвер сам определяет топологию и не даст запустить транзакции вне реплики. Подключение — mongo.Connect(options.Client().ApplyURI(uri)), а client.Ping(ctx, nil) подтверждает, что соединение живое. В тестах реплика-сета полезен SetDirect(true): драйвер подключается к указанному узлу напрямую и не тратит время на открытие топологии. Дальше начинается территория драйвера: контексты операций, сессии, курсоры Find, агрегации — всё это разобрано в [курсе mongo-driver](/courses/mongo-driver/), а mongo тесты go здесь проходят на живой базе, без единого мока.

### Опции mongodb.Run: кто за что отвечает

| Опция | За что отвечает | Пример значения |
| --- | --- | --- |
| WithUsername | root-пользователь через MONGO_INITDB_ROOT_USERNAME | WithUsername("app") |
| WithPassword | пароль root-пользователя; только в паре с логином | WithPassword("secret") |
| WithReplicaSet | одиночный узел как реплика-сет: --replSet, rs.initiate, ожидание rs.status() | WithReplicaSet("rs0") |
| без опций | mongod без авторизации; URI без кредов | mongodb.Run(ctx, "mongo:6") |
| ConnectionString | метод контейнера: URI с маппированным портом, кредами и replicaSet | mg.ConnectionString(ctx) |

### Проверка: логин без пароля

**Вопрос:** mongodb.Run вызвали только с WithUsername("app"), без WithPassword. Что произойдёт?

- Контейнер поднимется, а ConnectionString вернёт URI без пароля
- Образ создаст пользователя app с пустым паролем
- ✅ Run вернёт ошибку: логин и пароль задаются только парой
- Run зависнет, пока пароль не передадут отдельным вызовом

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

### Коллекция с bson-тегами: InsertOne и FindOne

Mongo не накладывает схему, но маппинг структуры в BSON и обратно драйвер делает по тегам. Без тегов он берёт имена полей как есть: поле Email уедет в документ как Email с большой буквы, и фильтр по email вернёт пустоту — классическая ловушка первого дня. Тег bson:"email" фиксирует имя поля в документе, bson:"_id" связывает идентификатор, omitempty не пишет пустые значения. Тег inline вкладывает структуру в документ без имени поля, а вложенные структуры размечаются так же, как верхний уровень. База и коллекция создаются лениво: client.Database("shop").Collection("users") — лишь дескриптор, а первое InsertOne создаст и то и другое на сервере. Вставка принимает структуру, FindOne — фильтр bson.M и Decode обратно в структуру; контекст пробрасывается в каждую операцию, и отмена ctx отменяет запрос. Для списков есть Find с курсором, для агрегаций — Aggregate, но для базового теста достаточно пары InsertOne и FindOne: записали документ, прочитали его тем же фильтром, сравнили поля с ожиданием.

### bson-теги, уникальный индекс и дубликат

```go
type User struct {
    ID    string `bson:"_id"`
    Email string `bson:"email"`
    Name  string `bson:"name"`
}

coll := client.Database("shop").Collection("users")

_, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{
    Keys:    bson.D{{Key: "email", Value: 1}},
    Options: options.Index().SetUnique(true),
})
if err != nil {
    t.Fatalf("create index: %v", err)
}

if _, err := coll.InsertOne(ctx, User{ID: "u-1", Email: "anna@example.com"}); err != nil {
    t.Fatalf("insert: %v", err)
}

var got User
if err := coll.FindOne(ctx, bson.M{"email": "anna@example.com"}).Decode(&got); err != nil {
    t.Fatalf("find: %v", err)
}

if _, err := coll.InsertOne(ctx, User{ID: "u-2", Email: "anna@example.com"}); err == nil {
    t.Fatal("ожидали ошибку дубликата")
} else if !mongo.IsDuplicateKeyError(err) {
    t.Fatalf("неожиданная ошибка: %v", err)
}
```

Структура User размечена bson-тегами: в документе появятся _id, email и name — ровно те имена, по которым вы фильтруете. CreateOne создаёт уникальный индекс: IndexModel с ключами bson.D и опцией SetUnique. Вторая вставка с тем же адресом падает, и mongo.IsDuplicateKeyError распознаёт код E11000 без парсинга текста. FindOne с фильтром и Decode возвращает документ в структуру.

Индексы в Mongo создаются вызовом coll.Indexes().CreateOne: модель с ключами bson.D{{Key: "email", Value: 1}} и опцией options.Index().SetUnique(true). Без уникального индекса дубликаты вставляются молча, и тест на уникальность проходит, хотя логика сломана, — поэтому индекс создаёт сам тест, а не прод-миграция, о которой забыли. Создание терпеливо к повторам: вызов с теми же ключами и опциями вернёт существующий индекс, а то же имя с другими опциями — ошибку IndexOptionsConflict, и её тоже полезно знать в лицо. Сама проверка честная: вставить документ дважды и убедиться, что вторая вставка падает именно как дубликат. Для этого mongo.IsDuplicateKeyError(err) проверяет код E11000 на стороне драйвера, а не подстроку в тексте ошибки — текст меняется между версиями драйвера и локалью сервера, код остаётся стабильным контрактом. Сам индекс создаётся и на пустой коллекции — коллекция появится вместе с ним, поэтому порядок «сначала индекс, потом вставки» безопасен.

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

**Вопрос:** Второй InsertOne с тем же email упал. Как в тесте убедиться, что причина именно в уникальном индексе?

- Проверить, что текст ошибки содержит слово duplicate
- ✅ Вызвать mongo.IsDuplicateKeyError(err) — она распознаёт код E11000
- Достаточно err != nil: другая причина вставке не страшна
- Повторить вставку в цикле, пока она не пройдёт

IsDuplicateKeyError проверяет код ошибки, а не текст: строка ошибки зависит от версии драйвера и локали, код E11000 — стабильный контракт. err != nil валит тест и на сетевом сбое, не отделяя дубликат от прочих проблем; цикл повторов замаскирует настоящую поломку индекса.

### Транзакции: без ReplicaSet никак

Самая дорогая грабля модуля — транзакции. Много-документные транзакции MongoDB работают только на реплика-сете: на одиночном mongod без --replSet первая же операция в сессии упадёт с ошибкой про Transaction numbers. Лечение — WithReplicaSet("rs0"): модуль превращает контейнер в одноузловой реплика-сет, сам инициирует его, сам ждёт готовности, а ConnectionString дописывает replicaSet в URI, чтобы драйвер выбрал правильную топологию. Дальше обычная механика драйвера: client.StartSession, sess.StartTransaction, операции, sess.CommitTransaction или sess.AbortTransaction — на живой базе, без моков. Из практичного: откат транзакции в конце теста возвращает коллекцию к исходному виду без пересоздания контейнера. Сессию привязывайте к контексту с дедлайном: зависший коммит без него подвесит тест до общего таймаута прогона. А коммит на одном узле стоит копейки. И честный совет: если транзакции в тестах не нужны, не включайте реплику — контейнер поднимется быстрее, а URI станет короче. Дальше по курсу — [модуль Kafka](/courses/testcontainers/lessons/08-kafka-module/) продолжит прогулку по хранилищам и брокерам, полный план — в [курсе testcontainers](/courses/testcontainers/): от wait-стратегий до CI и параллельности.

### Что вернёт ConnectionString

```go
mg, err := mongodb.Run(ctx, "mongo:6",
    mongodb.WithUsername("root"),
    mongodb.WithPassword("secret"),
    mongodb.WithReplicaSet("rs0"),
)
// err == nil

uri, err := mg.ConnectionString(ctx)
```

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

- ✅ mongodb://root:secret@localhost:<маппированный порт>/?replicaSet=rs0
- mongodb://localhost:27017/ — порт контейнера, креды в URI не попадают
- mongodb://root:secret@localhost:27017/shop — с портом и именем базы
- Ошибка: без имени базы модуль не соберёт URI

Креды модуль перечитал из переменных контейнера после старта и подставил в URI: пользователь и пароль появляются, только если заданы оба. Хост и порт — из PortEndpoint по 27017/tcp: Docker отдаёт свободный порт хоста, и он меняется от запуска к запуску. replicaSet дописывается query-параметром, а имени базы в пути нет — Mongo создаёт базы лениво.

### Чек-лист mongo-теста

- mongodb.Run вместо GenericContainer: порт, окружение и ожидание готовности уже настроены
- логин и пароль — только парой: WithUsername и WithPassword
- WithReplicaSet — если тестируете транзакции или зависящее от топологии поведение
- ConnectionString — единственный источник URI; порт выбирает Docker, не вы
- mongo.Connect(options.Client().ApplyURI(uri)) и client.Ping перед первыми операциями
- bson-теги на полях структуры: _id, email, omitempty — маппинг без сюрпризов
- уникальный индекс создаёт сам тест; дубликат ловится mongo.IsDuplicateKeyError

### Соберите подключение к MongoDB

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

```go
mg, err := mongodb.___(ctx, "mongo:6",
    mongodb.WithUsername("root"),
    mongodb.WithPassword("secret"),
    mongodb.WithReplicaSet("rs0"),
)

uri, err := mg.___(ctx)

client, err := mongo.Connect(options.Client().___(uri))
```

Подсказка: Контейнер запускает mongodb.Run, готовый URI отдаёт ConnectionString, а драйвер применяет его через options.Client().ApplyURI.

Правильные ответы в пропусках: `Run`, `ConnectionString`, `ApplyURI`

### Расставьте минимальный тест по порядку

Соберите путь теста: контекст, старт контейнера, проверка ошибки, уборка, URI, клиент.

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

1. `ctx := context.Background()`
2. `mg, err := mongodb.Run(ctx, "mongo:6")`
3. `if err != nil {`
4. `  t.Fatal(err)`
5. `}`
6. `defer testcontainers.TerminateContainer(mg)`
7. `uri, err := mg.ConnectionString(ctx)`
8. `client, err := mongo.Connect(options.Client().ApplyURI(uri))`

### Карточки: модуль MongoDB

- **mongodb.Run** — Вход в модуль: образ первым аргументом, дальше кастомайзеры. Возвращает типизированный MongoDBContainer с ошибкой; порт 27017 и ожидание лога Waiting for connections настроены за вас.
- **WithUsername и WithPassword** — Переменные MONGO_INITDB_ROOT_USERNAME и MONGO_INITDB_ROOT_PASSWORD: образ создаёт root-пользователя. Задаются только парой — иначе Run вернёт ошибку ещё до старта контейнера.
- **WithReplicaSet** — Одноузловой реплика-сет: флаг --replSet, хук после старта с rs.initiate по IP контейнера, exec-ожидание rs.status().ok с дедлайном 60 секунд. Обязателен для транзакций.
- **ConnectionString** — mongodb://[user:pass@]хост:порт/ из PortEndpoint по 27017/tcp; креды попадают в URI, только если заданы оба, имя реплики — query-параметром replicaSet.
- **bson-теги** — bson:"email" фиксирует имя поля в документе, bson:"_id" — идентификатор, omitempty не пишет пустые значения. Без тегов поля уезжают с заглавной буквы, и фильтры молча не находят ничего.
- **mongo.IsDuplicateKeyError** — Распознаёт нарушение уникального индекса по коду E11000: проверяет код, а не текст ошибки. Текст меняется между версиями драйвера и локалью сервера, код — стабильный контракт.

> **Про песочницу: нужен локальный Docker.** Честно: в браузерной песочнице этот урок не запустить — Docker там нет, а модулю нужен живой демон и локальный go test в терминале. Рабочая схема: установленный Go, запущенный Docker и обычный go test в корне пакета. Первая сборка скачает образ mongo за пару минут, дальше он кешируется локально и стартует заметно быстрее. Код разобран построчно — напечатайте тест руками и прогоните на своей машине: ошибка про подключение к демону значит, что Docker не запущен, и тест тут ни при чём.

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

- mongodb.Run поднимает настоящий mongod: порт 27017, ожидание готовности и типизированный MongoDBContainer с ConnectionString.
- WithUsername и WithPassword задают root-пользователя и задаются только парой; WithReplicaSet делает одноузловой реплика-сет для транзакций.
- ConnectionString собирает URI с маппированным портом, кредами и replicaSet; подключение — mongo.Connect(options.Client().ApplyURI) и Ping.
- bson-теги управляют маппингом структур, уникальный индекс создаётся в тесте, дубликаты ловятся mongo.IsDuplicateKeyError.

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

Соберите TestMain с mongodb.Run: WithUsername и WithPassword, WithReplicaSet, подключение через ConnectionString и mongo.Connect с Ping. Затем добавьте структуру с bson-тегами, уникальный индекс по email и тест: первая вставка проходит, вторая падает с mongo.IsDuplicateKeyError. В конце уберите WithReplicaSet и попробуйте транзакцию в сессии — прочитайте ошибку про реплика-сет в лицо. Сравните время первого и второго запуска: разница и есть цена кеша образов.

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

### Можно ли обойтись без реплика-сета?

Да, для большинства операций коллекции: вставки, чтения, обновления и индексы работают на одиночном mongod. Реплика обязательна для много-документных транзакций и для проверки поведения, зависящего от топологии, — смены первичного узла, чтений с вторичек. Если транзакции в тестах не нужны, запускайте mongod без WithReplicaSet: контейнер поднимется быстрее, а URI станет короче. Вернётесь к реплике, когда дойдёте до сессий и коммитов.

### Почему в строке подключения порт не 27017?

Docker маппирует порт контейнера на свободный порт хоста, и этот порт меняется от запуска к запуску. ConnectionString делает эту работу через PortEndpoint: адрес всегда актуальный, а параллельные прогоны не дерутся за один порт. Жёстко прописанный 27017 в URI — классическая ошибка переноса кода с локальной базы на тестовую; то же правило действует и для остальных модулей курса.

### База shop не появилась после подключения. Где она?

Mongo создаёт базы и коллекции лениво: Database и Collection в драйвере — только дескрипторы, а первое InsertOne создаёт и базу, и коллекцию на сервере. Явно создать коллекцию можно CreateCollection, а создание индексов тоже её создаёт. До первой записи список баз останется пустым — это не ошибка. И держите в уме: коллекция, опустевшая после удаления всех документов, исчезает из списка баз.
