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

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

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

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

Тестирование задач без ожидания

Тестирование cron на первый взгляд упирается в невозможное: задача срабатывает раз в сутки, значит, и тест должен ждать сутки — так звучит ошибочный вывод, из которого рождаются тесты с бесконечными снами и пропущенные проверки ночных выгрузок. На деле расписание и логика задачи разделяются без усилий: интерфейс Job объявляет один метод Run, и его можно вызвать руками из теста в любой момент. Вторая половина вопроса — сам календарь: валидность строк расписаний из конфига и момент следующего запуска проверяются без живого планировщика, через парсер и вычисление Next от фиксированного времени. Разберём тесты задач go на трёх уровнях — прямые вызовы, парсинг расписаний, контроль времени, — а заодно случай, где живой планировщик в тесте оправдан.

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

Сценарий для старта: команда писала задачу ночной сводки и тест к ней, который запускал планировщик и ждал тика со сном в две секунды. На машине автора тест был зелёным, в CI падал через раз: на перегруженном раннере планировщик не успевал. Вторая версия теста спала десять секунд — CI замедлился, а падения остались. Причина в постановке: тест пытался проверить одновременно три вещи — что строка расписания валидна, что планировщик запускает задачу вовремя и что сводка считается правильно. Каждая из этих вещей проверяется своим инструментом, и ни для одной из них не нужен сон. Так же, как HTTP-хендлер тестируют без поднимаемого сервера, задачу тестируют без тикающего расписания.

Тестирование cron: логика отдельно от расписания#

Тестирование cron начинается с разделения, а не с инструментов. Задача — это структура с методом Run и зависимостями в полях; механизм из урока про задачи-структуры даёт для этого всё: интерфейс Job, конструктор с зависимостями, pointer-приёмник. Планировщик — инфраструктура, которая в момент тика вызывает Run. Раз структура живёт сама по себе, тест создаёт её с фейковыми зависимостями, вызывает Run и проверяет результат — те же правила, что и в уроке про тестирование для обычных функций. Планировщик в этом тесте не участвует: доверять тому, что cron вызовет зарегистрированную задачу, — разумно, библиотека покрыта собственными тестами. Ваша ответственность — то, что происходит внутри тика, и строки расписаний, которые вы передаёте в регистрацию из конфига. Побочный бонус разделения: тесты задач без планировщика независимы друг от друга и помечаются t.Parallel, тогда как один общий живой экземпляр Cron вынуждал бы согласовывать их между собой.

Задача и тест прямого вызова Run

GO
type DigestTask struct {
    store DigestStore
    mail  Mailer
    date  string // 2024-03-15
}

func (t *DigestTask) Run() {
    rows, err := t.store.Daily(t.date)
    if err != nil {
        return // лог и метрика ошибки — за кадром
    }
    t.mail.Send(digestFrom(rows))
}

func TestDigestTask(t *testing.T) {
    fake := NewFakeStore(map[string][]Row{"2024-03-15": rows3()})
    task := &DigestTask{store: fake, mail: NewSpyMailer(), date: "2024-03-15"}

    task.Run()

    if got := fake.Calls(); got != 1 {
        t.Fatalf("обращений к хранилищу: %d", got)
    }
}

Задача объявлена как структура с зависимостями в полях — планировщик вызовет тот же Run, что и тест. Фейковое хранилище и шпион-отправитель подставлены в конструктор, поэтому тест исполняется за миллисекунды и не зависит от времени суток. Тот же Run вызывается и из планировщика — контракт один, тестировать его дважды не требуется.

Проверка: что проверяет прямой вызов

Тест создаёт задачу с фейковым хранилищем и вызывает task.Run() напрямую. Что именно проверяет такой тест?

Тесты задач go: парсинг расписаний из конфига#

Вторая часть контракта — строки расписаний, которые приходят из конфига и уходят в AddJob. Невалидная строка возвращает ошибку, а не панику, и задача из урока про конфиг обязана её обработать при старте; тесты задач go закрепляют этот контракт табличным набором: валидные строки парсятся, битые — нет. Табличные тесты из урока про тестирование здесь работают один в один: на каждый вариант — ожидание ошибки или её отсутствия. Отдельный класс — календарные ожидания: парсер возвращает объект Schedule с методом Next, и от него можно спросить следующий запуск от фиксированного момента. Так проверяется, что CRON_TZ-префикс из урока про часовые пояса учтён, а спецификация «в девять утра» действительно означает девять утра в нужной зоне, а не в зоне процесса.

