Раздел 31 · Janet на практике

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

middle~35 мин

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

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

Никакого большого фреймворка в Janet нет, и это нормально. И проверка, и отладка здесь держатся на данных: тест это код возврата, ошибка это обычное значение, трассировка это читаемый список кадров. Разберём, как из этого собрать рабочий процесс, а потом научимся читать трассировку, в которой не хватает половины кадров.

Проверка, которая падает громко

Основа проверок это assert. Он возвращает само значение, если оно истинно, и бросает ошибку иначе:

(assert 42 "должно вернуть 42")     # 42
(assert false "текст")              # error: текст
(assert nil)                        # error: assert failure in nil

Сообщение необязательно, но без него ошибка скупее. Заметь, что (assert nil) показал в ошибке само выражение nil: это возможно потому, что assert не функция, а макрос, ему доступна форма аргумента, а не только значение. То самое различие из урока про макросы: функции такое недоступно, она видела бы голый false. Своё сообщение всё равно пиши всегда, оно скажет больше, чем выражение.

error бросает ошибку явно, errorf с форматированием:

(error "что-то пошло не так")
(errorf "%d не найден" 404)         # error: 404 не найден

Ошибка не обязана быть строкой. error принимает любое значение:

(error {:code 404 :path "/missing"})

Перехватив такую ошибку, ты получишь структуру, а не строку, и сможешь разобрать её через match. Это удобно, когда ошибку обрабатывает код, а не человек: не нужно парсить текст сообщения. Ту же мысль мы проводили в уроке про ошибки как данные.

Тест это код возврата

jpm test выполняет каждый .janet-файл в каталоге test/. Файл считается провалившимся, если завершился с ненулевым кодом.

project/
├── project.janet
├── src/
│   └── module.janet
└── test/
    ├── module-test.janet
    └── other-test.janet

Простейший тест это файл с assert:

(import ../src/module :as m)

(assert (= 42 (m/twice 21)) "twice должна вернуть 42")

Работает, но при провале ты узнаешь только сообщение, без счётчика и без общей картины.

Сводка вместо одного сообщения

(import spork/test)

(test/start-suite "Название набора")

(test/assert (= 3 (+ 1 2)) "сложение работает")
(test/assert-not (= 3 (+ 1 1)) "а это не должно совпасть")

(test/end-suite)
test suite Название набора finished in 0.000 seconds - 2 of 2 tests passed.

Полезные проверки сверх обычного assert:

ФормаЧто проверяет
(test/assert cond msg)условие истинно
(test/assert-not cond msg)условие ложно
(test/assert-error msg body)тело падает
(test/assert-no-error msg body)тело не падает
(test/capture-stdout body)возвращает [result output]
(test/timeit body)печатает время выполнения

assert-error особенно ценен: без него проверить, что функция корректно ругается на плохие данные, приходится вручную через protect.

Описание это не украшение. Второй аргумент попадает в вывод при провале. Разница между assert failure и parse-config: перевод строки в конце: false это разница между “где-то что-то сломалось” и “я знаю, что чинить”. Пиши описания так, чтобы по ним было понятно, какой случай отвалился.

И помни про deep=: сравнение массивов через = в тесте даст false по причине, которую мы разбирали в уроке про значения и ссылки. Это самая частая ошибка в первых тестах на Janet.

Judge: тест, который пишет себя сам

Обещанный ещё в первом уроке Judge переворачивает процесс. Ты пишешь (test (parse-config "порт=8080")) без ожидаемого значения, запускаешь, и Judge сам вписывает результат в исходник теста. Дальше тест работает как снапшот: изменился результат, Judge показывает дифф и предлагает принять новый. Это удобнее ручного assert, когда результат громоздкий (разобранный конфиг, длинная структура) и выписывать его руками дольше, чем проверить глазами. Для проверок инвариантов, где ожидание известно до запуска, обычный assert остаётся честнее.

Чтение трассировки стека

Когда программа падает, Janet печатает трассировку. Вот настоящая: функция parse-line упала на пустой строке из map, который вызвала process-file:

