← BACK TO ENGINEERING
Runtime 13 min read

Porting Go's teatest to TypeScript: Golden-File Tests for Bubble Tea Programs

You can test a function by calling it. You can't test a terminal program that way. What it produces is a stream of escape sequences, it reads keys from a terminal, it runs on a clock, and it draws for a screen of a certain size.

Charm's Go answer is teatest, a small package in charmbracelet/x. It runs a Bubble Tea program with no terminal at a fixed size, lets a test send keys and messages, waits until the output says what you expect, hands back the final model, and compares everything the program wrote with a golden file.

Now it's available in TypeScript. It is the ninth package in the series, and the first one that tests the others.

A golden-file test of the kanban example fails after a column is renamed: the message shows the changed line as text, with In progress and Doing marked, and then the raw line with its escape sequences written out


I – What Shipped

@oakoliver/teatest 1.0.0, a port of teatest's v2 module (for Bubble Tea v2) and of the golden package it uses, taken from charmbracelet/x at commit e877ff7.

npm install --save-dev @oakoliver/teatest
npm install @oakoliver/bubbletea   # the peer dependency
import { expect, test } from 'bun:test';
import { NewTestModel, WaitFor, RequireEqualOutput } from '@oakoliver/teatest';
import { Counter } from './counter';

test('the counter counts with the arrow keys', async () => {
  const tm = NewTestModel(new Counter());

  await WaitFor(tm.output(), (out) => out.includes('Count: 0'));
  tm.press('up', 'up', 'up');
  await WaitFor(tm.output(), (out) => out.includes('Count: 3'));
  tm.press('q');

  expect((await tm.finalModel<Counter>()).count).toBe(3);
  RequireEqualOutput((await tm.finalOutput()).read(), 'counter');
});

The package has no dependencies of its own and one peer dependency, @oakoliver/bubbletea 1.2.1 or newer, which is the port that matches Bubble Tea v2.0.10. The tarball is 34.6 kB, and dist/index.js is 32,290 bytes. It has 137 tests, and it works with bun test, node:test and Vitest.

For a walk through writing tests with it, read Getting Started with teatest. This article is about how it was ported.


II – What teatest Does

Upstream's teatest.go is 318 lines. The idea fits in five parts:

  • NewTestModel starts the program with an input buffer, an output buffer, signal handling off and a fixed window size (80 by 24 unless you say otherwise). It returns a TestModel.
  • Send, Type, Quit put messages in the program. Type sends one key press per character.
  • WaitFor reads the output again and again until a function you gave says yes, or until a second has passed.
  • FinalModel, FinalOutput, WaitFinished wait for the program to end, and return the model it ended with, and the output it left.
  • RequireEqualOutput compares output with testdata/<test name>.golden, and prints a diff if they differ. A flag, -update, rewrites the file.