Табличный тест расписаний из конфига

GO
func TestSpecs(t *testing.T) {
    cases := []struct {
        name    string
        spec    string
        wantErr bool
    }{
        {"ночная сводка", "0 6 * * *", false},
        {"с поясом", "CRON_TZ=Asia/Yekaterinburg 0 9 * * *", false},
        {"интервал", "@every 30m", false},
        {"шесть полей", "0 */10 * * * *", true},
        {"пустая строка", "", true},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            _, err := cron.ParseStandard(tc.spec)
            if (err != nil) != tc.wantErr {
                t.Fatalf("spec %q: err = %v, ждали err: %v",
                    tc.spec, err, tc.wantErr)
            }
        })
    }
}

Пакетный парсер cron.ParseStandard принимает те же строки, что и AddJob, поэтому тест таблицей покрывает ровно то, что регистрируется в планировщике. Вариант с шестью полями падает на пятипольном парсере из урока про секунды: стандартный парсер ждёт пять полей. Таблица растёт вместе с конфигом: новая задача — новая строка в тесте.

Проверка: валидность строки из конфига

Расписания задач приходят из JSON-конфига. Какой вызов в тесте проверяет, что строка валидна?

Время под контролем: фиксированные часы#

Третий уровень — календарная математика, и здесь время подменяется фиксированным моментом. В тестах не вызывают time.Now на ожидаемое значение: момент задают конструктором time.Date с явной зоной, как советует и урок про пакет time. От фиксированного момента вычисляется Next расписания, и результат сравнивается с ожидаемым — проверяется дата, час и зона. Зона в тесте задаётся явно, потому что результат зависит от time.Local процесса, на котором тест исполняется: CI в UTC и ноутбук в Москве дадут разные строки для одного и того же выражения без префикса. Тот же приём лечит задачи, внутри которых вызывается time.Now: момент времени выносится в поле now func() time.Time, тест подставляет заглушку, и ночная логика проверяется днём. Момент следующего запуска зарегистрированной задачи виден и через Entry из урока про инспекцию, но для тестов хватит чистого Schedule.Next. Отдельная деталь: у выражения @every следующий момент считается от момента регистрации, поэтому его Next имеет смысл проверять, только если привязка к старту важна для логики — иначе достаточно валидности строки.

Что напечатает этот тестовый фрагмент?

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

loc, _ := time.LoadLocation("Europe/Moscow")
now := time.Date(2024, 3, 15, 10, 0, 0, 0, loc) // пятница
sch, _ := cron.ParseStandard("0 9 * * *")

next := sch.Next(now)
fmt.Println(next.Format("02.01 15:04"))

Уровни тестов задачи#

УровеньЧто проверяетсяИнструмент
логика задачиRun на данных: выборки, вызовы, записипрямой вызов Run с фейками
валидность расписанийстроки из конфига парсятсятабличный тест ParseStandard
календарьследующий запуск в нужный момент и зонеSchedule.Next от time.Date
сборка сервисаконфиг превращается в зарегистрированные задачиновый Cron без Start и Entries
живой прогонвсё вместе в окруженииручная проверка или один smoke-тест

Спорное: интеграционный тест на живом планировщике#

Спорная привычка, которую стоит оспорить: завести в тесте настоящий cron.NewWithSeconds, поставить @every 100ms, подождать полсекунды и посчитать тики. Такой тест кажется более честным, потому что проверяет связку целиком, но он проверяет в основном библиотеку, а не ваш код: тики у robfig/cron уже покрыты её собственными тестами, а ваши строки расписаний закрыты табличным тестом. Что остаётся — flakiness от таймингов и медленный пакет. Верное применение этой идеи — один smoke-тест на весь пакет задач: планировщик поднимается с укороченным расписанием, задач-пустышек набирается счётчик, тест подтверждает, что регистрация из конфига до тиков доведена. Такой тест допускает запас по времени и помечается как медленный. Логика задач при этом тестируется прямыми вызовами, календарь — через Next; интеграционная проверка — страховка, а не основа.

Чеклист тестов задач#

  • Логика задачи живёт в структуре: Run вызывается и из планировщика, и из теста.
  • Прямой вызов Run с фейковыми зависимостями — основа покрытия задачи.
  • Строки расписаний из конфига закрыты табличным тестом ParseStandard.
  • Календарные ожидания проверяются Schedule.Next от time.Date с явной зоной.
  • time.Now внутри задачи вынесено в поле-заглушку для подмены в тестах.
  • Живой планировщик в тесте — один smoke-тест, остановленный через t.Cleanup.

Соберите тест расписания

Впишите парсер расписания, аварийное прерывание теста и мягкое сообщение о расхождении.

sch, err := cron.("0 9 * * *") if err != nil { t.("расписание не парсится: %v", err) } next := sch.Next(now) if !next.Equal(want) { t.("next = %v, ждали %v", next, want) }

Расставьте строки теста задачи

Расставьте строки в порядке файла: сигнатура теста, сборка задачи с фейком, вызов, проверка и сообщение.

Строки

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

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

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

Термин

Прямой вызов Run

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

Задача — структура с методом Run: тест создаёт её с фейками и вызывает сам. Планировщик и время в тесте не участвуют, исполнение — миллисекунды.

1 / 6

Тесты задач go распадаются на три честных уровня вместо одного нечестного сна: прямой вызов Run проверяет логику на данных, табличный ParseStandard — строки расписаний из конфига, Next от фиксированного времени — календарь и зоны. Живой планировщик остаётся для одного smoke-теста, остановленного через cleanup. Дальше по маршруту: ретраи задач — как тестировать поведение при сбоях, и блокировки на нескольких инстансах — обёртка замка, которую стоит покрыть тем же набором. Если возвращались с пропуска — Start и Stop объясняют, что именно остаётся работать в тесте без Stop, а фейковые хранилища Redis показывают тот же приём для зависимостей с сетью. Похожая история для бота — в уроке про тесты бота. Полный маршрут — на странице курса. В домашнем задании — три уровня тестов на свою задачу.

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

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

Вывод

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

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

  • Задача — структура с Run: тест вызывает его напрямую с фейками и не ждёт тиков.
  • Расписание — инфраструктура: парсинг строк конфига закрывается табличным тестом ParseStandard.
  • Календарь проверяется через Schedule.Next от фиксированного time.Date с явной зоной.
  • time.Now внутри задачи выносится в поле-заглушку: ночная логика тестируется днём.
  • Живой планировщик в тесте — один smoke-тест с t.Cleanup и Stop, иначе goleak падает.
  • Сны в тестах проверяют тайминги CI, а не код: падения в CI — первый симптом такого теста.

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

Возьмите задачу из своего проекта и разложите покрытие по трём уровням: прямой вызов Run с фейковым хранилищем, табличный тест ParseStandard на строки из конфига, проверка Next от фиксированного времени с явной зоной. Вынесите time.Now в поле-заглушку и напишите тест, который проверяет ночную логику с подменённым моментом. Найдите в своих тестах сны в ожидании тиков и перепишите их по чеклисту; живой планировщик, если он нужен, оставьте в одном smoke-тесте с t.Cleanup(func(){ c.Stop() }). Финал — подключите goleak к пакету задач и убедитесь, что все тесты проходят после остановки планировщиков.

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

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

Можно ли протестировать, что задача сработает ровно в девять утра?

Да, без ожидания: ParseStandard возвращает Schedule, а его Next от фиксированного момента в девять утра пятницы покажет точный следующий запуск. Сравнение с ожидаемым временем фиксирует календарное поведение. Ждать реального тика в тесте не требуется ни для этого, ни для любой другой проверки расписания.

Что делать с задачей, которая вызывает time.Now внутри?

Вынесите время в поле структуры: поле now типа func() time.Time, прод-конструктор ставит time.Now, тест — заглушку с фиксированным моментом. Логика становится детерминированной: одна и та же дата подставляется в выборки и сравнения при каждом прогоне теста.

Почему тест на живом планировщике считается плохой практикой?

Он медленный, зависит от таймингов и в основном повторяет тесты самой библиотеки. Планировщик, загруженный расписанием на 100 миллисекундах, на перегруженном CI не гарантирует нужное число тиков в отведённое окно — тест начинает падать случайно. Такой тест допустим один, как smoke-проверка сборки конфига до тиков, с запасом времени и остановкой планировщика.

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

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

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

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

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь