Announcing GoReleaser v2.4
New release coming in hot! new: create macOS app bundles. Initially they are only usable with …
Learn how to use x/exp/teatest to write tests for your Bubble Tea apps.
Last week we launched our github.com/charmbracelet/x
repository,
which will contain experimental code that we’re not ready to promise any
compatibility guarantees on just yet.
The first module there is called teatest
.
It is a library that aims to help you test your Bubble Tea apps.
You can assert the entire output of your program, parts of it, and/or its
internal tea.Model
state.
In this post we’ll add tests to an existing app using current’s teatest
version API.
Our example app is a simple sleep
-like program, that shows how much time
is left. It is similar to my timer
TUI, if you’re interested in
something more complete.
Without further ado, let’s create the app.
First, navigate here to create a new repository based on our bubbletea-app-template repository.
Then clone it. In my case, I called it teatest-example
:
gh repo clone caarlos0/teatest-example
cd teatest-example
go mod tidy
$EDITOR .
This example will just sleep until the user presses q
to exit.
With a few modifications we can get what we want:
duration time.Duration
field to the model
. We’ll use this to keep
track of how long we should sleep.start time.Time
field to the model
to mark when we started the
countdown.initialModel
needs to take the duration
as an argument. Set it into the
model
, as well as setting start
to time.Now()
.timeLeft
method to the model, which calculates how long we still
need to sleep.Update
method, we need to check if that timeLeft > 0
, and quit
otherwise.View
method, we need to display how much time is left.main
we parse os.Args[1]
to a time.Duration
and pass it down
to initialModel
.And that’s pretty much it. Here’s the link to the full diff.
Before anything else, we need to import the teatest
package:
go get github.com/charmbracelet/x/exp/teatest@latest
Next let’s create a main_test.go
and start with a simple test that asserts
the entire final output of the app.
Here’s what it looks like:
// main_test.go
func TestFullOutput(t *testing.T) {
m := initialModel(time.Second)
tm := teatest.NewTestModel(
t, m,
teatest.WithInitialTermSize(300, 100),
)
out, err := io.ReadAll(tm.FinalOutput(t))
if err != nil {
t.Error(err)
}
teatest.RequireEqualOutput(t, out)
}
model
that will sleep for 1 second.teatest.NewTestModel
, also ensuring a fixed terminal sizeFinalOutput
, and read it all.
Final
means it will wait for the tea.Program
to finish before
returning, so be wary that this will block until that condition is met.If you just run go test ./...
, you’ll see that it errors. That’s because we
don’t have a golden file yet. To fix that, run:
go test -v ./... -update
The -update
flag comes from the teatest
package. It will update the
golden file (or create it if it doesn’t exist).
You can also cat
the golden file to see what it looks like:
> cat testdata/TestFullOutput.golden
⣻ sleeping 0s... press q to quit
In subsequent tests, you’ll want to run go test
without the -update
, unless
you changed the output portion of your program.
Here’s the link to the full diff.
Bubble Tea returns the final model after it finishes running, so we can also assert against that final model:
// main_test.go
func TestFinalModel(t *testing.T) {
tm := teatest.NewTestModel(
t,
initialModel(time.Second),
teatest.WithInitialTermSize(300, 100),
)
fm := tm.FinalModel(t)
m, ok := fm.(model)
if !ok {
t.Fatalf("final model have the wrong type: %T", fm)
}
if m.duration != time.Second {
t.Errorf("m.duration != 1s: %s", m.duration)
}
if m.start.After(time.Now().Add(-1 * time.Second)) {
t.Errorf("m.start should be more than 1 second ago: %s", m.start)
}
}
The setup is basically the same as the previous test, but instead of the asking
for the FinalOutput
, we ask for the FinalModel
.
We then need to cast it to the concrete type and then, finally, we assert for
the m.duration
and m.start
.
Here’s the link to the full diff.
Another useful test case is to ensure things happen during the test. We also need to interact with the program while its running.
Let’s write a quick test exploring these options:
// main_test.go
func TestOuput(t *testing.T) {
tm := teatest.NewTestModel(
t,
initialModel(10*time.Second),
teatest.WithInitialTermSize(300, 100),
)
teatest.WaitFor(
t, tm.Output(),
func(bts []byte) bool {
return bytes.Contains(bts, []byte("sleeping 8s"))
},
teatest.WithCheckInterval(time.Millisecond*100),
teatest.WithDuration(time.Second*3),
)
tm.Send(tea.KeyMsg{
Type: tea.KeyRunes,
Runes: []rune("q"),
})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
}
We setup our teatest
in the same fashion as the previous test, then we assert
that the app, at some point, is showing sleeping 8s
, meaning 2 seconds have
elapsed. We give that condition 3 seconds of time to be met, or else we fail.
Finally, we send a tea.KeyMsg
with the character q
on it, which should cause
the app to quit.
To ensure it quits in time, we WaitFinished
with a timeout of 1 second.
This way we can be sure we finished because we send a q
key press, not because
the program runs its 10 seconds out.
Here’s the link to the full diff.
Once you push your commits GitHub Actions will test them and likely fail.
The reason for this is because your local golden file was generated with
whatever color profile the terminal go test
was run in reported while GitHub
Actions is probably reporting something different.
Luckily, we can force everything to use the same color profile:
// main_test.go
func init() {
lipgloss.SetColorProfile(termenv.Ascii)
}
In this app we don’t need to worry too much about colors, so its fine to use the
Ascii
profile, which disables colors.
Another thing that might cause tests to fail is line endings. The golden files look like text, but their line endings shouldn’t be messed with—and git might just do that.
To remedy the situation, I recommend adding this to your .gitattributes
file:
*.golden -text
This will keep Git from handling them as text files.
Here’s the link to the full diff.
This is an experimental, work in progress library, hence the
github.com/charmbracelet/x/exp/teatest
package name.
We encourage you to try it out in your projects and report back what you find.
And, if you’re interested, here’s the link to the repository for this post.
This post is cross-posted from The Charm Blog.