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

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

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

Урок 24 из 25 ~18 мин +20 XP

Тесты Redis-кода с miniredis

Тесты Redis-кода в Go чаще всего ломаются не на логике, а на окружении: CI не поднял контейнер, два пакета подрались за одинаковые ключи, новый разработчик не смог запустить набор без настройки. miniredis заменяет настоящий сервер фейковым Redis в памяти — тесты идут за миллисекунды, без Docker и сети, и при этом проверяют реальный путь данных.

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

Ночью упал CI: не код, а интеграционные тесты — Redis-контейнер не поднялся после обновления раннера. Через неделю другой сюрприз: два пакета тестов запустились параллельно и чистили ключи друг друга, потому что оба писали в один Redis одинаковый product:42. Сборка краснеет, код не менялся, виноватых нет — просто окружение слишком хрупкое. Дорога к тихим тестам Redis-кода в Go лежит через miniredis: реализацию протокола RESP на чистом Go, которая живёт в памяти процесса и ведёт себя как настоящий сервер. Тесты перестают зависеть от Docker, сети и соседей, выполняются за миллисекунды и запускаются у любого разработчика без настройки окружения. В уроке — подключение фейка, структура тестируемого кода, проверка TTL без сна, эмуляция сбоев без обёрток над клиентом и честные границы того, что фейк не проверит.

miniredis: фейковый Redis в памяти для тестов#

miniredis — библиотека github.com/alicebob/miniredis/v2, которая поднимает фейковый Redis на локальном порту внутри теста. RunT открывает слушатель и регистрирует закрытие через t.Cleanup, Addr отдаёт адрес, а клиент go-redis подключается к нему как к обычному серверу и не знает о подмене. Поддержан повседневный набор команд: строки, хэши, списки, множества, ZSET, INCR, EXPIRE, Pub/Sub без персистентности. Настройки не нужны: каждый тест получает свой изолированный сервер с пустой базой, поэтому тесты можно гонять параллельно и в любом порядке. Важная деталь: miniredis — не эмулятор заглушек, а частичная реализация протокола, поэтому код в тестах выполняется по-настоящему: сериализация значений, построение ключей и ветки ошибок проверяются честно. Тот же принцип работает и наоборот: если тест зелёный на фейке, но краснеет на настоящем сервере, подозревайте тайминги и сетевую физику, а не протокол — это подсказка, где искать интеграционный кейс.

Хелпер и первый тест

GO
func newTestRedis(t *testing.T) *redis.Client {
        t.Helper()
        mr := miniredis.RunT(t) // сервер закроется после теста
        return redis.NewClient(&redis.Options{Addr: mr.Addr()})
}

func TestModeFromCache(t *testing.T) {
        rdb := newTestRedis(t)

        rdb.Set(ctx, "cfg:mode", "prod", 0)

        got, err := rdb.Get(ctx, "cfg:mode").Result()
        if err != nil || got != "prod" {
                t.Fatalf("get: %v %q", err, got)
        }
}

RunT поднимает сервер и закрывает его через t.Cleanup: пара Run и Close вручную не нужна, а адрес каждый раз новый — тесты изолированы. t.Helper делает трассировку читаемой: строка падения указывает на тест, а не на хелпер. Клиент не отличает фейк от сервера: код под тестом выполняется как в бою, только быстрее на порядки.

Готовим код к тестам: инъекция клиента#

Код, который должен проверяться, не создаёт клиент с жёстким адресом сам — иначе подмена невозможна. Правило то же, что и везде в тестировании: зависимости входят снаружи. Функции получают *redis.Client параметром, структуры — полем, а main при старте строит один клиент на процесс и раздаёт его конструкторам; в тестах вместо него — клиент к miniredis. Хардкод localhost:6379 внутри хендлеров превращает юнит-тесты в интеграционные и возвращает зависимость от Docker, от которой уходили. Отдельная ловушка — глобальная переменная rdb на пакет: самый дешёвый способ сделать тесты непараллелизуемыми, потому что каждый тест будет переопределять её под себя и ловить чужие данные в ключах. Инъекция выглядит как лишний параметр в сигнатуре, но именно она позволяет заменить сервер одной строкой в хелпере, не трогая продакшен-код.

Контракт кэша в двух под-тестах

GO
func TestProfileCache(t *testing.T) {
        rdb := newTestRedis(t)
        db := &fakeDB{profile: Profile{Name: "Аня"}}
        svc := NewProfileService(rdb, db)

        t.Run("первый вызов идёт в базу и греет кэш", func(t *testing.T) {
                p, err := svc.Get(ctx, 42)
                if err != nil || p.Name != "Аня" {
                        t.Fatalf("get: %v %v", err, p)
                }
                if db.calls != 1 {
                        t.Fatalf("db calls = %d, want 1", db.calls)
                }
        })

        t.Run("второй берёт из Redis", func(t *testing.T) {
                if _, err := svc.Get(ctx, 42); err != nil {
                        t.Fatal(err)
                }
                if db.calls != 1 {
                        t.Fatalf("db calls = %d, want 1: кэш не сработал", db.calls)
                }
        })
}

