catwalk

package module
v0.1.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 5, 2023 License: Apache-2.0 Imports: 16 Imported by: 0

README

catwalk

Latest Release GoDoc Build Status Go ReportCard Coverage Status

catwalk is a unit test library for Bubbletea TUI models (a.k.a. “bubbles”).

It enables implementers to verify the state of models and their View as they process tea.Msg objects through their Update method.

It is implemented on top of datadriven, an extension to Go's simple "table-driven testing" idiom.

Datadriven tests use data files containing both the reference input and output, instead of a data structure. Each data file can contain multiple tests. When the implementation changes, the reference output can be quickly updated by re-running the tests with the -rewrite flag.

Why the name: the framework forces the Tea models to "show themselves" on the runway of the test input files.

Example

Let's test the viewport Bubble!

First, we define a top-level model around viewport:

type Model struct {
    viewport.Model
}

var _ tea.Model = (*Model)(nil)

// New initializes a new model.
func New(width, height int) *Model {
    return &Model{
        Model: viewport.New(width, height),
    }
}

// Init adds some initial text inside the viewport.
func (m *Model) Init() tea.Cmd {
    cmd := m.Model.Init()
    m.SetContent(`first line
second line
third line
fourth line
fifth line
sixth line`)
    return cmd
}

Then, we define a Go test which runs the above model:

func TestModel(t *testing.T) {
	// Initialize the model to test.
	m := New(40, 3)

    // Run all the tests in input file "testdata/viewport_tests"
	catwalk.RunModel(t, "testdata/viewport_tests", m)
}

Then, we populate some test directives inside testdata/viewport_tests:

run
----

# One line down
run
type j
----

# Two lines down
run
type jj
----

# One line up
run
key up
----

Then, we run the test: go test ..

What happens: the test fails!

--- FAIL: TestModel (0.00s)
    catwalk.go:64:
        testdata/viewport_tests:1:
        expected:

        found:
        -- view:
        first line␤
        second line␤
        third line🛇

This is because we haven't yet expressed what is the expected output for each step.

Because it's tedious to do this manually, we can auto-generate the expected output from the actual output, using the rewrite flag:

 go test . -args -rewrite

Observe what happened with the input file:

run
----
-- view:
first line␤
second line␤
third line🛇

# One line down
run
type j
----
-- view:
second line␤
third line␤
fourth line🛇

# Two lines down
run
type jj
----
-- view:
fourth line␤
fifth line␤
sixth line🛇

# One line up
run
key up
----
-- view:
third line␤
fourth line␤
fifth line🛇

Now each expected output reflects how the viewport reacts to the key presses. Now also go test . succeeds.

Structure of a test file

Test files contain zero or more tests, with the following structure:

<directive> <arguments>
<optional: input commands...>
----
<expected output>

For example:

run
----
-- view:
My bubble rendered here.

run
type q
----
-- view:
My bubble reacted to "q".

Catwalk supports the following directives:

  • run: apply state changes to the model via its Update method, then show the results.
  • set/reset: change configuration variables.

Finally, directives can take arguments. For example:

run observe=(gostruct,view)
----

This is explained further in the next sections.

The run directive

run defines one unit test. It applies some input commands to the model then compares the resulting state of the model with a reference expected output.

Under run, the following input commands are supported:

  • type <text>: produce a series of tea.KeyMsg with type tea.KeyRunes. Can contain spaces.

    For example: type abc produces 3 key presses for a, b, c.

  • enter <text>: like type, but also add a key press for the enter key at the end.

  • key <keyname>: produce one tea.KeyMsg for the given key.

    For example: key ctrl+c

  • paste "<text>": paste the text as a single key event. The text can contain Go escape sequences.

    For example: paste "hello\nworld"

  • resize <W> <H>: produce a tea.WindowSizeMsg with the specified size.

You can also add support for your own input commands by passing an Updater function to catwalk.RunModel with the WithUpdater() option, and combine multiple updaters together using the ChainUpdater() function.

