Раздел 28 · Clojure

Babashka, ClojureScript и тесты

middle~20 мин

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

Главная мысль урока: 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.ednJVMвеб, данные, долгие процессы
Скриптbabashkanative-imageавтоматизация, CI, утилиты
ФронтендClojureScript, shadow-cljsJavaScriptбраузер, 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: запрос и ответ окажутся мапами, а обработчик проверится вызовом функции.

домашка

Домашка