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.SpecThe 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.
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 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.
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.
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()
}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
}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.enabledRead 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).
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.
Plugins reference each other by embedding; Testo reuses one instance per test across all references. See cross-plugin communication.
- examples/04_plugins - hooks, overrides and planning in one small file.
- examples/07_annotations - a
plugin that defines options and consumes annotations in
Plan. - testo-toppings - four small production plugins (rerun, xfail, parallel, async).
- testo-allure - a large
reporting plugin using every part of the
Spec.