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

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

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

Урок 7 из 25 ~16 мин +50 XP

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

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

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

Модуль 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, здесь берём тот же каркас.

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: кто за что отвечает#

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

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 упал. Как в тесте убедиться, что причина именно в уникальном индексе?

Транзакции: без 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

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

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

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

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

Строки

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

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

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

Термин

mongodb.Run

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

Вход в модуль: образ первым аргументом, дальше кастомайзеры. Возвращает типизированный MongoDBContainer с ошибкой; порт 27017 и ожидание лога Waiting for connections настроены за вас.

1 / 6

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 и попробуйте транзакцию в сессии — прочитайте ошибку про реплика-сет в лицо. Сравните время первого и второго запуска: разница и есть цена кеша образов.

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

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

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

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

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

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

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

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

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

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

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

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

Go с нуля: базовый синтаксис и конструкции

Интерактивный курс базового синтаксиса Go для начинающих: переменные, циклы, срезы, функции, структуры. 25 уроков с кодом в браузере, тестами и первым приложением.

  • Писать, компилировать и запускать программы на Go
  • Свободно работать с переменными, типами, операторами
  • Управлять потоком выполнения: if, switch, for, range
  • Хранить данные в массивах, срезах и картах