Два под-теста фиксируют контракт: первый вызов читает базу один раз, второй — ни разу. Счётчик на fakeDB вместо мока на весь клиент: проверяется эффект, а не способ его достижения. Контракт из урока про кэширование запросов здесь проверяется буквально — такие тесты ловят случайное удаление записи в кэш при рефакторинге.

Table-driven тесты на ключи и границы#

Повторяющиеся кейсы собирайте в таблицу: одна функция теста прогоняет срез случаев с именами, и провал указывает на конкретный случай. Для Redis-кода таблица чаще всего про построение ключей и границы: промах отвечает ErrCacheMiss, попадание возвращает значение, сбой Redis уходит наверх как есть, срок жизни после записи ненулевой. Каждый случай получает свой miniredis из хелпера — изоляция сохраняется, и t.Parallel на под-тестах становится безопасным. Таблицу гоняют обычным циклом с t.Run: имя случая видно в выводе -run и в логе CI. Это стандартная механика из продвинутого курса про тестирование, здесь она ничем не отличается, кроме фейка вместо базы. Ошибка новичка — один общий rdb на всю таблицу: состояние перетекает между случаями, тесты начинают зависеть от порядка и ломаются при добавлении нового. Новый сервер на случай стоит миллисекунды и снимает класс проблем целиком.

TTL и сбои: FastForward и SetError#

Время — главная боль тестов с TTL: спать минуту в тесте никто не будет. Miniredis умеет двигать часы: метод FastForward прибавляет к внутреннему времени сервера заданный интервал, ключи с истёкшим TTL умирают, и тест проверяет срок годности за миллисекунды. Ещё два метода повседневного набора: SetError подменяет ответ сервера заданной ошибкой — так проверяются ветки обработки сбоев, а Dump с Load сохраняют и восстанавливают состояние между этапами. Для проверки самого факта продления у клиента есть rdb.TTL: убедиться, что Expire вообще вызван, можно одним вызовом без абстракций над временем в продакшен-коде. Это дешевле эмуляции часов и не добавляет интерфейсов туда, где они не нужны. Если в коде появятся сложные формулы продления — скользящее окно для сессий, продление только для активных — именно FastForward позволит покрыть их таблицей случаев без единой паузы.

Истечение сессии без сна

GO
func TestSessionExpiry(t *testing.T) {
        mr := miniredis.RunT(t)
        rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})

        rdb.Set(ctx, "session:abc", "42", time.Minute)
        if ttl, _ := rdb.TTL(ctx, "session:abc").Result(); ttl <= 0 {
                t.Fatal("TTL не выставлен")
        }

        mr.FastForward(2 * time.Minute) // сдвигаем время сервера

        if _, err := rdb.Get(ctx, "session:abc").Result(); !errors.Is(err, redis.Nil) {
                t.Fatal("сессия должна истечь")
        }
}

FastForward на две минуты убивает ключ с TTL в одну — тест завершается за миллисекунды вместо минуты сна. Проверка TTL сразу после записи ловит забытый Expire: ключ жив, но бессрочно — тихая ошибка, разобранная в уроке про TTL и истечение. SetError проверяется так же: включили, вызвали, убедились в ветке обработки сбоя, выключили.

Чего miniredis не поймает#

Границы фейка важно знать, чтобы не получить зелёные тесты на красном проде. Miniredis не эмулирует сетевые сбои: таймауты, разрывы соединений, connection pool timeout и поведение пула остаются неизученными — их закрывает интеграционный дым против настоящего Redis из урока про ошибки go-redis. Однопоточность и мгновенность ответов скрывают латентность, а упрощённая поддержка Lua-скриптов означает, что сложные скрипты проверяются только на реальном сервере. Eviction при нехватке памяти тоже не происходит: политика maxmemory и поведение под давлением — темы интеграционных проверок. Рабочая пропорция, которая выживает в проектах: почти все тесты на miniredis плюс короткий набор дымовых проверок в CI против настоящего сервера — сеть и пул проверяются там, где они есть. Фейк отвечает за логику, настоящий сервер — за физику, и путать их роли не стоит.

miniredis против настоящего Redis в CI#

АспектminiredisНастоящий Redis в CI
СкоростьМиллисекунды на тестСекунды: контейнер, сеть, подключение
ИзоляцияСвой сервер на тестОбщий сервер: нужны префиксы и дисциплина
Сетевые сбоиНе эмулируютсяНастоящие: таймауты и разрывы проверяются честно
TTLFastForward сдвигает времяТолько реальные паузы или короткие сроки
Lua-скриптыПоддержка частичнаяПолная

