Testing
termflow-testkit is the test-only companion to termflow-app. It
gives you a synchronous driver that runs your TuiApp without a real
terminal, captures rendered frames as cell matrices for snapshot
testing, and provides KeySim / MouseSim constructors so you don't
have to hand-build KeyDecoder.InputKey values in test code.
libraryDependencies += "org.llm4s" %% "termflow-testkit" % "0.4.0" % Test
TuiTestDriver
The single entry point for testing apps:
import termflow.testkit.{TuiTestDriver, KeySim}
import termflow.tui.KeyDecoder.InputKey
val driver = TuiTestDriver(MyApp.App, width = 80, height = 24)
driver.init()
driver.send(MyApp.Msg.Increment)
assert(driver.model.count == 1)
The driver:
- Runs
app.init(ctx)synchronously, captures theTui[Model, Msg]. - Drains the command bus on every
send, processingGCmds recursively so chained transitions complete beforesendreturns. - Renders the current model after every
send—driver.framereturns the latestRenderFrame. - Suppresses real subscription start-up —
Sub.InputKeynever reads stdin andSub.Everytimers never tick on a real scheduler, so tests stay deterministic. Timer ticks are instead driven explicitly withadvanceTime(see DrivingSub.Everywith virtual time).
Key methods:
class TuiTestDriver[Model, Msg]:
def init(): Unit
def send(msg: Msg): Unit
def advanceTime(duration: FiniteDuration): Unit
def advanceTime(durationMillis: Long): Unit
def model: Model
def exited: Boolean
def cmds: List[Cmd[Msg]]
def observedErrors: List[TermFlowError]
def frame: RenderFrame
Driving Sub.Every with virtual time
Apps that animate or poll via Sub.Every (DigitalClock, SineWaveApp, …)
used to be untestable: their ticks fired on a real background scheduler. The
testkit removes that dependency on wall-clock time with a virtual clock.
RuntimeCtx.clock abstracts both wall-clock reads and the periodic scheduler
behind Sub.Every. Production uses SystemClock (real time, background
executor). TestRuntimeCtx substitutes a ManualClock whose time only moves
when you call advanceTime:
val driver = TuiTestDriver(DigitalClock.App, width = 44, height = 20)
driver.init()
assert(driver.model.clock.value == "00:00") // ManualClock starts at epoch 0, UTC
driver.advanceTime(1.second) // fires exactly one 1s tick
assert(driver.model.clock.value == "00:00:01")
driver.advanceTime(3.seconds) // three more ticks
assert(driver.model.clock.value == "00:00:04")
advanceTime starts the registered timer subs against the ManualClock (no
threads spawn), fires every tick that falls due, and applies the resulting
messages through app.update — just as the runtime would when its scheduler
ticks. Input and resize subs stay dormant.
Tick semantics: a timer with period p first fires p ms after the advance
that starts it, then every p thereafter, so advanceTime(N * p) produces
exactly N ticks. Unlike the real scheduler there is no immediate fire at
scheduling time — this gives an exact advance→tick correspondence.
For apps that display wall-clock time (e.g. DigitalClock), read it
through ctx.clock rather than LocalTime.now() so the ManualClock drives
it deterministically:
private def currentTime(ctx: RuntimeCtx[Msg]): String =
LocalTime.ofInstant(ctx.clock.instant(), ctx.clock.zone).toString
ManualClock can be constructed directly in lower-level tests:
val clock = new ManualClock(startMillis = 0L) // zone defaults to UTC
clock.schedulePeriodic(100L, () => tick())
clock.advance(300.millis) // tick() fires 3 times
Asserting on the frame
The simplest assertion is "did the rendered text contain X?":
val rendered = (0 until driver.frame.height)
.map(r => driver.frame.cells(r).map(_.ch).mkString)
.mkString("\n")
assert(rendered.contains("count: 1"))
For style assertions, drop into RenderCell:
val cell = driver.frame.cells(2)(15)
assert(cell.ch == '✓')
assert(cell.style.fg == Color.Green)
Golden snapshots
For higher-fidelity tests, mix in GoldenSupport:
import termflow.testkit.GoldenSupport
import org.scalatest.funsuite.AnyFunSuite
class MyAppSpec extends AnyFunSuite with GoldenSupport:
override def goldenSuiteName = "MyApp"
test("initial frame matches golden") {
val d = TuiTestDriver(MyApp.App, width = 80, height = 24)
d.init()
assertGoldenFrame(d.frame, "initial")
}
First run: the golden file doesn't exist, the test fails. Run with
-Dtermflow.update-goldens=true (or UPDATE_GOLDENS=1) and the
driver writes it. Commit the golden, future runs assert against it.
Goldens live under
src/test/resources/termflow/golden/<suite>/<name>.txt by default.
Override via goldenDir and goldenPath(name).
KeySim — typed key constructors
import termflow.testkit.KeySim
KeySim.Tab // InputKey.Tab
KeySim.Enter // InputKey.Enter
KeySim.Escape // InputKey.Escape
KeySim.char('q') // InputKey.CharKey('q')
KeySim.ctrl('S') // InputKey.Ctrl('S')
KeySim.f(2) // InputKey.F2
KeySim.paste("hi") // InputKey.Paste("hi")
Modifier wrappers:
KeySim.shift(KeySim.Tab) // BackTab equivalent (Shift+Tab)
KeySim.ctrl(KeySim.char('c')) // Ctrl+C
KeySim.modified(InputKey.End, shift = true, ctrl = true)
Sequences:
KeySim.typeString("hello")
// → List(CharKey('h'), CharKey('e'), CharKey('l'), CharKey('l'), CharKey('o'))
KeySim.typeString("a\tb")
// → List(CharKey('a'), Tab, CharKey('b')) // \t becomes Tab
KeySim.typeString("a\nb")
// → List(CharKey('a'), Enter, CharKey('b')) // \n becomes Enter
This is what the showcase, wizard, and form-demo specs use to drive their drivers — typing a string and asserting on the resulting model is by far the most readable test shape.
MouseSim — typed mouse constructors
Mouse events live inside InputKey.Mouse(event). MouseSim wraps
that for you:
import termflow.testkit.MouseSim
MouseSim.click(col = 10, row = 5) // Press at (10, 5)
MouseSim.scrollOnce(ScrollDirection.Down, 10, 5) // Single wheel click
MouseSim.clickPair(col = 10, row = 5)
// → Vector(InputKey.Mouse(Press(...)), InputKey.Mouse(Release(...)))
MouseSim.scroll(ScrollDirection.Down, 10, 5, ticks = 3)
// → three Mouse(Scroll(Down, ...)) events
For drag-resize tests of SplitPane:
val events = Vector(
MouseSim.press(col = 40, row = 12),
MouseSim.drag(col = 50, row = 12),
MouseSim.release(col = 50, row = 12)
)
events.foreach(e => driver.send(MyApp.Msg.KeyPressed(e)))
TestRuntimeCtx
TuiTestDriver constructs a TestRuntimeCtx[Msg] for you, but if
you want to drive a TuiApp lower-level, the ctx is exposed
directly. Useful when you're testing a sub-component that takes a
RuntimeCtx[Msg] parameter without going through the full app.
val ctx = TestRuntimeCtx[MyApp.Msg](width = 80, height = 24)
// Register a sub manually if you need to assert on it:
val keys = Sub.InputKey[MyApp.Msg](
msg = k => MyApp.Msg.KeyPressed(k),
onError = _ => MyApp.Msg.Quit,
ctx = ctx
)
assert(ctx.registeredSubs.contains(keys))
A complete spec
import org.scalatest.funsuite.AnyFunSuite
import termflow.testkit.{TuiTestDriver, KeySim}
class CounterSpec extends AnyFunSuite:
private def driver = {
val d = TuiTestDriver(SyncCounter.App, width = 80, height = 24)
d.init()
d
}
test("increment via keystroke") {
val d = driver
KeySim.typeString("increment").foreach(k =>
d.send(SyncCounter.Msg.ConsoleInputKey(k))
)
d.send(SyncCounter.Msg.ConsoleInputKey(KeySim.Enter))
assert(d.model.counter.count == 1)
}
test("exit on Ctrl+C") {
val d = driver
d.send(SyncCounter.Msg.ConsoleInputKey(KeySim.ctrl('C')))
assert(d.exited)
}
Testing async work
Cmd.FCmd futures don't run automatically inside the driver — that
would make tests non-deterministic. Instead, the driver records the
FCmd in cmds and you assert on it:
test("Increment dispatches an FCmd with onEnqueue = Busy") {
val d = driver
d.send(FutureCounter.Msg.Increment)
val fcmd = d.cmds.collectFirst {
case f: Cmd.FCmd[?, ?] => f
}.getOrElse(fail("expected an FCmd"))
assert(fcmd.onEnqueue.exists(_.isInstanceOf[FutureCounter.Msg.Busy]))
}
For end-to-end async tests, use Await.result on the captured future
and feed the resolved value back via driver.send. This is the
pattern the FutureCounter spec uses.
Reference
Files:
modules/termflow-testkit/src/main/scala/termflow/testkit/{TuiTestDriver,TestRuntimeCtx,KeySim,MouseSim,GoldenSupport}.scala.
For working examples, every spec under
modules/termflow-sample/src/test/scala
uses the testkit — the wizard, showcase, form-demo, and dashboard
specs cover the full surface.