In this tutorial we take a plain Go test and grow it, step by step, into a full Testo suite with a plugin, parametrization, hooks, fixtures and steps.
Each section changes only a small piece of code, and the file compiles and passes after every change. The complete final file is listed at the end.
Create a new project and add Testo to it:
mkdir testo-bakery
cd testo-bakery
go mod init testo-bakery
go get github.com/ozontech/testoWe are going to test a tiny bakery. Create main.go:
package main
// Pastry is something a bakery bakes.
type Pastry struct {
Name string
Tasty bool
Ingredients string
}
var recipes = map[string]string{
"honey cake": "honey, flour, eggs, sour cream",
"tiramisu": "mascarpone, coffee, ladyfingers",
}
// Bake bakes a pastry, if the bakery knows the recipe.
func Bake(name string) (Pastry, bool) {
ingredients, ok := recipes[name]
if !ok {
return Pastry{}, false
}
return Pastry{Name: name, Tasty: true, Ingredients: ingredients}, true
}
// Eat disposes of a pastry in the most pleasant way possible.
func Eat(Pastry) {}
// Oven turns the bakery oven on or off.
func Oven(on bool) error { return nil }
func main() {}We start without Testo. Create main_test.go:
package main
import "testing"
func TestBake(t *testing.T) {
t.Run("HoneyCake", func(t *testing.T) {
pastry, ok := Bake("honey cake")
if !ok {
t.Fatal("the bakery must know how to bake a honey cake")
}
// no reason to waste it after the test
t.Cleanup(func() { Eat(pastry) })
if !pastry.Tasty {
t.Error("honey cake must be tasty")
}
})
}go test . -v=== RUN TestBake
=== RUN TestBake/HoneyCake
--- PASS: TestBake (0.00s)
--- PASS: TestBake/HoneyCake (0.00s)
PASSSo far this is a plain Go test. Let's bring in Testo.
Replace the t.Run call with testo.RunTest:
package main
import (
"testing"
"github.com/ozontech/testo"
)
func TestBake(t *testing.T) {
testo.RunTest(t, func(t *testo.T) {
pastry, ok := Bake("honey cake")
if !ok {
t.Fatal("the bakery must know how to bake a honey cake")
}
// no reason to waste it after the test
t.Cleanup(func() { Eat(pastry) })
if !pastry.Tasty {
t.Error("honey cake must be tasty")
}
})
}The body of the test did not change - only the type of t.
This swap lets everything below (plugins, suites, parametrization)
attach to a plain test.
The HoneyCake name is gone, though: RunTest numbers its tests
instead. Suites (below) give tests proper names again.
testo.T wraps testing.T and keeps its interface, with one
exception: sub-tests are started with testo.Run instead of t.Run
(covered later). It also implements testing.TB,
so assertion libraries and mocks built for standard tests keep
working.
Run the test again:
=== RUN TestBake
=== RUN TestBake/#00
=== RUN TestBake/#00/testo!
=== RUN TestBake/#00/testo!/TestBake
--- PASS: TestBake (0.00s)
--- PASS: TestBake/#00 (0.00s)
--- PASS: TestBake/#00/testo! (0.00s)
--- PASS: TestBake/#00/testo!/TestBake (0.00s)
PASSTwo technical levels appeared in the names:
#00is the index of the test inside thisRunTestcall.testo!is a special test Testo inserts so hooks run correctly around parallel tests. It does not affect your tests, but it does affect-runpatterns - see how to run specific tests.
So far Testo has given us nothing new. Plugins are where it gets interesting.
A plugin is a struct that describes what it does through the
testoplugin.Plugin interface:
type Plugin interface {
Plugin(parent Plugin, options ...Option) Spec
}
type Spec struct {
// Plan tests for execution: filter, duplicate, reorder.
Plan Plan
// Hooks around suites, tests and sub-tests.
Hooks Hooks
// Middleware for built-in T methods,
// such as Fail, Log, Skip and others.
Overrides Overrides
}Let's write a plugin that measures how long each test takes.
In main_test.go, update the import block as shown below and add
the plugin after it:
import (
"testing"
"time"
"github.com/ozontech/testo"
"github.com/ozontech/testo/testoplugin"
)
type PluginTimer struct {
*testo.T
start time.Time
}
func (p *PluginTimer) Plugin(testoplugin.Plugin, ...testoplugin.Option) (spec testoplugin.Spec) {
spec.Hooks.BeforeEach.Func = func() {
p.start = time.Now()
}
spec.Hooks.AfterEach.Func = func() {
p.Logf("test %q took %s", p.Name(), time.Since(p.start))
}
return spec
}Two Go details in the signature above. The parameters are unnamed
because this plugin ignores them. And (spec testoplugin.Spec) is a
named return: Go creates spec as an empty value, the method fills
its fields and returns it.
The plugin embeds *testo.T. Testo fills it with the same T as
the current test, so the plugin sees everything the test sees.
The unused first argument of the Plugin method is the parent plugin
instance - see the
technical overview if you need it.
The Plugin method is called for each test and sub-test, and each
gets its own plugin instance. That is why writing to plugin fields
is safe.
To use the plugin, define your own T type that embeds *testo.T
together with the plugins you want:
type T struct {
*testo.T
*PluginTimer
}(Both fields contain a *testo.T, but the direct one is shallower,
so calls like t.Log stay unambiguous.)
And change the test function to take this T - no * this time,
since our T is a struct that already contains the pointers:
func TestBake(t *testing.T) {
testo.RunTest(t, func(t T) {
// ... body unchanged ...
})
}That is the whole installation. Testo looks at the T type a test
accepts, then collects and initializes the plugins listed in it:
=== RUN TestBake
=== RUN TestBake/#00
main_test.go:35: testo: plugins collected: 1: main.PluginTimer
=== RUN TestBake/#00/testo!
=== RUN TestBake/#00/testo!/TestBake
main_test.go:23: test "TestBake/#00/TestBake" took 98.125µs
--- PASS: TestBake (0.00s)
...
PASSYour timings will differ. The logged name has no testo! in it:
t.Name() returns the logical test name
(details).
Note
Plugins must be embedded as pointers. Pointers let plugins share state with each other by pointing to the same memory.
Plugins can do much more than hooks: reorder or filter the test plan,
wrap built-in methods like t.Log, add new methods to T, accept
options and command line
flags. The writing plugins guide covers the full
API with an example for each part.
Ready-made plugins: testo-allure for Allure reports, and testo-toppings - a collection of small utility plugins.
For instance, here is a complete plugin that makes every test parallel:
type PluginParallel struct{ *testo.T }
func (p *PluginParallel) Plugin(testoplugin.Plugin, ...testoplugin.Option) (spec testoplugin.Spec) {
spec.Hooks.BeforeEach.Func = func() {
p.Parallel()
}
return spec
}Don't add this one to your file, or it will change the outputs below. A more complete version is available as the parallel plugin in testo-toppings.
When tests share setup and plugins, it is convenient to group them
into a suite. A suite embeds testo.Suite[T], where T is the type
we defined above - i.e. the set of plugins every test in the suite
gets:
type Bakery struct{ testo.Suite[T] }Tests become methods of the suite. They follow the usual Go naming
rules (the Test prefix) and must accept the same T as specified in
testo.Suite[T]. Value and pointer receivers both work.
Replace the TestBake function with:
func (Bakery) TestBake(t T) {
pastry, ok := Bake("honey cake")
if !ok {
t.Fatal("the bakery must know how to bake a honey cake")
}
t.Cleanup(func() { Eat(pastry) })
if !pastry.Tasty {
t.Error("honey cake must be tasty")
}
}Go has no native notion of suites, so we launch the suite from one regular test function:
func Test(t *testing.T) {
testo.RunSuite(t, new(Bakery))
}=== RUN Test
=== RUN Test/Bakery
main_test.go:50: testo: plugins collected: 1: main.PluginTimer
=== RUN Test/Bakery/testo!
=== RUN Test/Bakery/testo!/TestBake
main_test.go:23: test "Test/Bakery/TestBake" took 66.583µs
--- PASS: Test (0.00s)
--- PASS: Test/Bakery (0.00s)
--- PASS: Test/Bakery/testo! (0.00s)
--- PASS: Test/Bakery/testo!/TestBake (0.00s)
PASSOur bakery bakes more than honey cake, and the test scenario is the same for every dessert. Instead of copying the test, we make the dessert a parameter.
A parametrized test takes a second argument after T: a struct whose
fields are the parameters. Replace TestBake with:
func (Bakery) TestBake(t T, p struct{ Dessert string }) {
pastry, ok := Bake(p.Dessert)
if !ok {
t.Fatalf("the bakery must know how to bake %s", p.Dessert)
}
t.Cleanup(func() { Eat(pastry) })
if !pastry.Tasty {
t.Errorf("%s must be tasty", p.Dessert)
}
}Parameter values are declared with CasesXxx methods, where Xxx
matches the field name:
func (Bakery) CasesDessert() []string {
return []string{"honey cake", "tiramisu"}
}=== RUN Test
=== RUN Test/Bakery
main_test.go:54: testo: plugins collected: 1: main.PluginTimer
=== RUN Test/Bakery/testo!
=== RUN Test/Bakery/testo!/TestBake
main_test.go:23: test "Test/Bakery/TestBake" took 108.791µs
=== RUN Test/Bakery/testo!/TestBake#01
main_test.go:23: test "Test/Bakery/TestBake#01" took 15.042µs
--- PASS: Test (0.00s)
--- PASS: Test/Bakery (0.00s)
--- PASS: Test/Bakery/testo! (0.00s)
--- PASS: Test/Bakery/testo!/TestBake (0.00s)
--- PASS: Test/Bakery/testo!/TestBake#01 (0.00s)
PASSThe test ran once per dessert. The name TestBake#01 does not say
which dessert it was, so log p.Dessert at the top of the test if
that matters to you. With several parameters, the test runs
with the Cartesian product
of all their values. For correlated values (classic table tests), use
a single struct-typed parameter - see
how to write parametrized tests.
If a test declares a parameter with no matching CasesXxx method, or
the types mismatch, Testo reports an informative error before running
any tests. The errors example
demonstrates these messages (that example fails on purpose).
One day the whole bakery stopped producing pastry. It turned out somebody had switched off the oven. The suite should manage the oven itself.
Suites can define these hooks as methods:
BeforeAll(T)- called once before all tests. ItsTrefers to the top-level test, i.e.Test/Bakery.BeforeEach(T)- called before each test, with that test'sT.AfterEach(T)- called after each test, beforet.Cleanupcallbacks, with that test'sT.AfterAll(T)- called once after all tests, including parallel ones. ItsTrefers to the top-level test.
We need the oven on once before all tests and off once after them:
func (Bakery) BeforeAll(t T) {
if err := Oven(true); err != nil {
t.Fatalf("failed to turn the oven on: %v", err)
}
}
func (Bakery) AfterAll(t T) {
if err := Oven(false); err != nil {
t.Errorf("failed to turn the oven off: %v", err)
}
}The run output looks the same as before: these hooks are silent.
Add a t.Log inside one if you want to see it fire.
If BeforeAll fails, the suite's tests do not run at all.
For hook behavior with parallel sub-tests and panics, see
how to write parallel tests
and the technical overview.
The test currently creates a pastry and remembers to clean it up. With more resources this gets repetitive, so we move the create-and-cleanup logic out of the test.
T is our own type, which means we can add methods to it. A method
that creates a resource and schedules its cleanup is a fixture:
// Bake is a fixture: it bakes a pastry and
// schedules the cleanup for the end of the test.
func (t T) Bake(name string) (Pastry, bool) {
pastry, ok := Bake(name)
if ok {
t.Cleanup(func() { Eat(pastry) })
}
return pastry, ok
}The Bake(name) inside the method is the package function from
main.go - methods don't shadow package names, so this is not
recursion. The test no longer deals with cleanup:
func (Bakery) TestBake(t T, p struct{ Dessert string }) {
pastry, ok := t.Bake(p.Dessert)
if !ok {
t.Fatalf("the bakery must know how to bake %s", p.Dessert)
}
if !pastry.Tasty {
t.Errorf("%s must be tasty", p.Dessert)
}
}A fixture in Testo is an ordinary method. There is nothing to register, and every test of the suite can use it.
Right now a failure tells us which test failed, but not which check inside it. We can structure the test into named steps using sub-tests.
Sub-tests are started with the testo.Run function (this is the one
place where Testo differs from t.Run). Replace TestBake with:
func (Bakery) TestBake(t T, p struct{ Dessert string }) {
pastry, ok := t.Bake(p.Dessert)
testo.Run(t, "check the bakery can bake it", func(t T) {
if !ok {
t.Fatalf("the bakery must know how to bake %s", p.Dessert)
}
})
testo.Run(t, "check it is tasty", func(t T) {
if !pastry.Tasty {
t.Errorf("%s must be tasty", p.Dessert)
}
})
}=== RUN Test/Bakery/testo!/TestBake
=== RUN Test/Bakery/testo!/TestBake/check_the_bakery_can_bake_it
=== RUN Test/Bakery/testo!/TestBake/check_it_is_tasty
...
--- PASS: Test/Bakery/testo!/TestBake (0.00s)
--- PASS: Test/Bakery/testo!/TestBake/check_the_bakery_can_bake_it (0.00s)
--- PASS: Test/Bakery/testo!/TestBake/check_it_is_tasty (0.00s)Warning
t.Fatal inside a sub-test stops only that sub-test, not the outer
test. That is standard go test behavior. If a failed step must stop the
whole test, check the sub-test result in the outer test, or use a
plugin that propagates the failure, such as allure.Step from
testo-allure.
Plugins get their own hooks around every sub-test:
BeforeEachSub and AfterEachSub.
Reporting plugins use sub-tests as steps - with
testo-allure installed,
each testo.Run above becomes a step in the Allure report.
Suites are optional. You have already seen testo.RunTest:
func TestFoo(t *testing.T) {
testo.RunTest(t, func(t T) {
t.Log("Hello from Testo!")
})
}And to run several tests from a single test function, there is the
testo.Test adapter for t.Run:
func TestFoo(t *testing.T) {
t.Run("FirstTest", testo.Test(func(t T) {
t.Log("1!")
}))
t.Run("SecondTest", testo.Test(func(t T) {
t.Log("2!")
}))
}Each testo.Test call gets its own technical levels, so the names
run deep - that is normal:
=== RUN TestFoo/FirstTest
=== RUN TestFoo/FirstTest/#00
=== RUN TestFoo/FirstTest/#00/testo!
=== RUN TestFoo/FirstTest/#00/testo!/FirstTest
...The complete main_test.go we built in this tutorial:
package main
import (
"testing"
"time"
"github.com/ozontech/testo"
"github.com/ozontech/testo/testoplugin"
)
type PluginTimer struct {
*testo.T
start time.Time
}
func (p *PluginTimer) Plugin(testoplugin.Plugin, ...testoplugin.Option) (spec testoplugin.Spec) {
spec.Hooks.BeforeEach.Func = func() {
p.start = time.Now()
}
spec.Hooks.AfterEach.Func = func() {
p.Logf("test %q took %s", p.Name(), time.Since(p.start))
}
return spec
}
type T struct {
*testo.T
*PluginTimer
}
// Bake is a fixture: it bakes a pastry and
// schedules the cleanup for the end of the test.
func (t T) Bake(name string) (Pastry, bool) {
pastry, ok := Bake(name)
if ok {
t.Cleanup(func() { Eat(pastry) })
}
return pastry, ok
}
type Bakery struct{ testo.Suite[T] }
func (Bakery) BeforeAll(t T) {
if err := Oven(true); err != nil {
t.Fatalf("failed to turn the oven on: %v", err)
}
}
func (Bakery) AfterAll(t T) {
if err := Oven(false); err != nil {
t.Errorf("failed to turn the oven off: %v", err)
}
}
func (Bakery) CasesDessert() []string {
return []string{"honey cake", "tiramisu"}
}
func (Bakery) TestBake(t T, p struct{ Dessert string }) {
pastry, ok := t.Bake(p.Dessert)
testo.Run(t, "check the bakery can bake it", func(t T) {
if !ok {
t.Fatalf("the bakery must know how to bake %s", p.Dessert)
}
})
testo.Run(t, "check it is tasty", func(t T) {
if !pastry.Tasty {
t.Errorf("%s must be tasty", p.Dessert)
}
})
}
func Test(t *testing.T) {
testo.RunSuite(t, new(Bakery))
}- Learn how to use various Testo features - filtering tests, plugin options, persistent cache, project structure and more.
- Browse the examples - each is a small runnable project with its expected output.
- Read the technical overview for the full lifecycle of a suite run.
- Migrating from testify or allure-go? See the migration guide.
- View the API documentation.