Skip to content

Latest commit

 

History

History
264 lines (206 loc) · 8.59 KB

File metadata and controls

264 lines (206 loc) · 8.59 KB

Writing plugins

This page assumes you finished the tutorial, where a first plugin (the timer) is built step by step.

A plugin is a struct that users embed into their T:

type T struct {
    *testo.T
    *PluginShout
}

Testo collects the plugins from the T type, creates a fresh instance of each for every test and sub-test, and asks each one what it wants to do by calling its Plugin method:

func (p *PluginShout) Plugin(parent testoplugin.Plugin, options ...testoplugin.Option) testoplugin.Spec

The returned testoplugin.Spec has three parts, all optional:

Part What it does
Hooks run code around suites, tests and sub-tests
Overrides wrap built-in T methods (Log, Fail, ...)
Plan filter, reorder or duplicate the tests before the run

A plugin usually embeds *testo.T. Testo fills it with the same T as the current test, so the plugin can log, fail, or inspect the test it runs in. Pin the interface at compile time:

var _ testoplugin.Plugin = (*PluginShout)(nil)

The parent argument is the plugin instance of the enclosing scope: the suite root for a test, the test for a sub-test. Only at the suite root itself is it a typed-nil pointer. A non-nil interface can hold a nil pointer, so parent != nil would lie - assert and nil-check the pointer instead:

prev, _ := parent.(*PluginShout)
if prev != nil {
    // not the suite root: prev belongs to the enclosing test or suite
}

Snippets in the Hooks, Overrides and Planning sections live inside a Plugin method with a named return, like the tutorial's timer: Go creates spec empty, the snippets fill its fields.

Hooks

BeforeAll/AfterAll run once per suite, BeforeEach/AfterEach around each test, BeforeEachSub/AfterEachSub around each sub-test. The first four mirror the suite hook methods but are a separate mechanism - both run, side by side. The Sub pair exists only for plugins.

spec.Hooks.BeforeEach = testoplugin.Hook{
    Priority: testoplugin.TryFirst,
    Func: func() {
        p.Logf("starting %s", p.Name())
    },
}

Priority is an int that orders hooks across plugins: lower values run earlier, any value works, TryFirst and TryLast are the extremes, zero keeps declaration order. Logs from hooks point at the plugin's own file, which is expected.

AfterEach and AfterEachSub are deferred, so with parallel sub-tests they run before those sub-tests finish - same caveat as suite hooks.

Overrides

Overrides wrap built-in T methods. Testo hands your function the current implementation (next) and installs whatever you return in its place. When the test calls t.Log, your replacement runs and decides if and when to call next. With several plugins the replacements nest, like HTTP middleware.

spec.Overrides.Log = func(next testoplugin.FuncLog) testoplugin.FuncLog {
    return func(args ...any) {
        p.Helper() // keep the caller's file:line in the output
        next(append([]any{"[quiet]"}, args...)...)
    }
}

Call t.Helper() inside the wrapper, or every log line will point at the wrapper's own file and line.

Overridable methods: Log, Error, Fatal, Fail, FailNow, Failed, Skip, SkipNow, Skipped, Parallel, Cleanup, Context, Deadline, TempDir, Setenv, Chdir. Each method X has a matching testoplugin.FuncX signature type.

Methods call each other underneath - Error is Log + Fail, Fatal is Log + FailNow - so overriding Log also affects Error and Fatal. Overrides.Priority orders stacks across plugins: the lowest priority becomes the outermost wrapper, so its code runs first. For a real example, the xfail plugin overrides Fail and FailNow to turn expected failures into skips.

Planning tests

Plan.Prepare receives the collected tests before the run and may filter, reorder or duplicate them in place:

import "github.com/ozontech/testo/testoreflect"

spec.Plan.Prepare = func(suite testoreflect.SuiteInfo, tests *[]testoplugin.PlannedTest) {
    slices.Reverse(*tests)
}

Each PlannedTest exposes Annotations() - the options attached to that test with testo.For - and Info(), which returns a testoreflect.TestInfo interface:

name := t.Info().GetName()

GetName() returns the full run path without the testo! segment, e.g. Test/Suite/TestFoo#01 - match with strings.HasSuffix, not ==.

Parametrized tests arrive already expanded, one PlannedTest per case. The initial order is regular tests first (alphabetical), then parametrized cases. One display quirk: after reordering, go test reassigns the #01 suffixes by run order, while t.Name() keeps the original case index.

The rerun plugin uses Prepare to drop every test that passed in the previous run.

Adding methods to T

Any method on the plugin becomes a method on T through embedding. If methods are all your plugin does, you don't even need a Plugin method:

type PluginGreet struct{ *testo.T }

func (g *PluginGreet) Greet() { g.Log("hello") }
func (Suite) TestFoo(t T) {
    t.Greet()
}

Options

An option is a testoplugin.Option wrapping any value your plugin recognizes. Users pass options to testo.RunSuite, testo.Run or testo.Options - see how to use plugin options. The common pattern is an unexported function type:

type shoutOption func(*PluginShout)

func WithPrefix(prefix string) testoplugin.Option {
    return testoplugin.Option{
        Value: shoutOption(func(p *PluginShout) { p.prefix = prefix }),
    }
}

Set Propagate: true only when sub-tests need the option too; leave it unset otherwise.

Consume options at the top of the Plugin method. All user-supplied options come through the variadic argument (including per-test annotations); ignore values that are not yours:

func (p *PluginShout) Plugin(_ testoplugin.Plugin, options ...testoplugin.Option) (spec testoplugin.Spec) {
    for _, o := range options {
        if o, ok := o.Value.(shoutOption); ok {
            o(p)
        }
    }
    // ...
    return spec
}

Command line flags

Plugins register flags with the standard flag package at package level. The go test machinery parses them like any other test flag:

var flagShout = flag.Bool("shout.enabled", false, "print a banner before each test")
go test . -shout.enabled

Read the value inside Plugin or a hook, after flags are parsed. Prefix flag names with your plugin name to avoid collisions. Real examples: -rerun.failed in the rerun plugin, -allure.dir in testo-allure.

Flags only exist in packages that import the plugin, so go test ./... -shout.enabled fails in packages that don't. Pair each flag with an environment variable if your plugin must be configurable across a whole repo (Testo does this with TESTO_STRICT and TESTO_CACHE_DIR).

Reading test metadata

testo.Reflect works on any T - including the plugin itself, via its embedded *testo.T:

spec.Hooks.BeforeEach.Func = func() {
    info, ok := testo.Reflect(p).Test.(testoreflect.ParametrizedTestInfo)
    if ok {
        p.Logf("params: %v", info.Params)
    }
}

That snippet answers "which parameters did TestFoo#01 run with": the plugin logs them for every parametrized test. The Reflection struct also carries the suite info, failure kind and source, and panic details - which is how testo-allure fills its reports. See the testoreflect reference.

Talking to other plugins

Plugins reference each other by embedding; Testo reuses one instance per test across all references. See cross-plugin communication.

Plugins worth reading