---
title: "Тесты Redis-кода с miniredis"
description: "Тесты Redis-кода в Go с miniredis: фейковый Redis в памяти, RunT и t.Run, FastForward для TTL и SetError для сбоев. Юнит-тесты без Docker и сети."
course: "Redis в Go: кэш, сессии и очереди на go-redis"
url: "https://golangmaster.ru/courses/go-redis/lessons/24-testy-miniredis/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Redis в Go: кэш, сессии и очереди на go-redis», урок 24 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-redis/lessons/24-testy-miniredis/*

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

Ночью упал 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 вместо мока на весь клиент: проверяется эффект, а не способ его достижения. Контракт из урока про [кэширование запросов](/courses/go-redis/lessons/06-keshirovanie-zaprosov/) здесь проверяется буквально — такие тесты ловят случайное удаление записи в кэш при рефакторинге.

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

Повторяющиеся кейсы собирайте в таблицу: одна функция теста прогоняет срез случаев с именами, и провал указывает на конкретный случай. Для Redis-кода таблица чаще всего про построение ключей и границы: промах отвечает ErrCacheMiss, попадание возвращает значение, сбой Redis уходит наверх как есть, срок жизни после записи ненулевой. Каждый случай получает свой miniredis из хелпера — изоляция сохраняется, и t.Parallel на под-тестах становится безопасным. Таблицу гоняют обычным циклом с t.Run: имя случая видно в выводе -run и в логе CI. Это стандартная механика из продвинутого курса про [тестирование](/courses/go-advanced/lessons/16-testirovanie/), здесь она ничем не отличается, кроме фейка вместо базы. Ошибка новичка — один общий 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 и истечение](/courses/go-redis/lessons/05-ttl-i-istechenie/). SetError проверяется так же: включили, вызвали, убедились в ветке обработки сбоя, выключили.

> **Общий Redis в CI — флейки раз в неделю.** Тесты против одного общего Redis — источник гонок, которые воспроизводятся редко и злят сильнее постоянных: параллельные пакеты пишут одинаковые ключи, один тест Del-ит ключ другого, сборка краснеет без изменений в коде. Miniredis снимает проблему изоляцией по умолчанию: свой сервер на каждый тест. Если часть тестов всё же ходит в настоящий Redis — раздайте каждому префикс ключей из t.Name() и не включайте параллельность на уровне пакета.

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

Границы фейка важно знать, чтобы не получить зелёные тесты на красном проде. Miniredis не эмулирует сетевые сбои: таймауты, разрывы соединений, connection pool timeout и поведение пула остаются неизученными — их закрывает интеграционный дым против настоящего Redis из урока про [ошибки go-redis](/courses/go-redis/lessons/19-oshibki-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?

- ✅ Каждый тест получает изолированный сервер, и тесты можно гонять параллельно
- Так быстрее сериализация значений
- Глобальные переменные запрещены линтером
- Miniredis не работает с глобальными переменными

Изоляция — главное свойство: у каждого теста свой адрес и пустая база, параллельный запуск не создаёт гонок за ключи. Глобальный клиент завязывает все тесты на один сервер и делает порядок выполнения значимым.

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

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

- ✅ mr.FastForward и проверка redis.Nil после сдвига
- time.Sleep(time.Minute + time.Second)
- Поставить TTL в наносекунду и надеяться
- Никак: только интеграционный тест

FastForward сдвигает внутренние часы сервера: TTL-ключи умирают, тест завершается за миллисекунды. Сон делает набор медленным и нестабильным, а наносекундные сроки не проверяют формулу продления.

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

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

**Что выведет программа?**

- ✅ true: Get вернёт ошибку LOADING
- false: клиент проигнорирует ошибку
- Панику
- true, но только после ретрая

SetError подменяет ответ сервера заданной ошибкой для всех команд, и Get вернёт её как есть — серверные ответы клиент не ретраит. Так проверяются ветки обработки сбоев без обёрток над клиентом: включили, вызвали, выключили.

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

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

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

Подсказка: t.Helper помечает функцию хелпером, RunT поднимает сервер с автозакрытием, а клиенту нужен адрес через Addr.

Правильные ответы в пропусках: `Helper`, `RunT`, `Addr`

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

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

Правильный порядок строк:

1. `func TestSessionExpiry(t *testing.T) {`
2. `    mr := miniredis.RunT(t)`
3. `    rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})`
4. `    rdb.Set(ctx, "session:abc", "42", time.Minute)`
5. `    mr.FastForward(2 * time.Minute)`
6. `    if _, err := rdb.Get(ctx, "session:abc").Result(); !errors.Is(err, redis.Nil) { t.Fatal("must expire") }`

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

- **miniredis** — Реализация протокола RESP в памяти процесса: RunT поднимает сервер на тест, Addr отдаёт адрес, закрытие через t.Cleanup. Поддержаны строки, хэши, списки, множества, ZSET, Pub/Sub.
- **Изоляция тестов** — Свой сервер и пустая база на каждый тест: параллельный запуск без гонок за ключи. Общий Redis в CI — источник редких флейков.
- **FastForward** — Сдвиг внутренних часов сервера: TTL-ключи истекают, тесты проверяют срок годности за миллисекунды вместо снов.
- **SetError** — Подмена ответа сервера заданной ошибкой: ветки обработки сбоев проверяются без обёрток над клиентом.
- **Границы фейка** — Сеть, пул, латентность, eviction и сложные Lua не эмулируются: их закрывает короткий интеграционный дым против настоящего Redis.

Тестовая база готова: хелпер с RunT, контракт кэша в t.Run, время через FastForward и сбои через SetError. Материал для тестов накоплен по всему курсу: контракт [кэширования запросов](/courses/go-redis/lessons/06-keshirovanie-zaprosov/), ветки [ошибок go-redis](/courses/go-redis/lessons/19-oshibki-go-redis/), [сессии веб-приложения](/courses/go-redis/lessons/21-sessii-web/) и [rate limiter](/courses/go-redis/lessons/22-rate-limiter/) — каждый тестируется по шаблону из этого урока. Тестирование в общем виде — [t.Run и table-driven tests](/courses/go-advanced/lessons/16-testirovanie/) из продвинутого курса. Остался последний урок — [итоговый проект: сервис коротких ссылок](/courses/go-redis/lessons/25-itogovyj-proekt/): соберём всё вместе и прикроем этим же набором тестов. Полный план — на странице [курса по Redis](/courses/go-redis/).

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

- 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 отвечает мгновенно, и тайминги ретраев там не имеют смысла.
