Содержание урока · 9 разделов
Сценарий для старта: команда писала задачу ночной сводки и тест к ней, который запускал планировщик и ждал тика со сном в две секунды. На машине автора тест был зелёным, в CI падал через раз: на перегруженном раннере планировщик не успевал. Вторая версия теста спала десять секунд — CI замедлился, а падения остались. Причина в постановке: тест пытался проверить одновременно три вещи — что строка расписания валидна, что планировщик запускает задачу вовремя и что сводка считается правильно. Каждая из этих вещей проверяется своим инструментом, и ни для одной из них не нужен сон. Так же, как HTTP-хендлер тестируют без поднимаемого сервера, задачу тестируют без тикающего расписания.
Тестирование cron: логика отдельно от расписания#
Тестирование cron начинается с разделения, а не с инструментов. Задача — это структура с методом Run и зависимостями в полях; механизм из урока про задачи-структуры даёт для этого всё: интерфейс Job, конструктор с зависимостями, pointer-приёмник. Планировщик — инфраструктура, которая в момент тика вызывает Run. Раз структура живёт сама по себе, тест создаёт её с фейковыми зависимостями, вызывает Run и проверяет результат — те же правила, что и в уроке про тестирование для обычных функций. Планировщик в этом тесте не участвует: доверять тому, что cron вызовет зарегистрированную задачу, — разумно, библиотека покрыта собственными тестами. Ваша ответственность — то, что происходит внутри тика, и строки расписаний, которые вы передаёте в регистрацию из конфига. Побочный бонус разделения: тесты задач без планировщика независимы друг от друга и помечаются t.Parallel, тогда как один общий живой экземпляр Cron вынуждал бы согласовывать их между собой.
Задача и тест прямого вызова Run
GOtype 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-префикс из урока про часовые пояса учтён, а спецификация «в девять утра» действительно означает девять утра в нужной зоне, а не в зоне процесса.
Табличный тест расписаний из конфига
GOfunc 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; интеграционная проверка — страховка, а не основа.
Кейс: тест ставил задачу на @every 1s с секундным парсером, спал полторы секунды и ждал двух тиков. На локальной машине проходило, на CI раннер под нагрузкой давал задержку планирования — второй тик не успевал, тест краснел, релизы стопорились на «мистических» падениях. Второй дефект того же теста незаметнее: планировщик не останавливали через c.Stop() в cleanup, и горутина тика жила до конца пакета — подключённый goleak из урока про утечки начал падать в случайных тестах. Лечение: сны заменяются прямыми вызовами и Next-вычислениями, а живой планировщик, если он в тесте заведён, останавливается отложенно — t.Cleanup(func() { c.Stop() }).
Чеклист тестов задач#
- Логика задачи живёт в структуре: Run вызывается и из планировщика, и из теста.
- Прямой вызов Run с фейковыми зависимостями — основа покрытия задачи.
- Строки расписаний из конфига закрыты табличным тестом ParseStandard.
- Календарные ожидания проверяются Schedule.Next от time.Date с явной зоной.
- time.Now внутри задачи вынесено в поле-заглушку для подмены в тестах.
- Живой планировщик в тесте — один smoke-тест, остановленный через t.Cleanup.
Соберите тест расписания
Впишите парсер расписания, аварийное прерывание теста и мягкое сообщение о расхождении.
Расставьте строки теста задачи
Расставьте строки в порядке файла: сигнатура теста, сборка задачи с фейком, вызов, проверка и сообщение.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: тесты задач
Пакет robfig/cron и команда go test в песочнице недоступны, поэтому табличный тест там не исполнится, — но обе его идеи проверяются обычной функцией main: фиксированный момент собирается через time.Date, а календарную математику с арифметикой дат песочница исполняет, потому что пакет time в ней есть. Упражнение: воспроизведите предикт без библиотеки — найдите следующий момент, кратный девяти утра, вручную и сверьте с ожидаемой строкой. Полные тесты с ParseStandard и фейками запускаются локально из модуля второго урока.
Тесты задач 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 к пакету задач и убедитесь, что все тесты проходят после остановки планировщиков.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли протестировать, что задача сработает ровно в девять утра?
Да, без ожидания: ParseStandard возвращает Schedule, а его Next от фиксированного момента в девять утра пятницы покажет точный следующий запуск. Сравнение с ожидаемым временем фиксирует календарное поведение. Ждать реального тика в тесте не требуется ни для этого, ни для любой другой проверки расписания.
Что делать с задачей, которая вызывает time.Now внутри?
Вынесите время в поле структуры: поле now типа func() time.Time, прод-конструктор ставит time.Now, тест — заглушку с фиксированным моментом. Логика становится детерминированной: одна и та же дата подставляется в выборки и сравнения при каждом прогоне теста.
Почему тест на живом планировщике считается плохой практикой?
Он медленный, зависит от таймингов и в основном повторяет тесты самой библиотеки. Планировщик, загруженный расписанием на 100 миллисекундах, на перегруженном CI не гарантирует нужное число тиков в отведённое окно — тест начинает падать случайно. Такой тест допустим один, как smoke-проверка сборки конфига до тиков, с запасом времени и остановкой планировщика.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь