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.
The app
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:
- Add a
duration time.Durationfield to themodel. We’ll use this to keep track of how long we should sleep. - Add a
start time.Timefield to themodelto mark when we started the countdown. - The
initialModelneeds to take thedurationas an argument. Set it into themodel, as well as settingstarttotime.Now(). - Add a
timeLeftmethod to the model, which calculates how long we still need to sleep. - In the
Updatemethod, we need to check if thattimeLeft > 0, and quit otherwise. - In the
Viewmethod, we need to display how much time is left. - Finally, in
mainwe parseos.Args[1]to atime.Durationand pass it down toinitialModel.
And that’s pretty much it. Here’s the link to the full diff.
Imports
Before anything else, we need to import the teatest package:
go get github.com/charmbracelet/x/exp/teatest@latest
The full output test
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)
}
- We created a
modelthat will sleep for 1 second. - We passed it to
teatest.NewTestModel, also ensuring a fixed terminal size - We ask for the
FinalOutput, and read it all. Finalmeans it will wait for thetea.Programto finish before returning, so be wary that this will block until that condition is met.- We check if the output we got is equal the output in the golden file.
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.
The final model test
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.
Intermediate output and sending messages
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.
The CI is failing. What now?
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.
Final words
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.