Тестирование кода с датами

Код, работающий с датами, отличается от большинства вычислительных задач своей нестабильностью в тестовой среде. Значение, возвращаемое текущим временем, меняется при каждом запуске, что приводит к флаканию тестов и невозможности воспроизведения ошибок.

Основная сложность заключается в источнике истины времени — системных часах. Любая логика, завязанная на текущий момент, становится зависимой от среды исполнения, часового пояса и даже локальных настроек операционной системы.

В библиотеке Luxon работа с временем инкапсулирована через DateTime, что делает тестирование более предсказуемым при правильной организации кода, однако сама проблема недетерминированности полностью не исчезает.


Детеминизм как основа тестирования временной логики

Тестирование временных сценариев опирается на принцип фиксированного времени выполнения. Любой тест, который зависит от now, должен либо:

  • получать «замороженное» время
  • либо использовать внедрённую зависимость времени
  • либо переопределять системный источник времени

Без этого невозможно гарантировать стабильный результат.

В Luxon ключевым источником текущего времени выступает:

DateTime.now()

и глобальная настройка:

Settings.now

Управление текущим временем через Settings.now

Внутренний механизм Luxon позволяет переопределять системное время через Settings.now. Это один из наиболее чистых способов обеспечить стабильность тестов.

Пример:

import { DateTime, Settings } from "luxon";

beforeEach(() => {
  Settings.now = () => new Date("2025-01-01T00:00:00Z").valueOf();
});

afterEach(() => {
  Settings.now = () => Date.now();
});

Теперь любые вызовы:

DateTime.now()

будут возвращать фиксированное значение.

Особенности использования Settings.now

  • работает глобально для всей библиотеки
  • влияет на все экземпляры DateTime
  • требует обязательного сброса после теста
  • может конфликтовать с параллельными тестами

Фиксация времени через конструкторы DateTime

Вместо использования текущего времени часто применяется явное создание даты:

DateTime.fromISO("2025-01-01T00:00:00Z")

или

DateTime.fromObject({
  year: 2025,
  month: 1,
  day: 1
});

Такой подход полностью исключает зависимость от системного времени и предпочтителен для большинства юнит-тестов.

Преимущества явного создания времени

  • отсутствие скрытых зависимостей
  • высокая читаемость тестов
  • воспроизводимость

Ограничения

  • не тестируется логика “текущего момента”
  • требует дополнительной абстракции в бизнес-коде

Инъекция времени через абстракцию

Практика тестируемой архитектуры предполагает вынос источника времени в отдельный слой.

Пример:

export function createClock() {
  return {
    now: () => DateTime.now()
  };
}

Использование:

function createService(clock) {
  return {
    isExpired(date) {
      return date < clock.now();
    }
  };
}

В тесте:

const fixedClock = {
  now: () => DateTime.fromISO("2025-01-01T00:00:00Z")
};

Такой подход устраняет глобальные зависимости библиотеки Luxon и делает код полностью контролируемым.


Использование фиктивных таймеров в Jest

Фреймворк Jest предоставляет механизм подмены времени:

jest.useFakeTimers();
jest.setSystemTime(new Date("2025-01-01T00:00:00Z"));

После этого любые вызовы Date.now() и косвенно DateTime.now() (если не переопределён Settings.now) будут возвращать фиксированное значение.

Типичный сценарий

beforeEach(() => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date("2025-01-01T00:00:00Z"));
});

afterEach(() => {
  jest.useRealTimers();
});

Ограничения

  • требует аккуратного сброса состояния
  • может влиять на асинхронные операции
  • иногда конфликтует с библиотеками времени

Аналогичный подход в Vitest

Фреймворк Vitest использует похожую концепцию:

vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));

Отличие заключается в интеграции с Vite и более легковесной реализации.


Работа с часовыми поясами в тестах

Luxon активно использует временные зоны через zone.

Типичная проблема — различие между локальной зоной тестовой среды и ожидаемой зоной бизнес-логики.

Пример явного задания:

DateTime.fromISO("2025-01-01T00:00:00Z", {
  zone: "utc"
});

Или:

DateTime.now().setZone("Europe/Paris");

Тестирование зон

Корректный тест всегда фиксирует:

  • входную временную зону
  • ожидаемую зону результата
  • нормализацию в UTC при сравнении

Сравнение дат: ловушки равенства

Объекты DateTime нельзя сравнивать напрямую:

date1 === date2 // некорректно

Правильный подход:

date1.toMillis() === date2.toMillis()

или:

date1.equals(date2)

Внутри Luxon метод equals учитывает момент времени и некоторые параметры зоны.


Нормализация времени перед сравнением

Частая ошибка тестирования — сравнение дат в разных зонах без нормализации.

Рекомендуемый подход:

const a = DateTime.fromISO("2025-01-01T10:00:00+03:00").toUTC();
const b = DateTime.fromISO("2025-01-01T07:00:00Z");

expect(a.toISO()).toBe(b.toISO());

Тестирование интервалов и длительностей

Работа с разницей времени требует осторожности.

Пример:

const start = DateTime.fromISO("2025-01-01T00:00:00Z");
const end = DateTime.fromISO("2025-01-02T00:00:00Z");

const diff = end.diff(start, "hours").hours;

Типичные ошибки

  • округление при переводе единиц
  • потеря точности при переходе между днями и часами
  • влияние летнего времени

Проверка в тестах

expect(diff).toBe(24);

или с допуском:

expect(diff).toBeCloseTo(24);

Моки вместо реального DateTime.now

Иногда требуется локальная подмена только части логики.

Пример:

jest.spyOn(DateTime, "now").mockReturnValue(
  DateTime.fromISO("2025-01-01T00:00:00Z")
);

Однако в Luxon этот подход менее предпочтителен, чем Settings.now, так как затрагивает только вызов метода, но не глобальную систему времени.


Тестирование бизнес-логики с дедлайнами

Сценарий проверки истечения срока:

function isExpired(expirationDate, clock) {
  return expirationDate < clock.now();
}

Тест:

const clock = {
  now: () => DateTime.fromISO("2025-01-01T00:00:00Z")
};

const expiration = DateTime.fromISO("2024-12-31T23:59:59Z");

expect(isExpired(expiration, clock)).toBe(true);

Стабильность сериализации и парсинга

Luxon активно используется для преобразования в ISO-строки:

const dt = DateTime.fromObject({ year: 2025, month: 1, day: 1 });

const iso = dt.toISO();

Тестирование должно учитывать:

  • формат строки
  • наличие timezone suffix
  • стабильность round-trip преобразования
const parsed = DateTime.fromISO(iso);

expect(parsed.toISO()).toBe(iso);

Изоляция временной логики в архитектуре

Наиболее устойчивый подход — минимизация прямого использования DateTime.now() внутри бизнес-кода.

Рекомендуемая структура:

  • слой доменной логики работает с переданными датами
  • слой инфраструктуры предоставляет текущее время
  • тесты подменяют источник времени

Это снижает зависимость от глобального состояния Luxon и упрощает масштабирование тестовой базы.


Частые ошибки при тестировании времени

  • смешивание UTC и локального времени без явного указания зоны
  • использование Date.now() вместо DateTime.now()
  • отсутствие сброса Settings.now
  • сравнение объектов без нормализации
  • зависимость тестов от текущей даты выполнения

Эти ошибки приводят к нестабильным тестам и трудновоспроизводимым багам в логике расписаний, дедлайнов и таймеров.