#004: First foray into snapshot testing bubbletea apps
I released v0.6.0 of hours today. It mostly includes quality-of-life features requested by users and a few UI improvements from my side. Here’s the changelog:
Added
- Allow filtering tasks by status in analytics commands (log, report, stats)
- Keymap to finish active task log without comment
- Contextual cues in the “Task Log Entry” view
Changed
- Removed date range limit for stats and log commands
- Missing end date in date range implies today (eg. 2025/08/12…)
- “today” can be used in date range (eg. 2025/08/12…today)
- Improved TUI navigation: esc/q now function in more panes, returning the user to previous panes in a predictable manner
- User messages in the TUI remain visible for a while
- Minimum terminal width needed brought down to 80 characters (from 96)
While refactoring the app for this release, I felt the need for snapshot tests to ensure I hadn’t caused regressions. The app didn’t have any, so I decided to set them up.
I’ve been used to snapshot testing my Rust apps using insta, which works like a charm — you run tests, review changes in the generated snapshots, accept or reject them. It makes refactoring large codebases far less painful.
For hours, which is a bubbletea app, I needed to find a Go counterpart. The two options I could find were go-snaps and cupaloy. The latter was last updated 3 years ago, so I went with go-snaps.
The experience was fine, but not as streamlined as insta, which supports interactive review of the snapshots. With go-snaps, the process is a bit manual — you set an environment variable and either accept all changes, or filter the tests being run for selective acceptance. Still, it gets the job done and I’m thankful to gkampitakis for creating it.
TUI testing approaches
Ever since I started snapshot testing TUIs, I’ve been pondering over the level of abstraction at which to test them. I design my TUI apps based on the Elm architecture — there’s a model, an update loop, and a view function. The UI is derived deterministically from the model.
I see four approaches for testing the TUI’s output:
- Rely only on the update loop of the app to make changes to the model (requires proper handling of any side effects performed by the app) — most integration-test-like
- Rely on the update loop and sometimes on the model itself — a mix of unit and integration tests
- Rely only on the model — most unit-test-like, but internal details bleed into testing code
- Treat the entire TUI as a black box, and drive it via events performed by the user — most E2E-test-like
I’ve been experimenting with the first three, and so far have found the second option to strike the best balance between elegance and pragmatism (here’s an example using insta). Since adding comprehensive snapshot tests wasn’t my priority for v0.6.0, and since Option 1 or 2 would’ve required a fair amount of setup, I went with Option 3 for now. The goal is to move to a more elegant approach in the future. I might also look into the experimental package teatest from the makers of bubbletea, which could help in setting up true E2E tests.
Getting a basic form of snapshot testing in hours feels like a good first step. This was the first time I’ve retroactively added snapshot tests to a medium-sized TUI codebase. It’s definitely more painful this way than having them set up from the get-go, but I liked how this process forced me to think deeply about the architecture of the app.
…
Oh, and there’s clearly an opportunity for an insta-like tool for the Go ecosystem.