Accepted
charm.clj is a TUI library built on JLine. Testing TUI applications is challenging because they involve terminal I/O, escape sequences, and interactive behavior. We need a testing strategy that:
We adopt a three-tier testing strategy:
The Elm architecture (init/update/view) makes most application logic pure and directly testable. Unit tests should cover:
update functions with synthetic messagesview functions with known stateExample:
(ns charm.components.list-test
(:require [clojure.test :refer [deftest is testing]]
[charm.components.list :as list]
[charm.message :as msg]))
(deftest list-navigation-test
(testing "down arrow moves selection"
(let [items ["Apple" "Banana" "Cherry"]
list (list/item-list items)
[updated _] (list/list-update list (msg/key-press "down"))]
(is (= 1 (list/selected-index updated)))))
(testing "view reflects selection"
(let [list (-> (list/item-list ["A" "B" "C"])
(list/select-index 1))
view (list/list-view list)]
(is (some #(re-find #">" %) (clojure.string/split-lines view))))))
For testing the full input → update → view → render pipeline, use JLine's dumb terminal with ByteArrayInputStream and ByteArrayOutputStream. This allows:
Example:
(ns charm.integration-test
(:require [clojure.test :refer [deftest is testing]])
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]
[org.jline.terminal TerminalBuilder]))
(defn make-test-terminal
"Creates a dumb terminal with the given input string."
[input-str]
(let [input (ByteArrayInputStream. (.getBytes input-str))
output (ByteArrayOutputStream.)]
{:terminal (-> (TerminalBuilder/builder)
(.dumb true)
(.streams input output)
(.build))
:output output}))
(deftest terminal-input-test
(testing "terminal can read input bytes"
(let [{:keys [terminal output]} (make-test-terminal "hello\n")]
(try
(let [reader (.reader terminal)
chars (repeatedly 5 #(.read reader))]
(is (= [\h \e \l \l \o] (map char chars))))
(finally
(.close terminal))))))
(deftest terminal-output-test
(testing "terminal captures output"
(let [{:keys [terminal output]} (make-test-terminal "")]
(try
(let [writer (.writer terminal)]
(.write writer "test output")
(.flush writer)
(is (= "test output" (.toString output))))
(finally
(.close terminal))))))
Guidelines for integration tests:
(TerminalBuilder/builder) with .dumb true and .streams input output"\u001b[A" for up arrow)finally blockFor critical user flows where visual correctness matters, use charmbracelet/vhs to record and verify terminal output.
Example VHS tape (test/vhs/list-navigation.tape):
# Test list navigation
Output test/vhs/output/list-navigation.gif
Set Shell "bash"
Set FontSize 14
Set Width 800
Set Height 600
Type "clj -M:examples -m examples.todos"
Enter
Sleep 1s
# Navigate down
Down
Sleep 500ms
Down
Sleep 500ms
# Select item
Enter
Sleep 500ms
# Quit
Type "q"
Guidelines for VHS tests:
test/vhs/vhs < tape.tapevhs tool installedtest/
├── charm/
│ ├── components/ # Tier 1: Unit tests for components
│ │ ├── list_test.clj
│ │ └── text_input_test.clj
│ ├── input/ # Tier 1: Unit tests for input parsing
│ │ └── handler_test.clj
│ └── integration/ # Tier 2: Dumb terminal integration tests
│ ├── terminal_test.clj
│ └── program_test.clj
└── vhs/ # Tier 3: VHS visual tests
├── list-navigation.tape
└── output/
# Unit + Integration tests
clj -X:test
# VHS visual tests (requires vhs installed)
vhs < test/vhs/list-navigation.tape
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |