dillonkearns/elm-pages/12.3.0

Test.Tui

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:

The same named tests can be:

  • Run headlessly through toTest with elm-test
  • Visualized through elm-pages test, which reads the same TuiTest.Test value and shows the recorded snapshots in the terminal stepper
import 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 tuiTests
type TuiTest model msg

An in-progress TUI test. Created with start (or startWithContext) and passed to test along with a list of Step values describing the user interactions and assertions.

type Test

A 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 tuiTests
type Step model msg

A 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
    ]

Starting a Test

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 : TestSetup -> ProgramConfig data model msg -> TuiTest model msg

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.

Context
-> TestSetup
-> ProgramConfig data model msg
-> TuiTest model msg

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.

Building Test Suites

Build a named test from a starting TuiTest and a list of Step values. Group tests with describe.

test : String -> TuiTest model msg -> List (Step model msg) -> Test

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
        ]
describe : String -> List Test -> Test

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
            ]
        ]
toTest : Test -> Test

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 tuiTests
toNamedSnapshots : Test -> List ( String, List Snapshot )

Flatten 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.first

Other Runners

Use these when you don't want to wrap a single test in test - for example, in plain elm-test \() -> ... bodies or when feeding the visual runner directly. runSteps is the lower-level primitive that threads a list of steps through a TuiTest without finalizing it.

expect : TuiTest model msg -> List (Step model msg) -> Expectation

Run 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
                ]
snapshots : TuiTest model msg -> List (Step model msg) -> List Snapshot

Run a list of Steps against a starting TuiTest and return the recorded snapshots. Use this when feeding the TUI stepper without going through test.

runSteps : List (Step model msg) -> TuiTest model msg -> TuiTest model msg

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"
    ]
    counterTest
applyStep : Step model msg -> TuiTest model msg -> TuiTest model msg

Apply 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.expectRunning

Simulating Events

Simulate user interactions in the order they would happen. Each function returns a Step; add them in order to the list passed to test or expect.

Prefer the user-facing helpers first (pressKey, clickText, paste, resize).

pressKey : Char -> Step model msg

Simulate pressing a character key with no modifiers.

[ TuiTest.pressKey 'j' ]
pressKeyWith : KeyEvent -> Step model msg

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 ]
    }
]
pressKeyN : Int -> Char -> Step model msg

Simulate pressing a character key N times.

-- Navigate down 7 items
[ TuiTest.pressKeyN 7 'j' ]
paste : String -> Step model msg

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"
]
{ width : Int
, height : Int
}
-> Step model msg

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.

{ row : Int
, col : Int
}
-> Step model msg

Simulate a left mouse click at the given row and column (0-based).

[ TuiTest.click { row = 3, col = 5 } ]
clickText : String -> Step model msg

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.

Int
-> { row : Int
, col : Int
}
-> Step model msg

Simulate N scroll-down events at the given position.

[ TuiTest.scrollDownN 10 { row = 3, col = 60 } ]
Int
-> { row : Int
, col : Int
}
-> Step model msg

Simulate N scroll-up events at the given position.

[ TuiTest.scrollUpN 5 { row = 3, col = 60 } ]
advanceTime : Int -> Step model msg

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.expectRunning

Multi-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.

Resolving Effects

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")
resolveEffect : Step model msg

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
        ]
(BackendTaskSimulator msg -> BackendTaskSimulator msg)
-> Step model msg

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"
]

Screen Assertions

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.

ensureView : (String -> Expectation) -> Step model msg

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
    )
]
ensureViewHas : String -> Step model msg

Assert that the current screen contains the given text.

[ TuiTest.ensureViewHas "Count: 0" ]
ensureViewDoesNotHave : String -> Step model msg

Assert that the current screen does NOT contain the given text.

ensureModel : (model -> Expectation) -> Step model msg

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
    ]
annotateAssertion : String -> Step model msg

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 ++ " ✓")
    ]

Styled Text Assertions

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.

fg : Color -> StyleCheck

Match text with a specific foreground color.

|> TuiTest.ensureViewHasStyled [ TuiTest.fg Ansi.Color.red ] "Error"
bg : Color -> StyleCheck

Match text with a specific background color.

|> TuiTest.ensureViewHasStyled [ TuiTest.bg Ansi.Color.blue ] "Selected"
ensureViewHasStyled : List StyleCheck -> String -> Step model msg

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
    ]
ensureViewDoesNotHaveStyled : List StyleCheck -> String -> Step model msg

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
    ]

Final Assertions

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.

expectRunning : Step model msg

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.

expectExit : Step model msg

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.

expectExitWith : Int -> Step model msg

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.

Internal

Used by the generated elm-pages test stepper code.

type alias BackendTaskSimulator msg =
BackendTaskTest msg

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 ) ])

Snapshots

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.

type alias Snapshot =
{ label : String
, screen : Screen
, hasPendingEffects : Bool
, modelState : Maybe String
, assertions : List String
}

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.

withModelToString : (model -> String) -> Step model msg

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.