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

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

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

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

Модуль Redis: кэш и TTL в тестах

redis тесты go почти всегда упираются в кэш: нужно проверить хит, мисс и истечение TTL, а моки врут — они ничего не хранят и не умеют истекать. Модуль testcontainers redis решает это в лоб: поднимает в Docker-контейнере настоящий Redis и отдаёт готовый redis://-адрес, как только сервер действительно готов принимать соединения. Вместо ручного GenericContainer с подбором образа, порта и wait-стратегий вы пишете один вызов redis.Run с парой опций — и дальше работаете с честным сервером через клиент go-redis: Set, Get, TTL и изоляция ключей между тестами. В этом уроке соберём такой тест с нуля: опции запуска, подключение клиента, кэш-хит и кэш-мисс, честные грабли TTL и типовые ошибки первого запуска.

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

Redis — самый ходовой модуль testcontainers после Postgres: кэш есть почти в каждом сервисе, и интеграционный тест без него проверяет меньше, чем обещает название. До сих пор контейнеры в курсе заводились голым GenericContainer, и для Redis этого мало по трём причинам. Первая — конфигурация: у redis-server свой набор флагов запуска, и повторять их руками в каждом пакете утомительно — где-нибудь да забудете снапшоты или уровень логов. Вторая — готовность: серверу нужно время на старт, и наивное ожидание флейкает на медленных машинах и в CI под нагрузкой. Третья — удобство: после старта нужно собрать адрес с маппированным портом, а это рутинный код без отношения к делу. Модуль закрывает все три пункта разом: он знает про флаги redis-server, про готовность и про формат адреса. Плюс сам кэш — это поведение: время жизни, вытеснение, инвалидация. Мок их просто не умеет, и тест на моке проверяет только ваш собственный код, а не его договорённости с хранилищем. Разберём, что модуль делает за кулисами, как собрать на нём тест и какие грабли он оставит вам.

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

GenericContainer из второго урока умеет поднять что угодно, но про Redis не знает ничего: образ, открытый порт 6379, ожидание готовности — всё руками. Модуль закрывает это разом. Внутри redis.Run выставляет два обязательных решения: порт 6379/tcp открывается заранее, а готовность ждут двумя стратегиями — слушающий порт с потолком в десять секунд и строка Ready to accept connections в логах сервера. То есть к моменту, когда Run вернёт управление, redis-server уже принимает команды — соединяться с неготовым сервером не получится. Взамен вы получаете типизированный RedisContainer — с методом ConnectionString, поддержкой TLS и учтёнными флагами запуска, а не россыпью строк, которую вы собираете и синхронизируете сами в каждом пакете. Ничего магического здесь нет: внутри модуль пользуется тем же GenericContainer, но этот код написан и обкатан один раз в исходниках, а не в каждом вашем тесте. Про сами стратегии ожидания подробнее — в отдельном уроке ниже.

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

GO
import (
    "context"
    "log"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
)

func TestMain(m *testing.M) {
    ctx := context.Background()
    r, err := tcredis.Run(ctx,
        "redis:7",
        tcredis.WithSnapshotting(10, 1),
        tcredis.WithLogLevel(tcredis.LogLevelWarning),
    )
    if err != nil {
        log.Fatalf("redis не поднялся: %v", err)
    }
    defer testcontainers.TerminateContainer(r)

    m.Run()
}

redis.Run принимает образ первым аргументом и возвращает типизированный RedisContainer с ошибкой. Опции WithSnapshotting и WithLogLevel переводятся во флаги --save и --loglevel запуска redis-server. Порт, готовность и wait-стратегии модуль ставит сам: управление вернётся только когда сервер готов принимать команды. TerminateContainer в defer уберёт контейнер, даже если тесты упадут. Образ — единственное, что вы обязаны выбрать сами.

redis.Run: образ и опции With#

Вход в модуль — функция redis.Run(ctx, образ, opts ...testcontainers.ContainerCustomizer): тот же принцип кастомайзеров, что у ядра testcontainers, плюс собственные опции модуля. Образ обязателен первым аргументом, и дефолтного тега у Run нет — строку вы всегда пишете сами, а сменить тег значит поменять её: отдельной опции переключения версии образа в модуле нет. Опции With переводятся в флаги redis-server. WithSnapshotting(секунды, ключи) превращается в --save: сохранять датасет каждые N секунд при M изменениях, значения меньше единицы подтягиваются до единицы. WithLogLevel принимает LogLevelDebug, LogLevelVerbose, LogLevelNotice или LogLevelWarning и уходит в --loglevel — удобно, когда тест падает и нужен подробный лог сервера. WithConfigFile монтирует ваш файл в /usr/local/redis.conf и ставит его первым аргументом запуска: лимиты памяти, политики вытеснения — всё, что обычно живёт в конфиге. WithTLS — отдельная история: сертификаты генерируются на лету, и что меняется в адресе, проверим вопросом ниже.

ConnectionString и подключение клиента

GO
// r — *tcredis.RedisContainer из TestMain
uri, err := r.ConnectionString(ctx)
if err != nil {
    t.Fatalf("connection string: %v", err)
}

opts, err := redis.ParseURL(uri)
if err != nil {
    t.Fatalf("parse url: %v", err)
}

client := redis.NewClient(opts)
defer client.Close()

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

ConnectionString возвращает redis://хост:маппированный_порт — схема redis вшита в модуль, и при WithTLS она сама станет rediss. redis.ParseURL разбирает адрес в структуру Options: собирать хост и порт руками не нужно. redis.NewClient строит на ней клиент, а Ping — минимальная проверка, что сервер жив: тест падает с ясной ошибкой, а не висит на первом деле. Close в defer освобождает соединения до конца прогона.

ConnectionString и клиент go-redis#

RedisContainer.ConnectionString(ctx) возвращает адрес вида redis://хост:порт — схема redis, а при включённом TLS уже rediss. Адрес собирается через PortEndpoint по порту 6379/tcp, то есть из маппированного порта Docker. Выбирать порт руками не нужно и вредно: на занятой машине тест упадёт, а так Docker отдаёт свободный порт каждому контейнеру, и параллельные прогоны не дерутся. Дальше адрес отдаётся клиенту go-redis: redis.ParseURL разбирает строку в готовую структуру Options, redis.NewClient строит на ней клиент, а Ping подтверждает, что сервер жив. Тот же ParseURL используют исходники модуля — в примерах к Options просто прикрепляют TLSConfig из контейнера, когда соединение шифруется. Подключайтесь один раз в TestMain и держите клиент открытым на весь пакет: пересоздавать его на каждый тест — лишняя работа. redis тесты go проходят на честных командах, а тонкости клиента — тема отдельного курса go-redis.

Задача — приём: чем резать конфигурацию#

ЗадачаПриём
поднять Redis в тестеtcredis.Run(ctx, "redis:7") — образ обязателен первым аргументом
сменить тег образадругой строкой образа в Run: отдельной опции тега нет
снапшоты датасетаWithSnapshotting(секунды, ключи) — уходит во флаг --save
подробные логи сервераWithLogLevel(tcredis.LogLevelVerbose) — флаг --loglevel
свой конфиг-файлWithConfigFile("testdata/redis.conf") — первым аргументом запуска
шифрованное соединениеWithTLS() — схема rediss и готовый tls.Config
URL для клиента go-redisConnectionString(ctx) и redis.ParseURL
чистое состояние между тестамиFlushAll(ctx) или второй клиент с Options{DB: 1}

Проверка: TLS и адрес

Вы передали в redis.Run опцию WithTLS(). Что вернёт ConnectionString?

Кэш-хит и кэш-мисс: тест на честном Redis#

Кэш-тест — это пара сценариев: мисс и хит. Кэш-мисс в go-redis — не пустая строка, а ошибка: Get по отсутствующему ключу возвращает сентинел-ошибку redis.Nil. Проверяйте её через errors.Is, а не сравнением текста ошибки: сентинел создан именно для этого. Дальше кладём значение Set с временем жизни и проверяем хит: тот же ключ возвращается с тем же значением. На честном сервере это проверяет больше, чем мок: что ключи называются с правильными префиксами, что сериализация значения туда и обратно не ломает данные, что инвалидация действительно удаляет ключ, а TTL выставляется, а не забыт. Состояние между тестами чистите: FlushAll после каждого теста стирает всё, а можно завести второй клиент с Options{DB: 1} — Redis держит несколько логических баз на одном сервере, и тесты в разных номерах не видят ключи друг друга. Выберите один способ на пакет и придерживайтесь его.

