Главная мысль урока: Clojure живёт не только в JVM-сервисе. Тот же язык и те же функции работают в скриптах на babashka, во фронтенде на ClojureScript и в браузерном интерпретаторе SCI. А
clojure.testодинаково проверяет код во всех трёх местах.
Каждое утро команда «Хроник Пограничья» хочет видеть, сколько вчера было установок, начатых и законченных обучений. Вчерашний events.log лежит на сервере. Обычно такую задачу решают на bash: grep, awk, sort, uniq -c, и через полгода никто не помнит, что делает третья труба в конвейере. Хочется писать на Clojure, но JVM стартует секунду-две, для скрипта на одну строку это долго.
Ответ называется babashka.
Babashka: Clojure для скриптов
Babashka это Clojure в одном исполняемом файле. Внутри тот же интерпретатор SCI, что крутит сниппеты курса, вместе с частью JVM-библиотек, и всё это заранее собрано через GraalVM native-image в машинный код. Поэтому bb script.clj стартует за десятки миллисекунд, а внутри доступны clojure.string, clojure.set, clojure.edn, работа с файлами и процессами. Все листинги с пометкой bb в этом разделе проверяются им.
Утренний отчёт на babashka:
#!/usr/bin/env bb
(require '[clojure.string :as str]
'[babashka.fs :as fs])
;; В настоящем скрипте путь придёт из аргументов: (first *command-line-args*).
(def path (str (fs/create-temp-file {:suffix ".log"})))
(spit path (str/join "\n" ["2026-05-14T07:38:24Z player=2979 platform=ios event=install"
"2026-05-14T07:44:32Z player=2979 platform=ios event=tutorial_start"
"2026-05-14T07:54:18Z player=2980 platform=android event=install"
"2026-05-22T13:0"]))
(defn event-of [line]
(second (re-find #"\bevent=(\S+)" line)))
(def counts
(->> (str/split-lines (slurp path))
(keep event-of)
frequencies))
(doseq [[event n] (sort-by val > counts)]
(println (format "%-16s %d" event n)))
(assert (= {"install" 2 "tutorial_start" 1} counts))
(fs/delete path)
Первая строка #!/usr/bin/env bb делает файл исполняемым, как скрипт на bash или Python. Всё остальное это обычный Clojure: ->>, keep, frequencies из урока про библиотеку последовательностей. Внешние программы запускает babashka.process/shell, а файлы и каталоги обходит babashka.fs.
Задачи проекта (запустить тесты, собрать, выложить) babashka описывает в файле bb.edn, как scripts в package.json:
{:paths ["src" "test"]
:tasks
{test {:doc "Прогнать тесты"
:task (exec 'cognitect.test-runner.api/test)
:exec-args {:dirs ["test"]}
:extra-deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}
report {:doc "Утренний отчёт по events.log"
:task (shell "bb scripts/report.clj logs/events.log")}}}
bb tasks покажет список, bb report запустит отчёт. Тот же приём в Janet разобран в уроке про скрипты: маленький лисп часто оказывается удобнее bash, когда в скрипте появляются данные.
ClojureScript и SCI
ClojureScript это тот же язык, который компилируется в JavaScript. Мапы, векторы, assoc, reduce и макросы там такие же, отличается хост: вместо классов Java объекты JavaScript, как в уроке про интероп. Фронтенд на ClojureScript обычно собирают через shadow-cljs: он берёт пакеты из npm и даёт горячую перезагрузку кода в браузере.
;; shadow-cljs.edn
{:source-paths ["src"]
:dependencies []
:builds {:app {:target :browser
:output-dir "public/js"
:modules {:main {:init-fn chronicles.app/init}}}}}
npx shadow-cljs watch app собирает public/js/main.js и подключает REPL прямо к открытой вкладке браузера.
Сниппеты этого курса работают без компиляции. В браузере крутится SCI, интерпретатор Clojure, который читает форму и исполняет её сразу. Отсюда особенности, которые встречались в разделе: (/ 1 2) даёт 0.5, нет ref и future, а catch пишут с :default. Язык тот же, хост JavaScript.
| Где | Чем запускается | Хост | Для чего |
|---|---|---|---|
| Сервис | Clojure CLI, deps.edn | JVM | веб, данные, долгие процессы |
| Скрипт | babashka | native-image | автоматизация, CI, утилиты |
| Фронтенд | ClojureScript, shadow-cljs | JavaScript | браузер, Node.js |
| Песочница курса | SCI в браузере | JavaScript | сниппеты и задачи |
clojure.test: один язык тестов
Во всех четырёх местах тесты пишут одинаково. deftest объявляет тест, is проверяет утверждение, testing подписывает группу проверок. Этот сниппет работает прямо в браузере, на той же реализации clojure.test, что проверяет задачи курса:
run-tests прогоняет все тесты неймспейса, печатает сводку и возвращает её мапой. Поменяй 25 на 26 и запусти снова: увидишь, как выглядит упавшая проверка. is показывает ожидание и факт, потому что сравнение (= ожидание факт) он разбирает отдельно.
Ещё две формы встречаются в каждом проекте. are проверяет одну форму на таблице примеров, thrown? внутри is проверяет, что код бросил исключение:
are разворачивается в три is, по одному на строку таблицы. Так тест читается как спецификация: атака, броня, ожидаемый урон. На JVM в thrown? пишут класс исключения, например clojure.lang.ExceptionInfo, а в ClojureScript :default или js/Error.
Фикстуры: подготовка мира перед тестом
Тесты, которые трогают общее состояние (атом мира, базу, временный каталог), должны получать его свежим. Для этого есть фикстуры. В браузерной реализации clojure.test, на которой работают задачи курса, фикстур нет, поэтому листинг идёт через babashka, где clojure.test настоящий:
(require '[clojure.test :refer [deftest is use-fixtures run-tests]])
(def world (atom nil))
(defn with-fresh-world [test]
(reset! world {:hero/hp 30})
(test)
(reset! world nil))
(use-fixtures :each with-fresh-world)
(deftest hit-for-5
(swap! world update :hero/hp - 5)
(is (= 25 (:hero/hp @world))))
(deftest hit-for-10
(swap! world update :hero/hp - 10)
(is (= 20 (:hero/hp @world))))
(let [{:keys [fail error]} (run-tests)]
(assert (zero? (+ fail error))))
Без фикстуры второй тест увидел бы здоровье после первого и упал бы в зависимости от порядка запуска. С фикстурой каждый тест начинает с 30. Это тот же принцип, что в уроке про модульные тесты: тест не зависит от соседей.
Тесты в проекте
В проекте на Clojure CLI тесты лежат в каталоге test, а запускает их test-runner от Cognitect. Его подключают алиасом в deps.edn:
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.0"}}
:aliases
{:test {:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}}}
Дальше всё из терминала:
$ clojure -X:test
Running tests in #{"test"}
Testing chronicles.combat-test
Ran 12 tests containing 31 assertions.
0 failures, 0 errors.
Неймспейс тестов называют как проверяемый, с суффиксом -test: chronicles.combat проверяется в chronicles.combat-test. В ClojureScript тот же код подключает cljs.test, а в babashka bb test из bb.edn выше. Тест, написанный для одного места, без правок переезжает в другое, если он не трогает хост.
Проверь себя
Задание
Дальше
Тесты без сервера и без браузера работают потому, что функции принимают данные и возвращают данные. В уроке про Ring этот принцип дойдёт до HTTP: запрос и ответ окажутся мапами, а обработчик проверится вызовом функции.
домашка