Babashka Scripting with Tests: bb.edn and babashka.test

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:

  1. No full Clojure test ecosystem — libraries that use Java interop or non-included namespaces won't load
  2. Fast startup is the point — your test suite should also start fast
  3. Scripts often have side effects — file I/O, HTTP calls, shell commands require mocking strategies
  4. bb.edn replaces deps.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.bb

The .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 test

For 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.bb

CI 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 test

Babashka'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:

  1. Separating logic from I/O — put side effects at the edges, test the logic in the middle
  2. Using with-redefs for HTTP, shell commands, and file operations
  3. Keeping bb.edn tasks thin — delegate to namespaces that are actually testable
  4. Automating test discovery — a script that finds and loads all *_test.bb files 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.

Read more