Babashka Scripting with Tests: bb.edn and babashka.test
Babashka is a native Clojure scripting runtime that starts in milliseconds. It runs Clojure code without the JVM startup penalty, making it practical for scripts that need to run frequently — build tools, CI helpers, file processors, HTTP clients. But scripts grow complex, and complex code needs tests. This guide covers testing Babashka scripts properly.
What Makes Babashka Testing Different
Testing Babashka scripts has a few distinct constraints:
- No full Clojure test ecosystem — libraries that use Java interop or non-included namespaces won't load
- Fast startup is the point — your test suite should also start fast
- Scripts often have side effects — file I/O, HTTP calls, shell commands require mocking strategies
bb.ednreplacesdeps.edn— the project configuration file is different
Babashka includes clojure.test in its standard library, so the basic test primitives work without any dependencies.
Project Structure
A typical Babashka project with tests:
my-script/
├── bb.edn
├── src/
│ └── my_script/
│ ├── core.bb
│ └── utils.bb
└── test/
└── my_script/
├── core_test.bb
└── utils_test.bbThe .bb extension is conventional but not required — .clj files also work in Babashka projects.
bb.edn Configuration
The bb.edn file configures your Babashka project similarly to deps.edn:
{:paths ["src" "test"]
:deps {}
:tasks {test {:doc "Run all tests"
:task (do (require '[babashka.classpath :as cp])
(cp/add-classpath "src:test")
(require '[clojure.test :as t])
(require 'my-script.core-test)
(require 'my-script.utils-test)
(let [{:keys [fail error]}
(t/run-all-tests #"my-script.*-test")]
(System/exit (if (pos? (+ fail error)) 1 0))))}}Run tests with:
bb testFor a more maintainable approach, use the babashka.test helper library.
Using babashka.test
babashka.test provides test discovery and a cleaner runner API. Add it to bb.edn:
{:paths ["src" "test"]
:deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}}Wait — that's the Clojure test runner, not Babashka-specific. For Babashka, use the built-in approach or babashka.test:
{:paths ["src" "test"]
:deps {}
:tasks {test {:doc "Run all tests"
:requires ([babashka.fs :as fs]
[clojure.test :as t])
:task (let [test-files (fs/glob "test" "**/*_test.bb")
namespaces (map #(-> % str
(str/replace "/" ".")
(str/replace "_" "-")
(str/replace #"\.bb$" "")
symbol)
test-files)]
(doseq [ns namespaces]
(require ns))
(let [{:keys [fail error]}
(apply t/run-tests namespaces)]
(System/exit (if (pos? (+ fail error)) 1 0))))}}}Writing Tests
Tests use standard clojure.test syntax:
;; test/my_script/utils_test.bb
(ns my-script.utils-test
(:require [clojure.test :refer [deftest is testing]]
[my-script.utils :refer [parse-csv format-date slugify]]))
(deftest parse-csv-test
(testing "parses comma-separated values"
(is (= ["a" "b" "c"] (parse-csv "a,b,c"))))
(testing "handles empty string"
(is (= [""] (parse-csv ""))))
(testing "handles quoted fields with commas"
(is (= ["hello, world" "foo"] (parse-csv "\"hello, world\",foo")))))
(deftest format-date-test
(testing "formats ISO date to display format"
(is (= "January 1, 2024" (format-date "2024-01-01"))))
(testing "handles invalid date"
(is (thrown? Exception (format-date "not-a-date")))))
(deftest slugify-test
(testing "converts title to URL slug"
(is (= "hello-world" (slugify "Hello World")))
(is (= "foo-bar-baz" (slugify "Foo Bar Baz")))
(is (= "my-post" (slugify "My Post!")))))Testing Code with Side Effects
Babashka scripts commonly read files, make HTTP requests, and run shell commands. These need isolation in tests.
Mocking File I/O
Extract file operations into functions that accept paths as arguments:
;; src/my_script/processor.bb
(ns my-script.processor
(:require [babashka.fs :as fs]
[clojure.string :as str]))
(defn read-config [path]
(-> path slurp (json/parse-string true)))
(defn process-file [input-path output-path transform-fn]
(let [content (slurp input-path)
result (transform-fn content)]
(spit output-path result)
result));; test/my_script/processor_test.bb
(ns my-script.processor-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[babashka.fs :as fs]
[my-script.processor :refer [process-file]]))
(def test-dir (fs/temp-dir "processor-test-"))
(use-fixtures :each
(fn [f]
(fs/delete-tree test-dir)
(fs/create-dirs test-dir)
(f)))
(deftest process-file-test
(testing "transforms file content"
(let [input (str test-dir "/input.txt")
output (str test-dir "/output.txt")]
(spit input "hello world")
(process-file input output str/upper-case)
(is (= "HELLO WORLD" (slurp output))))))Mocking HTTP Requests
For scripts that call external APIs, use Babashka's with-redefs for mocking:
;; src/my_script/github.bb
(ns my-script.github
(:require [babashka.http-client :as http]
[cheshire.core :as json]))
(defn get-repo-info [owner repo token]
(let [response (http/get (str "https://api.github.com/repos/" owner "/" repo)
{:headers {"Authorization" (str "token " token)
"Accept" "application/vnd.github.v3+json"}})]
(json/parse-string (:body response) true)))
(defn list-open-prs [owner repo token]
(let [response (http/get (str "https://api.github.com/repos/" owner "/" repo "/pulls")
{:headers {"Authorization" (str "token " token)}
:query-params {"state" "open"}})]
(json/parse-string (:body response) true)));; test/my_script/github_test.bb
(ns my-script.github-test
(:require [clojure.test :refer [deftest is testing]]
[babashka.http-client :as http]
[my-script.github :refer [get-repo-info list-open-prs]]))
(def mock-repo-response
{:status 200
:body (json/generate-string
{:name "my-repo"
:full_name "owner/my-repo"
:stargazers_count 42
:open_issues_count 5})})
(deftest get-repo-info-test
(testing "parses repo response"
(with-redefs [http/get (fn [& _] mock-repo-response)]
(let [info (get-repo-info "owner" "my-repo" "fake-token")]
(is (= "my-repo" (:name info)))
(is (= 42 (:stargazers_count info)))))))Mocking Shell Commands
For scripts that run shell commands via babashka.process:
;; src/my_script/git.bb
(ns my-script.git
(:require [babashka.process :as p]))
(defn current-branch []
(-> (p/shell {:out :string} "git rev-parse --abbrev-ref HEAD")
:out
str/trim))
(defn uncommitted-changes? []
(-> (p/shell {:out :string} "git status --porcelain")
:out
str/blank?
not));; test/my_script/git_test.bb
(ns my-script.git-test
(:require [clojure.test :refer [deftest is testing]]
[babashka.process :as p]
[my-script.git :refer [current-branch uncommitted-changes?]]))
(deftest current-branch-test
(testing "returns current git branch"
(with-redefs [p/shell (fn [& _] {:out "main\n"})]
(is (= "main" (current-branch))))))
(deftest uncommitted-changes-test
(testing "detects uncommitted changes"
(with-redefs [p/shell (fn [& _] {:out " M src/file.bb\n"})]
(is (true? (uncommitted-changes?)))))
(testing "returns false when clean"
(with-redefs [p/shell (fn [& _] {:out ""})]
(is (false? (uncommitted-changes?))))))Testing Tasks Defined in bb.edn
Tasks in bb.edn are harder to test because they're not regular functions. The solution is to put logic in namespaces and keep tasks thin:
;; bb.edn - thin task, delegates to namespace
{:tasks {deploy {:requires ([my-script.deploy :as deploy])
:task (deploy/run (assoc *command-line-args*
:dry-run? (System/getenv "DRY_RUN")))}}};; src/my_script/deploy.bb
(ns my-script.deploy
(:require [my-script.github :as github]
[my-script.config :as config]))
(defn run [{:keys [dry-run? env]}]
(let [cfg (config/load env)]
(if dry-run?
(println "Dry run — would deploy to" (:target cfg))
(github/create-deployment cfg))))Now deploy/run is fully testable.
Test Runner Script
For projects with many test files, a dedicated test runner script is cleaner than inline bb.edn tasks:
#!/usr/bin/env bb
;; script/test.bb
(require '[babashka.fs :as fs]
'[clojure.test :as t]
'[clojure.string :as str])
(def test-dir "test")
(defn find-test-namespaces []
(->> (fs/glob test-dir "**/*_test.{bb,clj}")
(map (fn [path]
(-> (str path)
(str/replace (str test-dir "/") "")
(str/replace "/" ".")
(str/replace "_" "-")
(str/replace #"\.(bb|clj)$" "")
symbol)))))
(doseq [ns (find-test-namespaces)]
(println "Loading" ns)
(require ns))
(let [nses (find-test-namespaces)
result (apply t/run-tests nses)]
(System/exit (if (pos? (+ (:fail result) (:error result))) 1 0)))Make it executable and run directly:
chmod +x script/test.bb
./script/test.bbCI Configuration
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Babashka
uses: turtlequeue/setup-babashka@v1
with:
babashka-version: 1.3.190
- name: Run tests
run: bb testBabashka's startup time is under 100ms, so even a full test suite completes in seconds.
When to Use Babashka vs Full Clojure
Use Babashka for:
- Build scripts and CI helpers
- File processing utilities
- Simple HTTP clients and API wrappers
- Developer tooling that runs frequently
Use full Clojure (with Kaocha or cognitect test runner) for:
- Application code
- Libraries with complex dependencies
- Code requiring Java interop
The testing approach is nearly identical — the main difference is which namespaces and libraries are available.
Summary
Testing Babashka scripts well means:
- Separating logic from I/O — put side effects at the edges, test the logic in the middle
- Using
with-redefsfor HTTP, shell commands, and file operations - Keeping
bb.edntasks thin — delegate to namespaces that are actually testable - Automating test discovery — a script that finds and loads all
*_test.bbfiles scales better than manual requires
Babashka's fast startup means there's no excuse not to run tests on every save. The feedback loop is fast enough to be part of your normal workflow.