Спорное мнение: моки на интерфейсе над Redis чаще вредны, чем полезны. Обёртка с ручным моком проверяет, что код вызвал Get, — но не проверяет, что ключ построен правильно, что TTL выставлен и что redis.Nil обработан как промах, а не как сбой; тест зелёный, а прод падает на опечатке в имени ключа. Miniredis дешевле в поддержке: мокать нечего, тестируется реальный путь данных. Интерфейс и мок остаются правильным инструментом там, где реальный компонент нельзя поднять в памяти — база с процедурами, платёжный шлюз. Redis поднимается в памяти процесса за миллисекунды, поэтому моков на него в моих проектах почти не осталось, а единственный живой мок — на шлюзе оплаты, который поднимать и правда негде.

Проверка: изоляция

Почему в тестах miniredis лучше инъекции клиента, чем глобальная переменная rdb?

Проверка: время в тестах

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

Что вернёт Get при включённой ошибке?

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

mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})

mr.SetError("LOADING Redis is loading the dataset in memory")

_, err := rdb.Get(ctx, "k").Result()
fmt.Println(err != nil)

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

Допишите запуск фейкового сервера, адрес для клиента и пометку функции как хелпера.

func newTestRedis(t *testing.T) *redis.Client { t.() mr := miniredis.(t) return redis.NewClient(&redis.Options{Addr: mr.()}) }

Соберите тест истечения

Расставьте строки по порядку: запуск сервера, запись с TTL, сдвиг времени, проверка промаха.

Строки

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

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

Карточки: тесты с miniredis

Термин

miniredis

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

Реализация протокола RESP в памяти процесса: RunT поднимает сервер на тест, Addr отдаёт адрес, закрытие через t.Cleanup. Поддержаны строки, хэши, списки, множества, ZSET, Pub/Sub.

1 / 5

Тестовая база готова: хелпер с RunT, контракт кэша в t.Run, время через FastForward и сбои через SetError. Материал для тестов накоплен по всему курсу: контракт кэширования запросов, ветки ошибок go-redis, сессии веб-приложения и rate limiter — каждый тестируется по шаблону из этого урока. Тестирование в общем виде — t.Run и table-driven tests из продвинутого курса. Остался последний урок — итоговый проект: сервис коротких ссылок: соберём всё вместе и прикроем этим же набором тестов. Полный план — на странице курса по Redis.

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

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

Вывод

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

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

  • miniredis — фейковый Redis в памяти: RunT поднимает сервер на тест и закрывает через t.Cleanup, клиент подключается как к обычному серверу.
  • Изоляция по умолчанию: свой сервер и пустая база на каждый тест — параллельный запуск без гонок за ключи.
  • Инъекция клиента вместо глобальной переменной: функции и структуры получают *redis.Client снаружи, тест подменяет адресом фейка.
  • FastForward сдвигает часы сервера — TTL проверяется за миллисекунды; rdb.TTL ловит забытый Expire сразу после записи.
  • SetError эмулирует сбой сервера: ветки обработки ошибок проверяются без обёрток и без настоящего сбоя.
  • Границы: сеть, пул, латентность, eviction и сложные Lua — территория интеграционного дыма против настоящего Redis.

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

Переведите функцию кэша из урока про кэширование запросов на тесты с miniredis: два под-теста на промах и попадание, проверка TTL после записи и SetError-тест на недоступность. Затем уберите инъекцию клиента и верните глобальную переменную — запустите набор с -count 5 и -race и сравните стабильность. В конце добавьте table-driven тест на построение ключей: одна функция, десять случаев.

Личные заметкиПусто
Хранятся только в вашем браузере (Redis · Тесты Redis-кода с miniredis)

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

Зачем miniredis, если можно гонять тесты против Redis в Docker?

Контейнер медленнее на порядки, требует Docker на машине каждого разработчика и делит один сервер между параллельными тестами — отсюда флейки. Miniredis изолирует тесты автоматически и запускается везде, где есть Go; контейнер остаётся для интеграционного дыма.

Все ли команды Redis поддерживает miniredis?

Повседневный набор — да: строки, хэши, списки, множества, ZSET, Pub/Sub, INCR, EXPIRE. Сложные Lua-скрипты и часть серверных команд поддержаны частично или нет — точный список есть в README проекта. Если тест упал на неподдержанной команде, это повод вынести её в интеграционный тест.

Как тестировать код, который ретраит вызовы?

SetError включает ошибку для всех команд — этого хватает, чтобы проверить ветку с ретраем на уровне логики. Честное поведение клиента с backoff проверяется интеграционно: miniredis отвечает мгновенно, и тайминги ретраев там не имеют смысла.

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

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

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

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

JWT-аутентификация в Go: golang-jwt и bcrypt

Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.

  • Понимать анатомию JWT и почему payload не зашифрован
  • Выпускать и проверять токены через golang-jwt v5
  • Хешировать пароли bcrypt и сравнивать безопасно
  • Писать middleware аутентификации для chi и Gin