Тестирование и отладка
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Тестирование и отладка
Никакого большого фреймворка в 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
Читается сверху вниз:
error: пустая строка. Что случилось: значение, с которым бросили ошибку.in parse-line [stats.janet]. Где возникла: файл, строка, колонка. Обычно чинить надо здесь.in map ... in process-file ... in main. Кто вызвал: цепочка вызовов до точки входа. Кадры изboot.janetэто стандартная библиотека и обвязка запуска, свои файлы ищи по имени.- Пометка
(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-скриптам. Аргументы командной строки, файлы, каталоги, процессы.
домашка