Test a Tui.program the same way a user uses it: start the app, simulate
terminal events, and assert on the screen or model. Inspired by
elm-program-test.
Typical flow:
start or startWithContext.pressKey, clickText,
paste, resize, or advanceTime.BackendTask effects with resolveEffect
for the common case, or resolveEffectWith when you
need custom HTTP/command simulation.expectRunning, expectExit,
or expectExitWith.The same named tests can be:
toTest with elm-testelm-pages test, which reads the same TuiTest.Test
value and shows the recorded snapshots in the terminal stepperimport BackendTask
import Test
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
import Tui
import Tui.Effect as Effect
import Tui.Screen
import Tui.Sub
type Msg
= Increment
| Quit
keyToMsg : Tui.Sub.KeyEvent -> Msg
keyToMsg event =
case event.key of
Tui.Sub.Character 'j' ->
Increment
_ ->
Quit
app : Tui.ProgramConfig () Int Msg
app =
{ data = BackendTask.succeed ()
, init = \() -> ( 0, Effect.none )
, update =
\msg count ->
case msg of
Increment ->
( count + 1, Effect.none )
Quit ->
( count, Effect.exit )
, view = \_ count -> Tui.Screen.text ("Count: " ++ String.fromInt count)
, subscriptions = \_ -> Tui.Sub.onKeyPress keyToMsg
}
run : Script
run =
Tui.program app |> Tui.toScript
tuiTests : TuiTest.Test
tuiTests =
TuiTest.describe "Counter"
[ TuiTest.test "increments with j"
(TuiTest.start BackendTaskTest.init app)
[ TuiTest.pressKey 'j'
, TuiTest.ensureViewHas "Count: 1"
, TuiTest.expectRunning
]
]
suite : Test.Test
suite =
TuiTest.toTest tuiTestsA named tree of TUI tests.
Use test for leaf tests and describe to group them,
then pass the resulting value to toTest for elm-test or expose it
for elm-pages test.
import Test
import Test.Tui as TuiTest
tuiTests : TuiTest.Test
tuiTests =
TuiTest.describe "Counter"
[ TuiTest.test "increments" <|
counterScenario
|> TuiTest.pressKey 'j'
|> TuiTest.expectRunning
]
suite : Test.Test
suite =
TuiTest.toTest tuiTestsA single step in a TUI test pipeline - a key press, an effect
resolution, an assertion, and so on. Steps are opaque values; build
them with the helpers in this module (pressKey,
ensureViewHas, resolveEffect,
and friends).
incrementSteps : List (TuiTest.Step Int Msg)
incrementSteps =
[ TuiTest.pressKey 'k'
, TuiTest.ensureViewHas "Count: 1"
, TuiTest.expectRunning
]Use start for the default terminal size, and
startWithContext when you want custom dimensions or
color profile.
Both resolve app.data through Test.BackendTask.
Start a TUI test with a default 80×24 terminal and TrueColor profile.
This resolves app.data through Test.BackendTask
before the first snapshot.
Pure startup data usually uses BackendTask.succeed. For file, env, time, or
db-backed startup, seed the virtual environment through the TestSetup.
If your app subscribes to Tui.Sub.onResize, the initial context is fired
automatically.
import BackendTask
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
import Tui
import Tui.Effect as Effect
import Tui.Screen as Screen
import Tui.Sub
type Msg
= Quit
counterTest : TuiTest.TuiTest Int Msg
counterTest =
TuiTest.start BackendTaskTest.init
{ data = BackendTask.succeed 0
, init = \count -> ( count, Effect.none )
, update = \_ count -> ( count, Effect.exit )
, view = \_ count -> Screen.text ("Count: " ++ String.fromInt count)
, subscriptions = \_ -> Tui.Sub.onKeyPress (\_ -> Quit)
}The config record is the same shape you pass to Tui.program,
so you can share a single app value between your production script and your
tests.
Use startWithContext for a custom terminal size.
Like start, but with a custom terminal context. Use this for
responsive layouts, small terminals, or color-profile-dependent rendering.
import BackendTask
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
import Tui
import Tui.Effect as Effect
import Tui.Screen as Screen
import Tui.Sub
type Msg
= Resized { width : Int, height : Int }
resizedTest : TuiTest.TuiTest { width : Int, height : Int } Msg
resizedTest =
TuiTest.startWithContext
{ width = 120, height = 40, colorProfile = Tui.TrueColor }
BackendTaskTest.init
{ data = BackendTask.succeed { width = 0, height = 0 }
, init = \model -> ( model, Effect.none )
, update =
\msg _ ->
case msg of
Resized size ->
( size, Effect.none )
, view =
\_ size ->
Screen.text
(String.fromInt size.width ++ "x" ++ String.fromInt size.height)
, subscriptions = \_ -> Tui.Sub.onResize Resized
}If your app subscribes to Tui.Sub.onResize, this initial context is also sent
through that subscription once at startup.
Name a single test from a starting TuiTest and a list of
Steps. The framework runs the steps and finalizes the test
for you.
import Test.Tui as TuiTest
counterTests : TuiTest.Test
counterTests =
TuiTest.test "increments"
counterScenario
[ TuiTest.pressKey 'j'
, TuiTest.expectRunning
]Group TUI tests under a shared heading.
import Test.Tui as TuiTest
counterTests : TuiTest.Test
counterTests =
TuiTest.describe "Counter"
[ TuiTest.test "increments"
counterScenario
[ TuiTest.pressKey 'j'
, TuiTest.expectRunning
]
, TuiTest.test "quits"
counterScenario
[ TuiTest.pressKey 'q'
, TuiTest.expectExit
]
]Convert a named TUI test tree into an elm-test Test.Test.
elm-pages test works with TuiTest.Test directly so it can show the
interactive stepper. Use toTest when you want to run the same named tests
through plain elm-test without the elm-pages wrapper CLI.
import Test
import Test.Tui as TuiTest
suite : Test.Test
suite =
TuiTest.toTest tuiTestsFlatten a named TUI test tree into named snapshot sequences.
This is used by the generated elm-pages test stepper code. The names include
any enclosing describe labels so the selected test is easy to
identify.
import Test.Tui as TuiTest
snapshotNames : List String
snapshotNames =
tuiTests
|> TuiTest.toNamedSnapshots
|> List.map Tuple.firstRun a list of Steps against a starting TuiTest and
finalize the result as an Expect.Expectation. Use this when writing
tests in plain elm-test style:
import Expect
import Test exposing (Test, test)
import Test.Tui as TuiTest
suite : Test
suite =
test "k increments" <|
\() ->
TuiTest.expect counterScenario
[ TuiTest.pressKey 'k'
, TuiTest.ensureViewHas "Count: 1"
, TuiTest.expectRunning
]Run a list of Steps against a TuiTest, returning the
new TuiTest (without finalizing). Use this to bake setup steps into
the result of a custom start helper, or to compose helper bundles
returned as List Step. End-of-test finalization is what
test, expect, and snapshots do
for you.
TuiTest.runSteps
[ TuiTest.pressKey 'k'
, TuiTest.ensureViewHas "Count: 1"
]
counterTestApply a single Step to a TuiTest. Useful in meta
tests that want to keep a chainable |> step |> step style with the
new opaque Step type. End-user tests should prefer test
or expect over chaining directly.
counterApp
|> TuiTest.applyStep (TuiTest.pressKey 'k')
|> TuiTest.applyStep TuiTest.expectRunningSimulate pressing a character key with no modifiers.
[ TuiTest.pressKey 'j' ]Simulate pressing any key, including special keys and modifiers.
[ TuiTest.pressKeyWith
{ key = Tui.Sub.Arrow Tui.Sub.Down
, modifiers = []
}
, TuiTest.pressKeyWith
{ key = Tui.Sub.Character 's'
, modifiers = [ Tui.Sub.Ctrl ]
}
]Simulate pressing a character key N times.
-- Navigate down 7 items
[ TuiTest.pressKeyN 7 'j' ]Simulate a bracketed paste event. Delivers the text as a single OnPaste
event, just like a real terminal with bracketed paste mode enabled. Use this
instead of typing character-by-character when testing paste behavior.
[ TuiTest.pressKey 'c' -- open commit dialog
, TuiTest.paste "fix: null pointer" -- paste commit message
, TuiTest.ensureViewHas "fix: null pointer"
]Simulate a terminal resize. The framework handles resize automatically:
this updates the Context that view receives and routes the new size through
any Tui.Sub.onResize subscriptions.
Simulate a left mouse click at the given row and column (0-based).
[ TuiTest.click { row = 3, col = 5 } ]Find a line containing the given text and simulate a click on it.
Like elm-program-test's clickButton, finds elements by content instead of
coordinates, making tests resilient to layout changes.
[ TuiTest.clickText "def5678" ]Fails with a helpful message if the text is not found on screen.
Simulate a scroll-down event at the given position.
Simulate a scroll-up event at the given position.
Simulate N scroll-down events at the given position.
[ TuiTest.scrollDownN 10 { row = 3, col = 60 } ]Simulate N scroll-up events at the given position.
[ TuiTest.scrollUpN 5 { row = 3, col = 60 } ]Advance simulated time by the given number of milliseconds. Any
Tui.Sub.everyMillis subscriptions fire for each
interval boundary crossed, passing the simulated Time.Posix at the moment
of fire to the user's message constructor.
import Time
TuiTest.test "animation advances one frame per 50ms tick" <|
spinnerTest
|> TuiTest.advanceTime 50
|> TuiTest.ensureViewHas "frame 1"
|> TuiTest.advanceTime 50
|> TuiTest.ensureViewHas "frame 2"
|> TuiTest.expectRunningMulti-interval subscriptions fire independently at their own rates. If
multiple ticks fall in the same advanceTime call, they are delivered to
update in chronological order; same-timestamp ticks from different
intervals fire in subscription order. Catch-up semantics match the runtime:
each interval fires at most once per advanceTime call with the actual
simulated fire time, not the target time.
The starting simulated clock is 1970-01-01T00:00:00Z (posix 0). The first
fire of everyMillis n _ is at simulated posix n.
When your update returns a Tui.Effect that performs a BackendTask, the
test captures it as a pending effect instead of running it automatically.
Use resolveEffect when the pending BackendTask can be
resolved directly by Test.BackendTask.
Use resolveEffectWith when you need to customize the
simulation, like stubbing an HTTP response:
TuiTest.resolveEffectWith
(BackendTaskTest.simulateCommand "git" "M src/Main.elm")Resolve the next pending BackendTask effect with the default
Test.BackendTask behavior.
Use this for the common case where the pending effect can be resolved without
extra setup, for example BackendTask.succeed, map, andThen, or other
pure BackendTask flows.
import BackendTask
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
import Tui
import Tui.Effect as Effect
import Tui.Screen as Screen
import Tui.Sub
type Msg
= Fetch
| Fetched String
backendTaskTest : TuiTest.TuiTest String Msg
backendTaskTest =
TuiTest.start BackendTaskTest.init
{ data = BackendTask.succeed "idle"
, init = \status -> ( status, Effect.none )
, update =
\msg status ->
case msg of
Fetch ->
( status
, BackendTask.succeed "done"
|> Effect.perform Fetched
)
Fetched newStatus ->
( newStatus, Effect.none )
, view = \_ status -> Screen.text status
, subscriptions = \_ -> Tui.Sub.onKeyPress (\_ -> Fetch)
}
fetchTest : TuiTest.Test
fetchTest =
TuiTest.test "fetches when f is pressed"
backendTaskTest
[ TuiTest.pressKey 'f'
, TuiTest.resolveEffect
, TuiTest.ensureViewHas "done"
, TuiTest.expectRunning
]Resolve the next pending BackendTask effect with a customized
Test.BackendTask simulation pipeline.
Use this when the pending effect needs extra simulation, like an HTTP response or shell command output.
The callback receives the same Test.BackendTask pipeline you would get from
BackendTaskTest.fromBackendTask, so in practice you usually just pipe one or
more BackendTaskTest.simulate... helpers into it.
import Json.Encode as Encode
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
[ TuiTest.pressKeyWith { key = Tui.Sub.Enter, modifiers = [] }
, TuiTest.resolveEffectWith
(BackendTaskTest.simulateHttpGet
"https://api.github.com/repos/elm/core"
(Encode.object [ ( "stargazers_count", Encode.int 7500 ) ])
)
, TuiTest.ensureViewHas "Stars: 7500"
]Assert on the plain text content of the current screen. Failed assertions show the full screen output for easy debugging.
Use ensureViewHas and
ensureViewDoesNotHave for the common case. Use
ensureView when you want a custom assertion, and
ensureModel when the important state is not visible on
screen.
Assert on the current screen content using a custom assertion function. The function receives the plain text content (no styling) of the rendered screen.
[ TuiTest.ensureView
(\text ->
if String.contains "Error" text then
Expect.fail "Should not show error"
else
Expect.pass
)
]Assert that the current screen contains the given text.
[ TuiTest.ensureViewHas "Count: 0" ]Assert that the current screen does NOT contain the given text.
Assert on the model directly. Useful for verifying internal state that
isn't visible in the rendered output, or for building higher-level test
helpers that query opaque framework state (like Layout.FrameworkModel).
TuiTest.test "counter is at 5"
counterApp
[ TuiTest.pressKeyN 5 'j'
, TuiTest.ensureModel
(\model -> Expect.equal 5 model.count)
, TuiTest.expectRunning
]Add an assertion label to the most recent snapshot. The stepper shows these beneath the action label, so custom helpers can describe what they checked without creating a new snapshot.
Use this when building companion helpers on top of ensureModel:
import Expect
import Test.Tui as TuiTest
ensureCount : Int -> List (TuiTest.Step Int msg)
ensureCount expected =
[ TuiTest.ensureModel (\actual -> Expect.equal expected actual)
, TuiTest.annotateAssertion
("ensureCount " ++ String.fromInt expected ++ " ✓")
]Assert on text that appears with specific styling (bold, color, etc.).
Adjacent spans with the same style are merged before matching, so
fragmented rendering like <red>ERROR</red><red> message</red> is
treated as a single "ERROR message" red region.
A check on a single style attribute. Combine multiple checks in a list
to require all of them. For example, [ bold, fg Ansi.Color.red ] means "bold AND red."
Match bold text.
|> TuiTest.ensureViewHasStyled [ TuiTest.bold ] "selected item"Match dim text.
Match italic text.
Match underlined text.
Match text with a specific foreground color.
|> TuiTest.ensureViewHasStyled [ TuiTest.fg Ansi.Color.red ] "Error"Match text with a specific background color.
|> TuiTest.ensureViewHasStyled [ TuiTest.bg Ansi.Color.blue ] "Selected"Assert that the screen contains the given text rendered with ALL of the specified style checks. Adjacent spans that satisfy the checks are merged before matching, so fragmented rendering is handled correctly.
TuiTest.test "selected item is highlighted"
myTest
[ TuiTest.ensureViewHasStyled [ TuiTest.bold, TuiTest.fg Ansi.Color.yellow ] "selected"
, TuiTest.expectRunning
]Assert that the screen does NOT contain the given text with ALL of the specified style checks.
TuiTest.test "error text is not bold"
myTest
[ TuiTest.ensureViewDoesNotHaveStyled [ TuiTest.bold ] "Error"
, TuiTest.expectRunning
]Each scenario should end with a final-assertion Step that pins the
expected termination state. If pending effects remain unresolved, these
checks fail so you do not accidentally ignore BackendTasks.
Assert that the TUI is still running (has not exited). Place this as
the last step in your test list. Fails if there are unresolved pending
BackendTask effects - use resolveEffect or
resolveEffectWith earlier in the list to resolve
them.
Assert that the TUI exited with code 0. Place this as the last step
in your test list. Fails if there are unresolved pending BackendTask
effects.
Assert that the TUI exited with a specific exit code. Place this as
the last step in your test list. Fails if there are unresolved pending
BackendTask effects.
Used by the generated elm-pages test stepper code.
Internal helper type used by resolveEffectWith.
Most tests do not need to refer to this directly; you can usually pass a
Test.BackendTask.simulate... pipeline inline.
import Json.Encode as Encode
import Test.BackendTask as BackendTaskTest
import Test.Tui as TuiTest
simulateStars : TuiTest.BackendTaskSimulator msg -> TuiTest.BackendTaskSimulator msg
simulateStars =
BackendTaskTest.simulateHttpGet
"https://api.github.com/repos/dillonkearns/elm-pages"
(Encode.object [ ( "stargazers_count", Encode.int 1234 ) ])Every step records a snapshot automatically. elm-pages test reads those
snapshots and lets you step through them visually in the terminal.
Use snapshots for low-level inspection, and
withModelToString when you also want to record model
state alongside each screen.
A snapshot of the TUI state at a point in the test pipeline. Used by
toSnapshots for the interactive test stepper.
screen is the Tui.Screen.Screen value (preserving styling), not a plain string.
Use Tui.Screen.toString to get plain text, or render it through the TUI pipeline
for styled output.
Enable model state inspection in snapshots. Pass Debug.toString (or
any model -> String function) and each snapshot will include the
pretty-printed model state.
Since published packages cannot use Debug.toString directly, this must
be called from your test code:
TuiTest.snapshots counterTest
[ TuiTest.withModelToString Debug.toString
, TuiTest.pressKey 'k'
]
|> List.map .modelState
-- [ Just "{ count = 0 }", Just "{ count = 1 }" ]For nicer formatting, use prettifyValue Debug.toString from
dillonkearns/elm-snapshot if you have it as a dependency.
An in-progress TUI test. Created with
start(orstartWithContext) and passed totestalong with a list ofStepvalues describing the user interactions and assertions.