Тест кэш-хита и кэш-мисса

GO
// cache — *redis.Client, собранный в TestMain
func TestCache_HitAndMiss(t *testing.T) {
    ctx := context.Background()

    // кэш-мисс: ключа ещё нет
    _, err := cache.Get(ctx, "user:42").Result()
    if !errors.Is(err, redis.Nil) {
        t.Fatalf("ожидали кэш-мисс: %v", err)
    }

    cache.Set(ctx, "user:42", "Анна", time.Minute)

    // кэш-хит: значение читается обратно
    got, err := cache.Get(ctx, "user:42").Result()
    if err != nil || got != "Анна" {
        t.Fatalf("кэш-хит: %v", err)
    }

    // TTL выставлен — проверяем остаток, а не спим
    ttl, err := cache.TTL(ctx, "user:42").Result()
    if err != nil || ttl <= 0 {
        t.Fatalf("TTL: %v", err)
    }
}

Кэш-мисс проверяется через errors.Is(err, redis.Nil): именно эту сентинел-ошибку go-redis отдаёт по отсутствующему ключу. Set кладёт значение с TTL — третий аргумент обязателен, ноль сделает ключ вечным. После записи тот же ключ читается обратно: это кэш-хит. TTL возвращает остаток жизни ключа как time.Duration: положительный остаток подтверждает, что время жизни выставлено, и тест не завязан на сон.

Проверка: кэш-мисс

cache.Get(ctx, "user:42") по ключу, которого нет. Что вернёт go-redis?

TTL: остаток вместо сна#

Первая грабля TTL — time.Sleep. Тест, который кладёт ключ с TTL на две секунды и спит три, замедляет весь пакет и флейкает на нагруженной машине: планировщик может отдать квант позже, и сон окажется недостаточным. Вместо сна спрашивайте сервер: команда TTL возвращает остаток жизни ключа как time.Duration — сразу после Set это почти заданное время, а к концу жизни остаток тает. Для ключа без истечения сервер отвечает -1, для отсутствующего — -2, и go-redis отдаёт их как отрицательные длительности. Если нужен именно факт истечения, ставьте TTL в доли секунды и один короткий сон в самом конце теста — но это последняя мера, а не паттерн. Вторая грабля — вечные ключи: Set с нулевым TTL не истекает никогда, и такой «кэш» живёт до конца прогона, портя соседние тесты. Помогают FlushAll и отдельная логическая DB из предыдущего раздела.

Что вернёт TTL сразу после Set

Прочитайте код и выберите, что он выведет:

cache.Set(ctx, "session:9", "abc", time.Minute)

d, err := cache.TTL(ctx, "session:9").Result()
// err == nil

Чек-лист запуска Redis в тесте#

  • tcredis.Run вместо GenericContainer: порт 6379 и готовность уже настроены
  • образ — первый аргумент Run; тег меняется строкой, отдельной опции нет
  • WithSnapshotting и WithLogLevel уходят во флаги redis-server; подробный лог выручает при падении
  • адрес — ConnectionString: redis://хост:маппированный_порт, порт выбирает Docker, не вы
  • клиент — redis.ParseURL и redis.NewClient; Ping как минимальная проверка
  • кэш-мисс — это redis.Nil: проверяйте через errors.Is, не сравнивайте текст ошибки
  • TTL — остатком через команду TTL; сон в тесте — последняя мера
  • изоляция — FlushAll после теста или Options{DB: 1} на втором клиенте

Соберите запуск Redis

Восстановите вызовы: старт контейнера, снапшоты, адрес и разбор URL.

r, err := tcredis.(ctx, "redis:7", tcredis.(10, 1), ) uri, err := r.(ctx) opts, err := redis.(uri) client := redis.NewClient(opts)

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

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

Строки

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

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

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

Термин

redis.Run

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

Вход в модуль: образ строкой первым аргументом, дальше кастомайзеры. Возвращает типизированный RedisContainer с ошибкой; порт 6379/tcp и wait на лог готовности настроены по умолчанию.

1 / 6

Что дальше: Redis — второй хранилищный модуль курса, следующий на очереди — MongoDB с коллекциями вместо ключей, а пока закрепите связку кэша с клиентом. Пятый урок — тестируем pgx на живой базе — показывает тот же каркас на SQL: TestMain, ConnectionString, уборка в defer. Клиент go-redis с наскоку не освоить — курс go-redis разбирает его по косточкам, от строк и хэшей до пайплайнов. Обзор всего пути — курс testcontainers: от жизненного цикла контейнера до CI и параллельности. Параллельность тоже держите в голове: несколько тестов на одном Redis-сервере спокойно живут вместе, если каждый чистит за собой. А когда понадобится тонкая настройка старта, пригодится урок про wait-стратегии: что именно модуль ждёт внутри Run и как писать свои стратегии. Каркас из этого урока переживёт десятки тестов без единой правки: контекст, Run, уборка в defer. И проверка на честность напоследок: уберите TTL из Set и посмотрите, какой тест поймает вечный ключ.

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • redis.Run поднимает настоящий Redis: образ — первый аргумент, порт 6379 и wait на лог готовности настроены модулем.
  • Опции WithSnapshotting, WithLogLevel, WithConfigFile и WithTLS переводятся в аргументы redis-server; тег образа меняется строкой, отдельной опции нет.
  • ConnectionString отдаёт redis://host:порт (rediss при TLS); клиент собирается через redis.ParseURL и redis.NewClient.
  • Кэш-мисс — это redis.Nil; TTL проверяйте остатком через команду TTL, а состояние чистите FlushAll или отдельной логической DB.
  • Один контейнер на пакет через TestMain: старт быстрый, а клиент живёт до конца прогона.

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

Соберите тестовый пакет с TestMain: redis.Run с WithSnapshotting и WithLogLevel, подключение через ConnectionString, redis.ParseURL и redis.NewClient. Напишите тест кэш-хита и кэш-мисса с errors.Is(err, redis.Nil) и проверкой остатка через TTL. Затем поднимите второй клиент с Options{DB: 1}, положите ключ с одним именем в обе базы и убедитесь, что они не мешают друг другу. В конце включите LogLevelDebug, найдите строку уровня debug в логах контейнера и сравните время первого и второго запуска — разница и есть цена кеша образов.

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

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

Можно ли заменить модуль на GenericContainer с тем же образом?

Технически да: задайте образ, откройте порт 6379 и соберите wait-стратегию на лог готовности и слушающий порт сами. Но вы потеряете типизированный RedisContainer с ConnectionString, схему redis://, генерацию TLS-сертификатов на лету и опции, которые переводятся во флаги redis-server. Модуль — тот же GenericContainer внутри, только уже собранный под Redis: меньше кода в тесте и меньше флейков. Образ поднимется один и тот же — различие в объёме кода, который вы поддерживаете.

Почему в ConnectionString странный порт вместо 6379?

Docker маппирует порт контейнера на свободный порт хоста, и этот порт меняется от запуска к запуску. ConnectionString делает эту работу за вас через PortEndpoint: адрес всегда актуальный, а параллельные прогоны не дерутся за один порт. Жёстко прописанный 6379 в клиенте — классическая ошибка переноса кода с локального Redis на тестовый: локально она работает, в CI падает. Для отладки печатайте адрес из ConnectionString одной строкой в лог теста — сразу видно, с чем клиент реально соединился.

Как проверить реальное истечение TTL, не тормозя тесты?

Сначала проверяйте остаток командой TTL — это покрывает большинство случаев без ожидания. Если нужен именно факт истечения, ставьте TTL в десятки миллисекунд и один короткий time.Sleep в конце теста: один сон на грани дешевле пачки проверок. Альтернатива — не ждать истечение, а изменить время жизни командой и убедиться, что кэш-логика реагирует. Долгий сон на несколько секунд в тесте — всегда запах: пакет тормозит, а флейк на нагруженной машине всё равно придёт.

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

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

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

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

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

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

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