EnglishРусский Map

Анатомия теста

title
Анатомия теста
type
summary
summary
Джонатан Фрер построчно разбирает реальный тест на Gleam: конструктор теста, настоящая БД, arrange через публичный API, сравнение значений целиком.
tags
testing, software-design, gleam
created
2026-09-13
updated
2026-09-13
lang
ru
translation_of
anatomy-of-a-test
source_updated
2026-09-13
translated
2026-09-14
translator
lllm/antigravity/gemini-3.7-flash-medium

Джонатан Фрер сетует на то, что советы по тестированию обычно сводятся либо к абстрактному жаргону, либо к игрушечным примерам, совершенно не похожим на production-тесты. Поэтому он берёт один обычный тест из своего пет-проекта и объясняет каждое принятое в нём решение. Проект представляет собой приложение для закладок на Gleam, которое скачивает, тегирует, индексирует и архивирует сохранённую ссылку в асинхронной задаче. Сам тест проверяет, что store.start_job помечает задачу как выполняющуюся и удаляет её из списка ожидающих:

pub fn start_job_marks_running_test() {
  use conn, Deps(clock:, ..) <- with_test_conn()

  mock_clock.set(clock, ts("2026-01-05T00:05:10Z"))
  let assert Ok(bookmark) = store.add_bookmark(conn, "http://example.com")
  let assert Ok(job) = store.schedule_job(conn, bookmark)

  let started_at = ts("2026-01-05T00:06:00Z")
  mock_clock.set(clock, started_at)

  let assert Ok(option.Some(started)) = store.start_job(conn, job)
  // ... field assertions ...
  store.list_pending_jobs(conn) |> should.equal(Ok([]))
}

Он не утверждает, что это образцовый тест, и в ходе разбора находит вещи, которые переделал бы.

Имена и расположение

Gleam не разрешает класть тесты рядом с тестируемым файлом, хотя Фрер предпочёл бы именно такой подход, поэтому test/ зеркально повторяет src/, и каждый модуль обычно экспортирует одну вещь, заслуживающую тестирования, и получает один файл с тестами. Он с большей охотой писал бы имена тестов в виде строк, как в it("marks a job as running") в JavaScript или в именах функций в обратных кавычках в Kotlin, поскольку в start_job_marks_running_test подчёркивания используются и внутри идентификатора, и вместо пробелов. Он признаётся, что часто придумывает название теста уже после написания кода. Важно лишь то, чтобы имя фиксировало цель, которая должна оставаться актуальной и при изменениях тела теста.

Конструктор теста

with_test_conn открывает базу данных SQLite в памяти, загружает db/schema.sql, создаёт мок часов, собирает хранилище и передаёт само хранилище вместе с его зависимостями в тест, а очистку берёт на себя конструкция use языка Gleam. Логика здесь в следующем (по его воспоминаниям, эту мысль вскользь высказал matklad): production-код вызывает функцию самыми разными способами, поэтому её сигнатура обязана быть гибкой, но тесты вызывают её каждый раз одинаково, так что обёртка сугубо для тестов должна забирать весь шаблонный код на себя.

Он противопоставляет это привычному паттерну beforeEach с переменными на уровне модуля и сбросом моков, взятому из первой строчки поисковой выдачи по мокам в Jest. Это работает, но подвижные части оказываются размазаны по всему файлу, а точно такое же количество глобальных мутаций в обычном коде не прошло бы код-ревью.

База данных здесь настоящая. Мокать нарушение уникального индекса - значит проверять утверждения на вымысле, который никак не гарантирует соответствие реальному поведению базы; он хочет тестировать код и базу данных как единое целое, потому что именно так всё работает в реальности. В данном случае это не стоит почти ничего, так как в production тоже используется SQLite. В delightful-integration-tests-rust ради того же принципа платят больше, запуская настоящие контейнеры на каждый тест и привязывая время их жизни к области видимости теста, а в t-context-go-testing приводится аналогичный аргумент для Golang: ресурс, принадлежащий тесту, должен разделять время жизни этого теста.

Arrange через публичный API

Мок часов нужен потому, что в Gleam нет встроенного способа подменять глобальное время. Фрер предпочитает полный контроль над временем вместо приблизительных сравнений или отказа от проверок меток времени - отчасти из-за риска нестабильных тестов (flakiness), отчасти потому, что часовые пояса и високосные дни заслуживают отдельных тест-кейсов.

Подготовка данных (setup) идёт через store.add_bookmark и store.schedule_job, а не через сырые INSERT-запросы, хотя конструктор и возвращает дескриптор базы данных. store.add_bookmark(...) лучше читается. Что ещё важнее, публичный API модуля должен меняться гораздо реже, чем его внутреннее устройство. Тест сам по себе является потребителем интерфейса, и тест, который лезет под слой абстракции, ломается при рефакторинге, даже если поведение не изменилось. Следствие из этого - самая универсальная мысль статьи: если тест невозможно написать без обхода абстракции, он, скорее всего, привязан не к тому уровню и должен находиться либо выше, либо глубже. Лезть глубже API на шаге arrange он готов только в крайнем случае, например чтобы создать повреждённые данные, возникновению которых этот самый API и призван препятствовать.

Act и assert

Строку с действием (act) он сделал бы более заметной, поскольку читатель смотрит на неё в первую очередь. Куда меньше ему нравятся четыре попольные проверки, и сейчас он предпочёл бы сравнивать значение целиком:

started |> should.equal(store.Job(..job, status: store.Running(started_at:)))

Это чётко описывает ожидаемый результат (исходная задача с новым статусом) и защищено от будущих изменений. Новое поле, которое start_job не трогает, не потребует правок в тесте, а то, которое функция меняет, уронит тест до тех пор, пока изменение не будет зафиксировано явно. Snapshot-тестирование упростило бы написание, но размыло бы понимание того, какие именно атрибуты на самом деле важны для проверки.

Последняя строка проверяет, что задача ушла из списка ожидающих, через вызов другой публичной функции, а не прямым запросом к таблице. В проверках Фрер ещё строже придерживается работы исключительно через API, чем на этапе подготовки данных.

Зачем нужен один тест

Для Фрера тесты решают две задачи: дают обратную связь при написании кода быстрее, чем подключение CLI или эндпоинта, и фиксируют поведение, переживающее смену реализации - так что очередь задач можно переписать заново без изменений в этом тесте. Один тест не покрывает start_job целиком. Несколько задач, удалённые задачи, уже запущенные или завершённые задачи отданы на откуп другим тестам.