Ночью упал 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 — не эмулятор заглушек, а частичная реализация протокола, поэтому код в тестах выполняется по-настоящему: сериализация значений, построение ключей и ветки ошибок проверяются честно. Тот же принцип работает и наоборот: если тест зелёный на фейке, но краснеет на настоящем сервере, подозревайте тайминги и сетевую физику, а не протокол — это подсказка, где искать интеграционный кейс.
Хелпер и первый тест
GOfunc 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 на пакет: самый дешёвый способ сделать тесты непараллелизуемыми, потому что каждый тест будет переопределять её под себя и ловить чужие данные в ключах. Инъекция выглядит как лишний параметр в сигнатуре, но именно она позволяет заменить сервер одной строкой в хелпере, не трогая продакшен-код.
Контракт кэша в двух под-тестах
GOfunc 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 позволит покрыть их таблицей случаев без единой паузы.
Истечение сессии без сна
GOfunc 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 проверяется так же: включили, вызвали, убедились в ветке обработки сбоя, выключили.
Тесты против одного общего Redis — источник гонок, которые воспроизводятся редко и злят сильнее постоянных: параллельные пакеты пишут одинаковые ключи, один тест Del-ит ключ другого, сборка краснеет без изменений в коде. Miniredis снимает проблему изоляцией по умолчанию: свой сервер на каждый тест. Если часть тестов всё же ходит в настоящий Redis — раздайте каждому префикс ключей из t.Name() и не включайте параллельность на уровне пакета.
Чего miniredis не поймает#
Границы фейка важно знать, чтобы не получить зелёные тесты на красном проде. Miniredis не эмулирует сетевые сбои: таймауты, разрывы соединений, connection pool timeout и поведение пула остаются неизученными — их закрывает интеграционный дым против настоящего Redis из урока про ошибки go-redis. Однопоточность и мгновенность ответов скрывают латентность, а упрощённая поддержка Lua-скриптов означает, что сложные скрипты проверяются только на реальном сервере. Eviction при нехватке памяти тоже не происходит: политика maxmemory и поведение под давлением — темы интеграционных проверок. Рабочая пропорция, которая выживает в проектах: почти все тесты на miniredis плюс короткий набор дымовых проверок в CI против настоящего сервера — сеть и пул проверяются там, где они есть. Фейк отвечает за логику, настоящий сервер — за физику, и путать их роли не стоит.
miniredis против настоящего Redis в CI#
| Аспект | miniredis | Настоящий Redis в CI |
|---|---|---|
| Скорость | Миллисекунды на тест | Секунды: контейнер, сеть, подключение |
| Изоляция | Свой сервер на тест | Общий сервер: нужны префиксы и дисциплина |
| Сетевые сбои | Не эмулируются | Настоящие: таймауты и разрывы проверяются честно |
| TTL | FastForward сдвигает время | Только реальные паузы или короткие сроки |
| Lua-скрипты | Поддержка частичная | Полная |
Спорное мнение: моки на интерфейсе над Redis чаще вредны, чем полезны. Обёртка с ручным моком проверяет, что код вызвал Get, — но не проверяет, что ключ построен правильно, что TTL выставлен и что redis.Nil обработан как промах, а не как сбой; тест зелёный, а прод падает на опечатке в имени ключа. Miniredis дешевле в поддержке: мокать нечего, тестируется реальный путь данных. Интерфейс и мок остаются правильным инструментом там, где реальный компонент нельзя поднять в памяти — база с процедурами, платёжный шлюз. Redis поднимается в памяти процесса за миллисекунды, поэтому моков на него в моих проектах почти не осталось, а единственный живой мок — на шлюзе оплаты, который поднимать и правда негде.
Всё из урока запускается локально одной командой: go test ./... с miniredis не требует ни Docker, ни запущенного Redis — зависимости качаются один раз и живут в модуле. Песочница проекта сетевые вызовы не открывает, но логику веток redis.Nil и сравнения значений можно скопировать в песочницу на локальном Go и проверить на подставных данных. Если набор вдруг замедлился, посмотрите сначала на -v тайминги, а не на догадки: обычно тянут тяжёлые фикстуры вокруг, а не сам фейк.
Проверка: изоляция
Почему в тестах 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)Соберите тестовый хелпер
Допишите запуск фейкового сервера, адрес для клиента и пометку функции как хелпера.
Соберите тест истечения
Расставьте строки по порядку: запуск сервера, запись с TTL, сдвиг времени, проверка промаха.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты с miniredis
Тестовая база готова: хелпер с 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 тест на построение ключей: одна функция, десять случаев.
Личные заметкиПусто
Вопросы и ответы по уроку
Зачем miniredis, если можно гонять тесты против Redis в Docker?
Контейнер медленнее на порядки, требует Docker на машине каждого разработчика и делит один сервер между параллельными тестами — отсюда флейки. Miniredis изолирует тесты автоматически и запускается везде, где есть Go; контейнер остаётся для интеграционного дыма.
Все ли команды Redis поддерживает miniredis?
Повседневный набор — да: строки, хэши, списки, множества, ZSET, Pub/Sub, INCR, EXPIRE. Сложные Lua-скрипты и часть серверных команд поддержаны частично или нет — точный список есть в README проекта. Если тест упал на неподдержанной команде, это повод вынести её в интеграционный тест.
Как тестировать код, который ретраит вызовы?
SetError включает ошибку для всех команд — этого хватает, чтобы проверить ветку с ретраем на уровне логики. Честное поведение клиента с backoff проверяется интеграционно: miniredis отвечает мгновенно, и тайминги ретраев там не имеют смысла.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
JWT-аутентификация в Go: golang-jwt и bcrypt
Разбираем JWT в Go: устройство токена, golang-jwt v5, подписи HMAC и RSA, refresh-токены, bcrypt, middleware авторизации и лучшие практики.
- Понимать анатомию JWT и почему payload не зашифрован
- Выпускать и проверять токены через golang-jwt v5
- Хешировать пароли bcrypt и сравнивать безопасно
- Писать middleware аутентификации для chi и Gin