The run directive accepts the following arguments:

  • observe: what to look at as expected output (observe=xx or observe=(xx,yy)).

    By default, observe is set to view: look at the model's View() method. Alternatively, you can use the following observers:

    • gostruct: show the contents of the model object as a go struct.
    • debug: call the model's Debug() string method, if defined.

    You can also add your own observers using the WithObserver() option.

  • trace: detail the intermediate steps of the test.

    Used for debugging tests.

The set and reset directives

These can be used to configure parameters in the test driver.

For example:

set cmd_timeout=100ms
----
cmd_timeout: 100ms

reset cmd_timeout
----
ok

The following parameters are currently recognized:

  • cmd_timeout: how long to wait for a tea.Cmd to complete. This is set by default to 20ms, which is sufficient to ignore the commands of a blinking cursor.

Advanced topic: testing style changes

Many bubbles have a Styles struct with configurable styles (using lipgloss). It's useful to verify that the bubbles react properly when the styles are reconfigured at run-time.

For this, you can tell catwalk about your styles this will activate the following special run input commands:

restyle <stylefield> <newstyle...>`

For example: restyle mymodel.ValueStyle foreground: #f00 changes the ValueStyle style to use the color red, as if .ValueStyle.Foreground(lipgloss.Color("#f00")) was called.

To activate, use the option catwalk.WithUpdater(catwalk.StylesUpdater(...)). For example:

func TestStyles(t *testing.T) {
  m := New(...)
  catwalk.RunModel(t, "testdata/styles", m, catwalk.WithUpdater(
    // The string "hello" is the prefix for identifying the styles container in tests.
    // Useful when there are multiple nested models.
    catwalk.StylesUpdater("hello",
      func(m tea.Model, fn func(interface{}) error) (tea.Model, error) {
        tm := m.(myModel)
        err := fn(&tm)
        return tm, err
    }),
  ))
}

After this, the input command restyle hello.X ... will automatically affect the style .X in your model.

Alternatively, if your model implements tea.Model by reference (i.e. the address of its styles does not change between Update calls), you can simplify as follows:

func TestStyles(t *testing.T) {
  m := New(...)
  catwalk.RunModel(t, "testdata/bindings", &m, catwalk.WithUpdater(
    // The string "hello" is the prefix for identifying the styles container in tests.
    // Useful when there are multiple nested models.
    KeyMapUpdater("hello", catwalk.SimpleStylesApplier(&m))))
}

See the test TestStyles in styles_test.go and the input file testdata/styles for an example.

Advanced topic: testing key bindings

Many bubbles have a KeyMap struct with configurable key bindings. It's useful to verify that the bubbles react properly when the keymaps are reconfigured at run-time.

For this, you can tell catwalk about your KeyMap struct and this will activate the following special run input commands:

  • keybind <keymapfield> <newbinding>

    For example: keybind mykeys.CursorUp up j rebinds the CursorUp binding in the KeyMap mykeys as if key.NewBinding(key.WithKeys("up", "j")) was called.

  • keyhelp <keymapfield> <helpkey> <helptext>

    For example: keybind mykeys.CursorUp up move the cursor up rebinds the CursorUp binding in the KeyMap mykeys as if key.NewBinding(key.WithHelp("up", "move the cursor up")) was called.

To declare a KeyMap in a test, use the option catwalk.WithUpdater(catwalk.KeyMapUpdater(...)). For example:

func TestBindings(t *testing.T) {
  m := New(...)
  catwalk.RunModel(t, "testdata/bindings", m, catwalk.WithUpdater(
    // The string "hello" is the prefix for identifying the keymap in tests.
	// Useful when the model contains multiple keymaps.
    catwalk.KeyMapUpdater("hello",
      func(m tea.Model, fn func(interface{}) error) (tea.Model, error) {
        tm := m.(YourModel)
        err := fn(&tm.KeyMap)
        return tm, err
    }),
  ))
}

After this, the input command keybind hello.X ... will automatically affect the binding .KeyMap.X in your model.

Alternatively, if your model implements tea.Model by reference (i.e. the address of its KeyMap does not change between Update calls), you can simplify as follows:

func TestBindings(t *testing.T) {
  m := New(...)
  catwalk.RunModel(t, "testdata/bindings", &m, catwalk.WithUpdater(
    // The string "hello" is the prefix for identifying the keymap in tests.
	// Useful when the model contains multiple keymaps.
    KeyMapUpdater("hello", catwalk.SimpleKeyMapApplier(&m.KeyMap))))
}

See the test TestRebind in bindings_test.go and the input file testdata/bindings for an example.

Your turn!

You can start using catwalk in your Bubbletea / Charm projects right away!

If you have any questions or comments:

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RunModel

func RunModel(t *testing.T, path string, m tea.Model, opts ...Option)

RunModel runs the tests contained in the file pointed to by 'path' on the model m, using a fresh driver initialize via NewDriver and the specified options.

To apply RunModel on all the test files in a directory, use datadriven.Walk.

func RunModelFromString

func RunModelFromString(t *testing.T, input string, m tea.Model, opts ...Option)

RunModelFromString is a version of RunModel which takes the input test directives from a string directly.

Types

type Driver

type Driver interface {
	// Close stops the model at the end.
	Close(t TB)

	// ApplyTextCommand applies the given textual command to the model.
	// It may return an extra tea.Cmd to process by the test.
	ApplyTextCommand(t TB, cmd string, args ...string) tea.Cmd

	// Observe observes the given component of the model.
	// Supported values:
	// - view: call View()
	// - gostruct: print with %#v
	// - debug: call Debug()
	Observe(t TB, what string) string

	// RunOneTest runs one step of a test file.
	//
	// The following directives are supported:
	//
	// - run: apply some state changes and view the result.
	//
	//   Supported directive options:
	//   - trace: produce a log of the intermediate test steps.
	//   - observe: what to observe after the state changes.
	//
	//     Supported values for observe:
	//     - view: the result of calling View().
	//     - gostruct: the result of printing the model with %#v.
	//     - debug: the result of calling the Debug() method (it needs to be defined)
	//     - msgs/cmds: print the residual tea.Cmd / tea.Msg input.
	//
	//   Supported input commands under "run":
	//   - type: enter some runes as tea.Key
	//   - key: enter a special key or combination as a tea.Key
	RunOneTest(t TB, d *datadriven.TestData) string
}

Driver is the externally-visible interface for a test driver.

func NewDriver

func NewDriver(m tea.Model, opts ...Option) Driver

NewDriver creates a test driver for the given model.

type KeyMapApplier

type KeyMapApplier func(m tea.Model, changeKeyMap func(interface{}) error) (tea.Model, error)

KeyMapApplier is the type of a function which applies the changeKeyMap callback on a KeyMap struct inside the model, then returns the resulting model.

Example implementation:

func(m tea.Model, changeKeyMap func(interface{}) error) (tea.Model, err) {
   myModel := m.(mymodel)
   if err := changeKeyMap(&myModel.KeyMap); err != nil {
         return m, err
   }
   return myModel, nil
}

func SimpleKeyMapApplier

func SimpleKeyMapApplier(keymap interface{}) KeyMapApplier

SimpleKeyMapApplier is a helper to simplify the definition of the function argument to KeyMapUpdater, in the case the model is implemented by reference -- i.e. the address of the KeyMap does not change from one call to Update to the next.

type Observer

type Observer func(out io.Writer, m tea.Model) error

Observer is an optional function added to RunModel(), which can extract information from the model to serve as expected output in tests.

type Option

type Option func(*driver)

Option is the type of an option which can be specified with RunModel or NewDriver.

func WithAutoInitDisabled

func WithAutoInitDisabled() Option

WithAutoInitDisabled tells the test driver to not automatically initialize the model (via the Init method) upon first use.

func WithObserver

func WithObserver(what string, obs Observer) Option

WithObserver tells the test driver to support an additional observer with the given function.

For example, after WithObserver("hello", myObserver) The function myObserver() will be called every time a test specifies `observe=hello` in the run directive.

func WithUpdater

func WithUpdater(upd Updater) Option

WithUpdater adds the specified model updater to the test. It is possible to use multiple WithUpdater options, which will chain them automatically (using ChainUpdaters).

func WithWindowSize

func WithWindowSize(width, height int) Option

WithWindowSize tells the test driver to issue a tea.WindowSizeMsg as the first event after initialization.

type StylesApplier

type StylesApplier func(m tea.Model, changeStyles func(interface{}) error) (tea.Model, error)

StylesApplier is the type of a function which applies the changeStyles callback on a struct inside the model, then returns the resulting model.

Example implementation:

func(m tea.Model, changeStyles func(interface{}) error) (tea.Model, err) {
   myModel := m.(mymodel)
   if err := changeKeyMap(&myModel); err != nil {
         return m, err
   }
   return myModel, nil
}

func SimpleStylesApplier

func SimpleStylesApplier(styledStruct interface{}) StylesApplier

SimpleSyylesApplier is a helper to simplify the definition of the function argument to StylesUpdater, in the case the model is implemented by reference -- i.e. the address of the styles does not change from one call to Update to the next.

type TB

type TB interface {
	Fatal(...interface{})
	Fatalf(string, ...interface{})
	Logf(string, ...interface{})
}

TB is a shim interface for testing.T / testing.B.

type Updater

type Updater func(m tea.Model, testCmd string, args ...string) (supported bool, newModel tea.Model, teaCmd tea.Cmd, err error)

Updater is an optional function added to RunModel(), which can apply state change commands as input to a test.

It should return false in the first return value to indicate that the command is not supported.

It can return an error e.g. to indicate that the command is supported but its arguments use invalid syntax, or that the model is in an invalid state.

func ChainUpdaters

func ChainUpdaters(upds ...Updater) Updater

ChainUpdaters chains the specified updaters into a resulting updater that supports all the commands in the chain. Test input commands are passed to each updater in turn until the first updater that supports it.

For example: - upd1 supports command "print" - upd2 supports command "get" - ChainUpdaters(upd1, upd2) will support both commands "print" and "get.

func KeyMapUpdater

func KeyMapUpdater(prefix string, apply KeyMapApplier) Updater

KeyMapUpdater defines an updater which supports the "keybind" and "keyhelp" commands to change a KeyMap struct. You can add this to a test using WithUpdater(). It is possible to add multiple keymap updaters to the same test.

A KeyMap struct is any go struct containing exported fields of type key.Binding. For example, using:

KeyMapUpdater("mymodel",
              func(m tea.Model, changeKeyMap func(interface{})) (tea.Model, err) {
                 myModel := m.(mymodel)
                 if err := changeKeyMap(&myModel.KeyMap); err != nil {
                       return m, err
                 }
                 return myModel, nil
              })

and mymodel.KeyMap containing a CursorUp binding, it becomes possible to use "keybind mymodel.CursorUp ctrl+c" to define a new keybinding during a test.

If your model implements tea.Model by reference (i.e. its address does not change through Update calls), you can simplify the call as follows:

KeyMapUpdater("...", SimpleKeyMapApplier(&yourmodel.KeyMap)).

func StylesUpdater

func StylesUpdater(prefix string, apply StylesApplier) Updater

StylesUpdater defines an updater which supports the "restyle" command to change a struct containing lipgloss.Style fields. You can add this to a test using WithUpdater(). It is possible to add multiple styles updaters to the same test.

For example, using:

StylesUpdater("mymodel",
              func(m tea.Model, changeStyles func(interface{})) (tea.Model, err) {
                 myModel := m.(mymodel)
                 if err := changeStyles(&myModel); err != nil {
                       return m, err
                 }
                 return myModel, nil
              })

and mymodel containing a CursorStyle field, it becomes possible to use "restyle mymodel.CursorStyle foreground: 11" to define a new style during a test.

If your model implements tea.Model by reference (i.e. its address does not change through Update calls), you can simplify the call as follows:

StylesUpdater("...", SimpleStylesApplier(&yourmodel)).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL