Содержание урока · 10 разделов
- Почему testcontainers mongodb, а не GenericContainer
- mongodb.Run: опции и учётные данные
- Строка подключения: порт, креды, replicaSet
- Опции mongodb.Run: кто за что отвечает
- Коллекция с bson-тегами: InsertOne и FindOne
- Транзакции: без ReplicaSet никак
- Чек-лист mongo-теста
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Модуль 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 с опциями
GOimport (
"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, здесь берём тот же каркас.
База без реплики готова, когда 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, а 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. Что произойдёт?
Коллекция с 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-теги, уникальный индекс и дубликат
GOtype 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 упал. Как в тесте убедиться, что причина именно в уникальном индексе?
Транзакции: без ReplicaSet никак#
Самая дорогая грабля модуля — транзакции. Много-документные транзакции MongoDB работают только на реплика-сете: на одиночном mongod без --replSet первая же операция в сессии упадёт с ошибкой про Transaction numbers. Лечение — WithReplicaSet("rs0"): модуль превращает контейнер в одноузловой реплика-сет, сам инициирует его, сам ждёт готовности, а ConnectionString дописывает replicaSet в URI, чтобы драйвер выбрал правильную топологию. Дальше обычная механика драйвера: client.StartSession, sess.StartTransaction, операции, sess.CommitTransaction или sess.AbortTransaction — на живой базе, без моков. Из практичного: откат транзакции в конце теста возвращает коллекцию к исходному виду без пересоздания контейнера. Сессию привязывайте к контексту с дедлайном: зависший коммит без него подвесит тест до общего таймаута прогона. А коммит на одном узле стоит копейки. И честный совет: если транзакции в тестах не нужны, не включайте реплику — контейнер поднимется быстрее, а URI станет короче. Дальше по курсу — модуль Kafka продолжит прогулку по хранилищам и брокерам, полный план — в курсе testcontainers: от wait-стратегий до CI и параллельности.
Что вернёт ConnectionString
Прочитайте код и выберите, что он выведет:
mg, err := mongodb.Run(ctx, "mongo:6",
mongodb.WithUsername("root"),
mongodb.WithPassword("secret"),
mongodb.WithReplicaSet("rs0"),
)
// err == nil
uri, err := mg.ConnectionString(ctx)Чек-лист 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
Восстановите вызовы: старт контейнера, строка подключения и вход драйвера.
Расставьте минимальный тест по порядку
Соберите путь теста: контекст, старт контейнера, проверка ошибки, уборка, URI, клиент.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: модуль MongoDB
Честно: в браузерной песочнице этот урок не запустить — Docker там нет, а модулю нужен живой демон и локальный go test в терминале. Рабочая схема: установленный Go, запущенный Docker и обычный go test в корне пакета. Первая сборка скачает образ mongo за пару минут, дальше он кешируется локально и стартует заметно быстрее. Код разобран построчно — напечатайте тест руками и прогоните на своей машине: ошибка про подключение к демону значит, что Docker не запущен, и тест тут ни при чём.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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, а создание индексов тоже её создаёт. До первой записи список баз останется пустым — это не ошибка. И держите в уме: коллекция, опустевшая после удаления всех документов, исчезает из списка баз.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Go с нуля: базовый синтаксис и конструкции
Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.
- Писать, компилировать и запускать программы на Go
- Свободно работать с переменными, типами, операторами
- Управлять потоком выполнения: if, switch, for, range
- Хранить данные в массивах, срезах и картах