The last part lives in a second package, golden, 83 lines, that uses go-udiff for the diff. A golden file holds the raw bytes of the output, escape sequences and all. Before comparing, both sides are passed through strconv.Quote, one line at a time, so that a failure shows \x1b[1m and not a byte that would change your terminal's colour.


III – The Hard Parts

Most of teatest ports as it reads. What took work was what Go gives you for free.

A Test That Knows Its Name

RequireEqualOutput(tb, out) finds its file by asking tb.Name(). A TypeScript test function has no tb.

I checked what each runner offers. Vitest has expect.getState().currentTestName, and a global expect when globals are on. node:test passes a context whose name is the test's. Bun has neither: expect.getState is undefined in Bun 1.4, and nothing else says which test is running.

So the golden file is named by an argument, RequireEqualOutput(out, 'counter'), and where a runner does know the name, the argument can be left out or can be the runner's own context. Vitest's context turned out to be a function whose name is "context", so the port reads ctx.task first. A name may contain /, which makes a subdirectory, as Go's subtests do.

The directory is testdata next to the test file. Go gets that from the working directory of go test. Here it comes from the call stack: the first file outside the package's own directory.

Go's -update flag has no counterpart either. UPDATE_GOLDEN=1 does the same job. As in Go, it writes the file and then compares, so an update run still fails if it can't read what it wrote.

Reading Consumes

In Go, tm.Output() returns the program's bytes.Buffer, and reading a buffer empties it. WaitFor reads through a TeeReader into its own buffer; whatever it has read is gone from the output. So a golden file made after a WaitFor holds only what came after it.

That is easy to trip over, and it's also what makes golden files stable, because they hold the end of the output and not every frame on the way. I kept it. Output in the port has read(), which returns what was written since the last read(), and an addition, all(), which returns everything.

Waiting Without Blocking

The Go package blocks. FinalModel waits for a channel; waitDone uses sync.Once so that the first call decides how to wait. In TypeScript every wait is a promise. The first call to waitFinished, finalModel or finalOutput builds a promise from its options, and later calls share it, which is what sync.Once does.

Errors needed care. In Go the goroutine that runs the program calls tb.Fatalf if Run fails, from wherever it happens to be. A thrown error in a promise nobody awaits is lost. The port catches the program's failure, remembers it, and raises it from the wait that follows, as app failed: .... A program that kill() is stopped and a model that throws in update both end this way. Both are covered by tests.

The SIGINT handler that kills the program on Ctrl-C is kept, with one change: it removes itself when the program ends, or a long suite would add a listener per test.

No Diff Library

Upstream gets its diff from go-udiff. TypeScript has no equivalent I would trust to match, so diff.ts is a Myers diff (401 lines, with the readable rendering described below) and a unified-diff writer.

To know whether they agree, I ran real Go. A 77-line Go program, built against the same go-udiff v0.4.1 that upstream uses, writes, for 16 strings, what escapeSeqs produces, and for 13 pairs of texts, what udiff.Unified("golden", "run", a, b) returns. The strings include invalid UTF-8, U+00A0, a C1 control, an OSC 8 hyperlink and a bare \x00. The result is a JSON file in tests/fixtures/, and the tests compare the port with it, byte for byte.

All 16 escapes match. Twelve of the 13 diffs match. The 13th is a b c against c b a: it has two shortest edit scripts, two deletes and two inserts each, and the two libraries pick different ones. That one is tested as "the same length", not "the same text".

Along the way: go-udiff marks a missing final newline with \ No newline at end of file, and treats three and three\n as different lines. My first version split on \n and did neither. It splits on lines that keep their terminator now.

The Output Is Not Go's Output

You'd hope that a golden file from Go would pass in TypeScript. It can't. The two renderers don't write the same bytes at start and exit. Upstream's TestApp.golden starts \x1b[?25l\x1b[?2004h\x1b[=1;1u\x1b[?u, and the port's starts \x1b[?2004h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[>4;2m\x1b[>1u. So upstream's testdata isn't reused, and the port's tests write their own.

I did find one thing that mattered. The port's Program asks the terminal whether it supports synchronized output, by writing \x1b[?2026$p\x1b[?2027$p at start, unless TERM_PROGRAM says it's Apple's terminal or SSH_TTY is set. Which means the first bytes of every golden file depended on which terminal you ran the tests in, and a file made on a laptop failed in CI. Go's teatest doesn't pin anything and its golden file has no such probe.

NewTestModel now gives the program a fixed environment: TERM=xterm-256color, COLORTERM=truecolor and TERM_PROGRAM=Apple_Terminal, the combination in which the probe isn't sent. It's exported as DEFAULT_TEST_ENVIRONMENT, and WithProgramOptions(WithEnvironment([...])) changes it.

An Upstream Test That Is a Race

TestAppSendToOtherProgram starts two programs that send each other a ping for each p typed, quits both with q, and asserts that their outputs are identical. It passes in Go because of how the goroutines are scheduled.

In the port, both event loops share one thread. type("pp") and type("pppp") put their keys in the two queues before either program has run. Each program handles its own keys first, and the other's pings arrive after, so the two outputs have the same six lines in different orders. Worse, a program quits through a command, so a ping from the other program can arrive after the quit and be lost: in every run I tried, the first program ended with five pings and not six.

The port's version waits until both models have six messages and then quits, and compares the output with a golden file. It is the one upstream test that I changed, and the README says so.

Taking the Clock Away

A Bubble Tea program that uses Tick runs on real time, and TestApp really waits: it sleeps 1.2 s and its interactive twin waits for a countdown. That's the upstream style and I ported it as it is. But it's not how I'd write tests, so the examples show another way.

WithFilter is a program option that sees every message before the model does. A filter that drops the program's own timer messages, and a test that sends its own, turns time into something the test controls. A countdown of three one-second ticks, measured with Bun 1.4.4:

Time
Waiting for the real ticks 3.009 s
Sending the ticks from the test 0.4 ms

The kanban example's tests use the same trick to drop the program's flash and toast timers, so no golden file depends on how long a test took. The blocks example, a falling-blocks game, replaces the whole clock: the test sends the frames, each with a time it chooses, so a second of game time costs nothing, and the AI that plays the game must end seed 7 in the same position every time.

A Failure Message You Can Read

This is the part I spent the most time on, because it's what people see.

When a golden file doesn't match, Go prints the expected text, the actual text and the diff, each in full. For a Bubble Tea frame that's hundreds of characters per line, mostly \x1b[38;2;255;180;84m. In the kanban example the longest line of a golden file is 1,112 characters once its escape sequences are written out, and the changed word is somewhere in the middle of it.

What the port prints for each changed line is two views. The first is the text as it would look on screen, with the escape sequences left out and the words that differ marked. The second is the raw line, with the escape sequences written out, cut to the neighbourhood of the change, and the tokens that differ marked. Here a column was renamed from In progress to Doing:

GoldenMismatchError: output does not match examples/testdata/kanban/first-screen.golden
golden 24 lines, this run 24: 1 removed, 1 added. Escape sequences are written as text (\x1b is the ESC byte).
If the new output is right, accept it with UPDATE_GOLDEN=1.

@@ -2,3 +2,3 @@
     2 │    ╭──────────────────────────────╮ ╭──────────────────────────────╮ ╭──────────────────────────────╮
-    3 │    │ ● To do                   5  │ │ ● In progress             4  │ │ ● Done                    3  │
         ?                                       ^^ ^^^^^^^^
       raw …\x1b[1m\x1b[38;2;255;180;84mIn progress\x1b[m            \x1b[1m…
         ?                              ^^^^^^^^^^^
+    3 │    │ ● To do                   5  │ │ ● Doing                   4  │ │ ● Done                    3  │
         ?                                       ^^^^^ ^^^^^
       raw …\x1b[1m\x1b[38;2;255;180;84mDoing\x1b[m                  \x1b[1m\x1b[38;2;255;180;84m…
         ?                              ^^^^^      ^^^^^^
     4 │    ├──────────────────────────────┤ ├──────────────────────────────┤ ├──────────────────────────────┤

With colour, the marks are reverse video, in red and green, and the escape sequences in the raw line are dim. If only escape sequences changed, it says "same text, different escape sequences" and leaves the text view out. The line numbers are the line numbers of the golden file, which in Bubble Tea's output are the rows of the screen.

Two more details, both found by seeing Bun print the error. Bun prints an error's own properties, so expected, actual and diff (the plain unified diff, kept for tools) are not enumerable; they would have filled the screen. And the stack starts at your call to RequireEqualOutput, not inside the library, so Bun shows the line of your test, as in the picture above, instead of a line of golden.ts.

WaitFor got the same treatment. Go prints the last output raw. The port prints how much it read, and the last 30 lines of the text with the escape sequences removed, each cut at 160 characters. In the kanban test, a WaitFor for a title that was off the screen printed the board as it had been drawn, which told me in two seconds why it timed out. The raw bytes are on the error as output.


IV – Test Parity

Upstream has ten tests across the two packages: six in teatest/v2 and four in golden. All ten are ported. TestTypes has two subtests, so Bun counts eleven.

File Tests What
app.test.ts 2 TestApp, TestAppInteractive, with real seconds
send.test.ts 1 TestAppSendToOtherProgram, changed as described above
waitfor.test.ts 13 the three tests of teatest_test.go, and the failure message
golden.test.ts 17 the four tests of golden_test.go, and checks against real Go
diff.test.ts 13 the diff and the readable rendering
keys.test.ts 50 every named key parsed back to its name
extras.test.ts 12 the size, the input, the environment, the output, the lifecycle
tests/ 108
examples/ 29 five suites, described below
Total 137

Two Go tests needed their package's internals: TestWaitForErrorReader and TestWaitForTimeout call the unexported doWaitFor. It's exported in the port, for the same tests. Go's iotest.ErrReader and strings.NewReader became two small Reader objects.

The full suite takes about 15.5 s. The slowest file is the blocks suite, 8.6 s, where the AI plays four 20-second games. The next is app.test.ts, 4.2 s, because TestApp sleeps 1.2 s and TestAppInteractive waits for a one-second countdown to reach 7. I ran the whole suite 10 times in a row, and nine more times with three copies running at once; all 19 runs passed.


V – What the Examples Needed

The two big examples are copies of kanban.ts and blocks.ts from the Bubble Tea examples, with the program untouched. To import them from a test, only the plumbing changed: the imports name the package, the classes are exported, and the command line and the final run() only happen when the file is run directly. Their tests are 13 and 11 tests and show most of the API: flows with press and type, goldens, a resize to a terminal that is too small and back, a file the board saves to, a seeded game.

While writing them I wanted three things that Go's teatest doesn't have, and added them:

  • press("shift+right", "enter", "ctrl+c"). Go's tests build tea.KeyPressMsg{Code: ...} by hand. press takes the names that KeyPressMsg.toString() prints, turns each into the bytes a terminal sends, and runs them through Bubble Tea's own input parser, so the model gets exactly the message a real terminal would produce. 47 names round-trip in a test, and an unknown name throws.
  • settle(), which resolves when the program has handled everything sent so far. Without it, a test that looks at the model right after press sees the old one.
  • resize(w, h), a WindowSizeMsg.

VI – A Bug in the Port Below

press("alt+x") found one. In Go, ESC x is a key with code x and the Alt modifier and no text, so its string is alt+x. In @oakoliver/bubbletea 1.2.1 the parser also sets the text to x, and KeyPressMsg.toString() returns the text when there is one: "x". The code and the modifier are right; only the string is wrong, and key bindings that match on alt+x won't see it. I haven't changed the other repository. The README of teatest lists it, and the test of press checks the code and modifier instead.


VII – What's Next

Two things I didn't do. Jest should work, because RequireEqualOutput reads expect.getState(), but I didn't run it. And a screen model: teatest compares the byte stream, as Go's does, so a test that cares about what a person would see after a hundred frames still needs a terminal emulator that applies the cursor moves. That would be a different package.

To write your first test, see Getting Started with teatest. The framework underneath is in Getting Started with Bubble Tea.


The TypeScript Charm Series

Each package is a TypeScript port of one of Charm's Go tools, published on npm and running on Node.js and Bun.

Package What it does Start here How it was ported Source
@oakoliver/lipgloss Terminal styling Getting started Porting story GitHub
@oakoliver/glamour Markdown rendering Getting started Porting story GitHub
@oakoliver/bubbletea TUI framework Getting started Porting story GitHub
@oakoliver/bubbles TUI components Getting started Porting story GitHub
@oakoliver/huh Terminal forms Getting started Porting story GitHub
@oakoliver/glow Markdown reader Getting started Porting story GitHub
@oakoliver/gum Shell script prompts Getting started — GitHub
@oakoliver/vhs Terminal GIFs Getting started — GitHub
@oakoliver/teatest Tests for Bubble Tea programs Getting started this article GitHub

– Antonio

"Simplicity is the ultimate sophistication."