error: пустая строка
  in parse-line [stats.janet] on line 3, column 5
  in map [boot.janet] on line 1109, column 3
  in process-file [stats.janet] on line 7, column 21
  in main [stats.janet] (tail call) on line 11, column 27
  in run-main [boot.janet] on line 4684, column 16
  in cli-main [boot.janet] on line 4904, column 17

Читается сверху вниз:

  1. error: пустая строка. Что случилось: значение, с которым бросили ошибку.
  2. in parse-line [stats.janet]. Где возникла: файл, строка, колонка. Обычно чинить надо здесь.
  3. in map ... in process-file ... in main. Кто вызвал: цепочка вызовов до точки входа. Кадры из boot.janet это стандартная библиотека и обвязка запуска, свои файлы ищи по имени.
  4. Пометка (tail call). Знак, что рядом с этим кадром часть цепочки не записалась. Почему, разберём ниже.

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

(try
  (risky)
  ([err fib] (debug/stacktrace fib err "")))

Ловушка: хвостовые вызовы исчезают из трассировки

Включи тумблер и посмотри, что происходит с кадрами:

Janet оптимизирует хвостовые вызовы: если функция возвращает результат вложенного вызова напрямую, её кадр стека переиспользуется вместо создания нового. Благодаря этому рекурсия не переполняет стек, но в трассировке этих кадров уже нет.

Практический приём. Если ты не понимаешь, откуда пришёл вызов, временно сломай хвостовой вызов: оберни его во что-нибудь, например в (+ 0 ...) или (do ...) с последующим выражением. Кадры вернутся в трассировку, и путь станет виден.

Кто вызвал эту функцию

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

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

Когда печать быстрее отладчика

Полноценный отладчик в Janet есть: debug/break ставит точку останова по файлу, строке и колонке ((debug/break "stats.janet" 7 21)), при её срабатывании открывается REPL с доступом к локальным переменным, debug/step шагает по инструкциям, debug/unbreak снимает точку.

Но в повседневной работе pp и printf дешевле. В динамическом языке с быстрым REPL печать побеждает отладчик по скорости получения ответа: вставил (pp value), перезапустил, увидел. printf со спецификатором %p печатает то же самое внутри строки. И помни: %j падает на значениях-функциях, которые встречаются в раскрытиях макросов, поэтому для отладки бери %p.

Отладчик выигрывает, когда падение происходит глубоко в чужом коде, куда печать не вставишь, или когда до ошибки надо пройти десяток итераций и смотреть состояние на каждой. Тогда точка останова с живым REPL быстрее, чем пересборка с очередным pp.

Замер времени

(test/timeit
  (do (var s 0) (for i 0 100000 (+= s i)) s))
# Elapsed time: 0.000658 seconds

Для собственных измерений есть os/clock:

(def start (os/clock))
(expensive-operation)
(- (os/clock) start)

Один замер ничего не значит. Первый прогон включает разогрев кэшей, а разброс между запусками легко превышает разницу, которую ты измеряешь. Прогоняй измеряемый код много раз (timeit-loop из spork/test делает это за тебя) и сравнивай медиану, а не единичный результат.

Что запомнить

  • Проверка и отладка в Janet это данные: тест это код возврата, ошибка это значение, трассировка это список кадров.
  • assert возвращает значение или падает. Это макрос, поэтому без сообщения он покажет само выражение, но сообщение пиши всегда.
  • error принимает любое значение, в том числе структуру, которую потом разберёт match.
  • jpm test запускает каждый файл из test/, провал определяется кодом возврата.
  • spork/test даёт сводку, assert-error, capture-stdout и timeit.
  • В тестах сравнивай коллекции через deep=, а не =.
  • Трассировка читается сверху вниз, верхняя строка это место падения.
  • Хвостовые вызовы пропадают из трассировки. Чтобы увидеть путь, временно сломай хвостовую позицию.
  • trace показывает вызовы, pp и %p часто быстрее отладчика.
  • Один замер времени ничего не значит, сравнивай медиану многих прогонов.

Упражнения

Дальше

Следующий урок применяет Janet по прямому назначению: как замену shell-скриптам. Аргументы командной строки, файлы, каталоги, процессы.

домашка

Домашка