From fc37d86a3a42861f76f7687da1cf25e06013c81d Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 11:17:19 +0300 Subject: [PATCH 01/23] add suiteless tests --- collector.go | 27 +++++++++-- examples/08_suiteless/Makefile | 4 ++ examples/08_suiteless/main_test.go | 27 +++++++++++ examples/08_suiteless/output.golden | 31 +++++++++++++ runner.go | 70 ++++++++++++++++++++++++++--- suite.go | 8 +++- 6 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 examples/08_suiteless/Makefile create mode 100644 examples/08_suiteless/main_test.go create mode 100644 examples/08_suiteless/output.golden diff --git a/collector.go b/collector.go index 8e198ad..2b455e1 100644 --- a/collector.go +++ b/collector.go @@ -191,17 +191,36 @@ func (tc *testsCollector[Suite, T]) testName(base string) string { //nolint:cyclop,funlen,gocognit // splitting it would make it even more complex func (tc *testsCollector[Suite, T]) Collect( tb testing.TB, + suite Suite, ) suiteTests[Suite, T] { tb.Helper() + // special case for [Test] and [RunTest]. + if s, ok := any(suite).(singleton[T]); ok { + return suiteTests[Suite, T]{ + Regular: []suiteTest[Suite, T]{ + { + Name: s.name, + Info: testoreflect.RegularTestInfo{ + Name: tc.testName(s.name), + RawBaseName: s.name, + Level: 1, + FuncPC: reflect.ValueOf(s.test).Pointer(), + }, + Run: func(_ Suite, t T) { s.test(t) }, + }, + }, + } + } + cases := suiteCasesOf[Suite](tb) - suite := reflect.TypeFor[Suite]() + suiteTyp := reflect.TypeFor[Suite]() var tests suiteTests[Suite, T] - for i := range suite.NumMethod() { - method := suite.Method(i) + for i := range suiteTyp.NumMethod() { + method := suiteTyp.Method(i) if !isTest(method.Name, "Test") { continue @@ -213,7 +232,7 @@ func (tc *testsCollector[Suite, T]) Collect( //nolint:lll // it's a long message tb.Fatalf( "testo: wrong signature for (%[1]s).%[2]s, must be: func (%[1]s).%[2]s(%[3]s) or func (%[1]s).%[2]s(%[3]s, struct{...})", - suite, + suiteTyp, method.Name, reflect.TypeFor[T](), ) diff --git a/examples/08_suiteless/Makefile b/examples/08_suiteless/Makefile new file mode 100644 index 0000000..cc8ee4c --- /dev/null +++ b/examples/08_suiteless/Makefile @@ -0,0 +1,4 @@ +MAKEFLAGS += --always-make + +test: + go test . -v -tags example -count=1 diff --git a/examples/08_suiteless/main_test.go b/examples/08_suiteless/main_test.go new file mode 100644 index 0000000..74b3d68 --- /dev/null +++ b/examples/08_suiteless/main_test.go @@ -0,0 +1,27 @@ +//go:build example + +package main + +import ( + "testing" + + "github.com/ozontech/testo" +) + +type T = *testo.T + +func TestSimple(t *testing.T) { + testo.RunTest(t, func(t T) { + t.Log("Hello from testo!") + }) +} + +func TestMultiple(t *testing.T) { + t.Run("First test", testo.Test(func(t T) { + t.Log("Hello from the first test!") + })) + + t.Run("Second test", testo.Test(func(t T) { + t.Log("Hello from the second test!") + })) +} diff --git a/examples/08_suiteless/output.golden b/examples/08_suiteless/output.golden new file mode 100644 index 0000000..e9ba851 --- /dev/null +++ b/examples/08_suiteless/output.golden @@ -0,0 +1,31 @@ +=== RUN TestSimple +=== RUN TestSimple/#00 +=== RUN TestSimple/#00/testo! +=== RUN TestSimple/#00/testo!/TestSimple + main_test.go:15: Hello from testo! +--- PASS: TestSimple (0.00s) + --- PASS: TestSimple/#00 (0.00s) + --- PASS: TestSimple/#00/testo! (0.00s) + --- PASS: TestSimple/#00/testo!/TestSimple (0.00s) +=== RUN TestMultiple +=== RUN TestMultiple/First_test +=== RUN TestMultiple/First_test/#00 +=== RUN TestMultiple/First_test/#00/testo! +=== RUN TestMultiple/First_test/#00/testo!/First_test + main_test.go:21: Hello from the first test! +=== RUN TestMultiple/Second_test +=== RUN TestMultiple/Second_test/#00 +=== RUN TestMultiple/Second_test/#00/testo! +=== RUN TestMultiple/Second_test/#00/testo!/Second_test + main_test.go:25: Hello from the second test! +--- PASS: TestMultiple (0.00s) + --- PASS: TestMultiple/First_test (0.00s) + --- PASS: TestMultiple/First_test/#00 (0.00s) + --- PASS: TestMultiple/First_test/#00/testo! (0.00s) + --- PASS: TestMultiple/First_test/#00/testo!/First_test (0.00s) + --- PASS: TestMultiple/Second_test (0.00s) + --- PASS: TestMultiple/Second_test/#00 (0.00s) + --- PASS: TestMultiple/Second_test/#00/testo! (0.00s) + --- PASS: TestMultiple/Second_test/#00/testo!/Second_test (0.00s) +PASS +ok github.com/ozontech/testo/examples/08_suiteless 0.384s diff --git a/runner.go b/runner.go index de46408..a2e6dde 100644 --- a/runner.go +++ b/runner.go @@ -2,6 +2,7 @@ package testo import ( "fmt" + "path" "reflect" "runtime/debug" "testing" @@ -20,7 +21,55 @@ import ( // cannot include (like exclamation mark), so that it won't collide with suite type name. const parallelWrapperTest = "testo!" -// RunSuite will run the tests under the given suite. +// Test constructs a new "test" ready to run as a native [testing] test. +// +// func Test(t *testing.T) { +// t.Run("My awesome test", testo.Test(func(t T) { +// // your test goes here +// })) +// } +// +// This is syntax-sugar for a more verbose [RunTest] API: +// +// func Test(t *testing.T) { +// t.Run("My awesome test", func(t *testing.T) { +// testo.RunTest(t, func(t T) { +// // your test goes here +// }) +// }) +// } +func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(t *testing.T) { + return func(t *testing.T) { + t.Helper() + + RunTest(t, f, options...) + } +} + +// RunTest runs a single test without a suite. +// +// Under the hood it constructs a special singleton suite with one test and calls [RunSuite]. +// +// It also accepts options for the plugins which can be used to configure those plugins. +// See [testoplugin.Option]. +// +// RunTest reports whether f succeeded. +func RunTest[T CommonT]( + testingT TestingT, + f func(t T), + options ...testoplugin.Option, +) bool { + testingT.Helper() + + s := singleton[T]{ + test: f, + name: path.Base(testingT.Name()), + } + + return RunSuite(testingT, s, options...) +} + +// RunSuite runs tests under a suite. // // Test is defined as a suite method in the form of "TestXXX" or "Test" // which accepts a single parameter of the same type as T passed to this function. @@ -28,7 +77,7 @@ const parallelWrapperTest = "testo!" // It also accepts options for the plugins which can be used to configure those plugins. // See [testoplugin.Option]. // -// RunSuite reports whether all suite tests succeeded. +// RunTest reports whether suite succeeded. func RunSuite[Suite suite[T], T CommonT]( testingT TestingT, suite Suite, @@ -109,13 +158,22 @@ type runner[Suite suite[T], T CommonT] struct { } func newRunner[Suite suite[T], T CommonT]() runner[Suite, T] { + name := reflectutil.NameOf[Suite]() + if name == reflectutil.NameOf[singleton[T]]() { + name = "" + } + return runner[Suite, T]{ - suiteName: reflectutil.NameOf[Suite](), + suiteName: name, testNamer: testnamer.New(), } } -func (r *runner[Suite, T]) collectTests(t TestingT, caller string) suiteTests[Suite, T] { +func (r *runner[Suite, T]) collectTests( + t TestingT, + caller string, + suite Suite, +) suiteTests[Suite, T] { t.Helper() collector := testsCollector[Suite, T]{ @@ -123,7 +181,7 @@ func (r *runner[Suite, T]) collectTests(t TestingT, caller string) suiteTests[Su TestNamer: r.testNamer, } - return collector.Collect(t) + return collector.Collect(t, suite) } func (r *runner[Suite, T]) runSuite( @@ -137,7 +195,7 @@ func (r *runner[Suite, T]) runSuite( caller := r.testNamer.Name(testingT.Name(), r.suiteName) - tests := r.collectTests(testingT, caller) + tests := r.collectTests(testingT, caller, suite) suiteInfo := testoreflect.SuiteInfo{ Name: r.suiteName, diff --git a/suite.go b/suite.go index 405f726..9dd06ea 100644 --- a/suite.go +++ b/suite.go @@ -1,5 +1,12 @@ package testo +type singleton[T CommonT] struct { + Suite[T] + + name string + test func(t T) +} + type suite[T CommonT] interface { // BeforeAll is called before all suite tests once. // T is shared with a top-level suite test. @@ -74,5 +81,4 @@ func (Suite[T]) AfterAll(t T) { t.unwrap().reflection.Suite.Hooks.MissedAfterAll = true } -//nolint:unused // sealed interface func (Suite[T]) private() {} From 4387f79253495e1f1e54bb1e0d66ff239cb3adb0 Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 11:31:21 +0300 Subject: [PATCH 02/23] fix docs --- runner.go | 2 +- suite.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/runner.go b/runner.go index a2e6dde..02e8120 100644 --- a/runner.go +++ b/runner.go @@ -77,7 +77,7 @@ func RunTest[T CommonT]( // It also accepts options for the plugins which can be used to configure those plugins. // See [testoplugin.Option]. // -// RunTest reports whether suite succeeded. +// RunSuite reports whether suite succeeded. func RunSuite[Suite suite[T], T CommonT]( testingT TestingT, suite Suite, diff --git a/suite.go b/suite.go index 9dd06ea..d317927 100644 --- a/suite.go +++ b/suite.go @@ -1,5 +1,8 @@ package testo +// singleton is a special (virtual) suite with a single test. +// +// Used by [RunTest] & [Test] functions to invoke a single suiteless test. type singleton[T CommonT] struct { Suite[T] From 988bb73ee3bdf0fbf0c077f3a349b2d04d87c467 Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 11:56:42 +0300 Subject: [PATCH 03/23] update README --- README.md | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0cb9087..1f50704 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ [![Code Coverage](https://github.com/ozontech/testo/raw/gh-pages/coverage.svg?raw=true)](https://ozontech.github.io/testo/coverage.html) [![Quality Assurance](https://github.com/ozontech/testo/actions/workflows/qa.yml/badge.svg)](https://github.com/ozontech/testo/actions/workflows/qa.yml) -Testo is a modular testing framework for Go built on top of `testing.T`. -It is focused on suite-based tests and has an extensive plugin system. +Testo is a modular testing framework for Go built on top of `testing.T` +with an extensive plugin system. > Testo (/tɛstɒ/) is a play on words "test" and "тесто", meaning "dough". > Just like you can cook anything from dough, you can test anything with Testo! @@ -46,7 +46,6 @@ go get github.com/ozontech/testo Your first test with Testo: ```go -// file: main_test.go package main import ( @@ -59,14 +58,10 @@ import ( // Here we use the base T without plugins. type T struct { *testo.T } -type Suite struct{ testo.Suite[T] } - -func (Suite) TestHelloWorld(t T) { - t.Log("hello from testo!") -} - func Test(t *testing.T) { - testo.RunSuite(t, new(Suite)) + testo.RunTest(t, func(t T) { + t.Log("Hello Testo!") + }) } ``` @@ -76,6 +71,22 @@ And run it with `go test` as usual: go test . ``` +Testo also supports suites: + +```go +type T struct { *testo.T } + +type MySuite struct { testo.Suite[T] } + +func (MySuite) TestHello(t T) { + t.Log("Hello from Testo Suite!") +} + +func Test(t *testing.T) { + testo.RunSuite(t, new(MySuite)) +} +``` + See also [VS Code extension for Testo](#vs-code-extension). ### Next steps From 79c54313f4a5dda7e39df5fb46e27da56f482a5b Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 14:34:17 +0300 Subject: [PATCH 04/23] update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab900e..1bdd797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Ability to run tests without suites. + ## [1.1.0] - 2026-05-20 ### Added From 0f06b6ae83bb96ada79899104dff9fdc9dac934f Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 18:36:29 +0300 Subject: [PATCH 05/23] improve docs --- runner.go | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/runner.go b/runner.go index 02e8120..c3b137b 100644 --- a/runner.go +++ b/runner.go @@ -21,7 +21,7 @@ import ( // cannot include (like exclamation mark), so that it won't collide with suite type name. const parallelWrapperTest = "testo!" -// Test constructs a new "test" ready to run as a native [testing] test. +// Test constructs a new test ready to run as a native [testing] test. // // func Test(t *testing.T) { // t.Run("My awesome test", testo.Test(func(t T) { @@ -29,7 +29,7 @@ const parallelWrapperTest = "testo!" // })) // } // -// This is syntax-sugar for a more verbose [RunTest] API: +// This is a syntax sugar for a more verbose [RunTest] API: // // func Test(t *testing.T) { // t.Run("My awesome test", func(t *testing.T) { @@ -48,11 +48,34 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(t *testing // RunTest runs a single test without a suite. // -// Under the hood it constructs a special singleton suite with one test and calls [RunSuite]. +// Under the hood it constructs a special singleton suite with one test, named +// as the parent test, and calls [RunSuite]. // // It also accepts options for the plugins which can be used to configure those plugins. // See [testoplugin.Option]. // +// For example: +// +// func TestFoo(t *testing.T) { +// testo.RunTest(t, func(t T) { +// t.Log("Hi") +// }) +// } +// +// In the example above plugins would see this test as a suite with a single TestFoo method. +// +// See also [Test] as a syntax sugar to run a named test: +// +// func TestFoo(t *testing.T) { +// t.Run("named-test", testo.Test(func(t T) { +// t.Log("Hi") +// })) +// } +// +// NOTE: running this function more than once inside the same test +// means rerunning the same test, not running several different tests. +// If you want to run several suiteless tests from a single test, use [Test]. +// // RunTest reports whether f succeeded. func RunTest[T CommonT]( testingT TestingT, From a086a775f9e196ab5ad3a281b29aacfb9669abb5 Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 20 May 2026 18:53:34 +0300 Subject: [PATCH 06/23] fix typos --- runner.go | 4 ++-- suite.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/runner.go b/runner.go index c3b137b..6891397 100644 --- a/runner.go +++ b/runner.go @@ -29,7 +29,7 @@ const parallelWrapperTest = "testo!" // })) // } // -// This is a syntax sugar for a more verbose [RunTest] API: +// This is syntactic sugar for a more verbose [RunTest] API: // // func Test(t *testing.T) { // t.Run("My awesome test", func(t *testing.T) { @@ -74,7 +74,7 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(t *testing // // NOTE: running this function more than once inside the same test // means rerunning the same test, not running several different tests. -// If you want to run several suiteless tests from a single test, use [Test]. +// If you want to run several suite-less tests from a single test, use [Test]. // // RunTest reports whether f succeeded. func RunTest[T CommonT]( diff --git a/suite.go b/suite.go index d317927..79f9567 100644 --- a/suite.go +++ b/suite.go @@ -2,7 +2,7 @@ package testo // singleton is a special (virtual) suite with a single test. // -// Used by [RunTest] & [Test] functions to invoke a single suiteless test. +// Used by [RunTest] & [Test] functions to invoke a single suite-less test. type singleton[T CommonT] struct { Suite[T] @@ -23,7 +23,7 @@ type suite[T CommonT] interface { // AfterEach is called after each suite test. // T is shared with an actual test. // - // WARN: this hook is defered to run at the end of the test. + // WARN: this hook is deferred to run at the end of the test. // If that test has sub-tests marked as parallel, // this hook will run BEFORE those sub-tests are finished. // From e697dde183605625750cae364a7dbf62502fbda1 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 21 May 2026 08:45:38 +0300 Subject: [PATCH 07/23] simplify running parallel standalone tests --- runner.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/runner.go b/runner.go index 6891397..e5d5a09 100644 --- a/runner.go +++ b/runner.go @@ -38,7 +38,7 @@ const parallelWrapperTest = "testo!" // }) // }) // } -func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(t *testing.T) { +func Test[T CommonT](f func(t T), options ...testoplugin.Option) TestFunc { return func(t *testing.T) { t.Helper() @@ -46,6 +46,18 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(t *testing } } +type TestFunc func(t *testing.T) + +func (f TestFunc) Parallel() TestFunc { + return func(t *testing.T) { + t.Helper() + + t.Parallel() + + f(t) + } +} + // RunTest runs a single test without a suite. // // Under the hood it constructs a special singleton suite with one test, named From 1a8615ce0eef72dfd98b91a3fcefdf7ce1a7c903 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 21 May 2026 08:45:44 +0300 Subject: [PATCH 08/23] update tutorial --- docs/tutorial.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.md b/docs/tutorial.md index d901881..c44b0f3 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -41,7 +41,8 @@ import ( type T = *testo.T ``` -Now we need a Suite. A Suite must "inherit" `testo.Suite[T]` by embedding it. +Now we need a Suite. Note, that it's possible to run tests without suites, more on that later. +A Suite must "inherit" `testo.Suite[T]` by embedding it. ```go type Suite struct{ testo.Suite[T] } @@ -324,3 +325,37 @@ func (*Suite) TestBoom(t T) { > > Pointers allow plugins to share their state with other plugins, > by pointing to the same memory location through pointers. + +## Running tests without suites + +It's possible: + +```go +type T struct{ + *testo.T + *ReverseTestsOrder + *OverrideLog + *AddNewMethods + *Timer +} + +func TestFoo(t *testing.T) { + testo.RunSuite(t, func(t T) { + t.Log("Hello from testo!") + }) +} +``` + +Or, if you need to run several tests from a single "real" test: + +```go +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!") + })) +} +``` From 078888042fcc14e59bcc5f059863bf38370b20f1 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 21 May 2026 23:22:41 +0300 Subject: [PATCH 09/23] update docs --- CHANGELOG.md | 1 + options.go | 2 +- t.go | 10 ++++++++++ testoreflect/reflect.go | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c0185..f9c758e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Ability to run tests without suites. + ## [1.2.0] - 2026-05-21 ### Added diff --git a/options.go b/options.go index 359a1c9..063dae4 100644 --- a/options.go +++ b/options.go @@ -16,7 +16,7 @@ var ( // Options appends given options to the global options. // -// Global options are prepended to each [RunSuite] call. +// Global options are prepended to each [RunSuite] & [RunTest] call. // // func init() { // testo.Options(myplugin.OutputDir("...")) diff --git a/t.go b/t.go index c6c44e3..6e15608 100644 --- a/t.go +++ b/t.go @@ -108,6 +108,16 @@ func (t *T) Context() context.Context { // other parallel tests. When a test is run multiple times due to use of // -test.count or -test.cpu, multiple instances of a single test never run in // parallel with each other. +// +// NOTE: top-level calls to this function in [Test] or [RunTest] are effectively no-op. +// +// func Test(t *testing.T) { +// t.Parallel() // call parallel there instead +// +// testo.RunTest(t, func(t T) { +// t.Parallel() // no-op +// }) +// } func (t *T) Parallel() { t.Helper() diff --git a/testoreflect/reflect.go b/testoreflect/reflect.go index b206955..0f4d9d7 100644 --- a/testoreflect/reflect.go +++ b/testoreflect/reflect.go @@ -205,6 +205,7 @@ type SuiteInfo struct { Parent *SuiteInfo // Name of this suite. + // For suite-less tests this field is an empty string. Name string // Caller is the full test name from From 5325fcc9d9751d538e35c804762870d914642ca0 Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 00:07:05 +0300 Subject: [PATCH 10/23] rename example --- examples/{08_suiteless => 09_suiteless}/Makefile | 0 examples/{08_suiteless => 09_suiteless}/main_test.go | 0 examples/{08_suiteless => 09_suiteless}/output.golden | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename examples/{08_suiteless => 09_suiteless}/Makefile (100%) rename examples/{08_suiteless => 09_suiteless}/main_test.go (100%) rename examples/{08_suiteless => 09_suiteless}/output.golden (95%) diff --git a/examples/08_suiteless/Makefile b/examples/09_suiteless/Makefile similarity index 100% rename from examples/08_suiteless/Makefile rename to examples/09_suiteless/Makefile diff --git a/examples/08_suiteless/main_test.go b/examples/09_suiteless/main_test.go similarity index 100% rename from examples/08_suiteless/main_test.go rename to examples/09_suiteless/main_test.go diff --git a/examples/08_suiteless/output.golden b/examples/09_suiteless/output.golden similarity index 95% rename from examples/08_suiteless/output.golden rename to examples/09_suiteless/output.golden index e9ba851..562b8a2 100644 --- a/examples/08_suiteless/output.golden +++ b/examples/09_suiteless/output.golden @@ -28,4 +28,4 @@ --- PASS: TestMultiple/Second_test/#00/testo! (0.00s) --- PASS: TestMultiple/Second_test/#00/testo!/Second_test (0.00s) PASS -ok github.com/ozontech/testo/examples/08_suiteless 0.384s +ok github.com/ozontech/testo/examples/09_suiteless 0.384s From 4555e68b1b236a170de72629ade088835b03056f Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 10:33:26 +0300 Subject: [PATCH 11/23] unexport TestFunc --- runner.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/runner.go b/runner.go index 666098c..60d0be1 100644 --- a/runner.go +++ b/runner.go @@ -38,7 +38,9 @@ const parallelWrapperTest = "testo!" // }) // }) // } -func Test[T CommonT](f func(t T), options ...testoplugin.Option) TestFunc { +// +//nolint:unexported-return // users should use it as func(*testing.T), might change later +func Test[T CommonT](f func(t T), options ...testoplugin.Option) testFunc[*testing.T] { return func(t *testing.T) { t.Helper() @@ -46,10 +48,23 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) TestFunc { } } -type TestFunc func(t *testing.T) +type testFunc[T common] func(t T) -func (f TestFunc) Parallel() TestFunc { - return func(t *testing.T) { +// Parallel wraps this test with call to Parallel. +// +// func Test(t *testing.T) { +// t.Run("first", testo.Test(func(t T) { +// t.Log("...") +// }).Parallel() +// +// t.Run("second", testo.Test(func(t T) { +// t.Log("...") +// }).Parallel() +// } +// +// NOTE: calling this function more than once will cause panic upon test execution. +func (f testFunc[T]) Parallel() testFunc[T] { + return func(t T) { t.Helper() t.Parallel() From 6128734c401ddd60928992acc72371cd0338a9b2 Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 10:34:13 +0300 Subject: [PATCH 12/23] update suiteless example --- examples/09_suiteless/main_test.go | 10 ++++++++++ examples/09_suiteless/output.golden | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/examples/09_suiteless/main_test.go b/examples/09_suiteless/main_test.go index 74b3d68..142d422 100644 --- a/examples/09_suiteless/main_test.go +++ b/examples/09_suiteless/main_test.go @@ -25,3 +25,13 @@ func TestMultiple(t *testing.T) { t.Log("Hello from the second test!") })) } + +func TestMultipleParallel(t *testing.T) { + t.Run("First test", testo.Test(func(t T) { + t.Log("Hello from the first test!") + }).Parallel()) + + t.Run("Second test", testo.Test(func(t T) { + t.Log("Hello from the second test!") + }).Parallel()) +} diff --git a/examples/09_suiteless/output.golden b/examples/09_suiteless/output.golden index 562b8a2..9ad54dd 100644 --- a/examples/09_suiteless/output.golden +++ b/examples/09_suiteless/output.golden @@ -27,5 +27,29 @@ --- PASS: TestMultiple/Second_test/#00 (0.00s) --- PASS: TestMultiple/Second_test/#00/testo! (0.00s) --- PASS: TestMultiple/Second_test/#00/testo!/Second_test (0.00s) +=== RUN TestMultipleParallel +=== RUN TestMultipleParallel/First_test +=== PAUSE TestMultipleParallel/First_test +=== RUN TestMultipleParallel/Second_test +=== PAUSE TestMultipleParallel/Second_test +=== CONT TestMultipleParallel/First_test +=== RUN TestMultipleParallel/First_test/#00 +=== RUN TestMultipleParallel/First_test/#00/testo! +=== RUN TestMultipleParallel/First_test/#00/testo!/First_test + main_test.go:31: Hello from the first test! +=== CONT TestMultipleParallel/Second_test +=== RUN TestMultipleParallel/Second_test/#00 +=== RUN TestMultipleParallel/Second_test/#00/testo! +=== RUN TestMultipleParallel/Second_test/#00/testo!/Second_test + main_test.go:35: Hello from the second test! +--- PASS: TestMultipleParallel (0.00s) + --- PASS: TestMultipleParallel/First_test (0.00s) + --- PASS: TestMultipleParallel/First_test/#00 (0.00s) + --- PASS: TestMultipleParallel/First_test/#00/testo! (0.00s) + --- PASS: TestMultipleParallel/First_test/#00/testo!/First_test (0.00s) + --- PASS: TestMultipleParallel/Second_test (0.00s) + --- PASS: TestMultipleParallel/Second_test/#00 (0.00s) + --- PASS: TestMultipleParallel/Second_test/#00/testo! (0.00s) + --- PASS: TestMultipleParallel/Second_test/#00/testo!/Second_test (0.00s) PASS -ok github.com/ozontech/testo/examples/09_suiteless 0.384s +ok github.com/ozontech/testo/examples/09_suiteless 0.665s From 27cf37d6120147b8450c861b9ec1e4226cc0297b Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 10:49:36 +0300 Subject: [PATCH 13/23] improve doc --- runner.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runner.go b/runner.go index 60d0be1..f3f2ddd 100644 --- a/runner.go +++ b/runner.go @@ -48,6 +48,11 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) testFunc[*testi } } +// it's only used with [testing.T] for now, but +// type param makes it more readable for the end user: +// +// Test(f, options) testFunc[*testing.T ] // vs +// Test(f, options) testFunc type testFunc[T common] func(t T) // Parallel wraps this test with call to Parallel. From 34e85ce9a9fc1ec6b26e3c60db1ffaa43eee529a Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 10:54:45 +0300 Subject: [PATCH 14/23] update tutorial --- docs/tutorial.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/tutorial.md b/docs/tutorial.md index c44b0f3..d145dd4 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -41,8 +41,10 @@ import ( type T = *testo.T ``` -Now we need a Suite. Note, that it's possible to run tests without suites, more on that later. -A Suite must "inherit" `testo.Suite[T]` by embedding it. +Now we need a Suite. A Suite must "inherit" `testo.Suite[T]` by embedding it. + +> [!NOTE] +> It's possible to run tests without suites, more on that in [later](#running-tests-without-suites). ```go type Suite struct{ testo.Suite[T] } From 36cbf617a836cd6f9705f198f1a2c4726e0b96a3 Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 21:16:10 +0300 Subject: [PATCH 15/23] better test naming --- collector.go | 37 +++++++++++++++++-------------------- runner.go | 36 +++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/collector.go b/collector.go index 2b455e1..484f716 100644 --- a/collector.go +++ b/collector.go @@ -160,7 +160,7 @@ type annotatedSuiteTest[Suite suite[T], T CommonT] struct { // // Suite instance is required here to get // parameter cases (CasesXXX funcs), not to invoke the actual tests. -func (st suiteTests[Suite, T]) Collect(s Suite) []annotatedSuiteTest[Suite, T] { +func (st suiteTests[Suite, T]) Collect(s Suite, name func(string) string) []annotatedSuiteTest[Suite, T] { tests := make([]annotatedSuiteTest[Suite, T], 0, len(st.Regular)) for _, r := range st.Regular { @@ -176,6 +176,22 @@ func (st suiteTests[Suite, T]) Collect(s Suite) []annotatedSuiteTest[Suite, T] { tests = append(tests, cases...) } + // special case for [Test] and [RunTest]. + if s, ok := any(s).(singleton[T]); ok { + tests = append(tests, annotatedSuiteTest[Suite, T]{ + suiteTest: suiteTest[Suite, T]{ + Name: s.name, + Info: testoreflect.RegularTestInfo{ + Name: name(s.name), + RawBaseName: s.name, + Level: 1, + FuncPC: reflect.ValueOf(s.test).Pointer(), + }, + Run: func(_ Suite, t T) { s.test(t) }, + }, + }) + } + return tests } @@ -191,28 +207,9 @@ func (tc *testsCollector[Suite, T]) testName(base string) string { //nolint:cyclop,funlen,gocognit // splitting it would make it even more complex func (tc *testsCollector[Suite, T]) Collect( tb testing.TB, - suite Suite, ) suiteTests[Suite, T] { tb.Helper() - // special case for [Test] and [RunTest]. - if s, ok := any(suite).(singleton[T]); ok { - return suiteTests[Suite, T]{ - Regular: []suiteTest[Suite, T]{ - { - Name: s.name, - Info: testoreflect.RegularTestInfo{ - Name: tc.testName(s.name), - RawBaseName: s.name, - Level: 1, - FuncPC: reflect.ValueOf(s.test).Pointer(), - }, - Run: func(_ Suite, t T) { s.test(t) }, - }, - }, - } - } - cases := suiteCasesOf[Suite](tb) suiteTyp := reflect.TypeFor[Suite]() diff --git a/runner.go b/runner.go index f3f2ddd..0cd94e3 100644 --- a/runner.go +++ b/runner.go @@ -140,7 +140,7 @@ func RunSuite[Suite suite[T], T CommonT]( ) bool { testingT.Helper() - r := newRunner[Suite]() + r := newRunner[Suite](testingT) return r.runSuite(testingT, suite, nil, options...) } @@ -159,7 +159,7 @@ func RunSubSuite[Suite suite[Sub], Parent, Sub CommonT]( ) bool { t.Helper() - r := newRunner[Suite]() + r := newRunner[Suite](t) return r.runSuite(t.unwrap().testingT, suite, &t.unwrap().reflection.Suite, options...) } @@ -228,35 +228,37 @@ func Run[T CommonT]( } type runner[Suite suite[T], T CommonT] struct { + caller string suiteName string testNamer *testnamer.Namer } -func newRunner[Suite suite[T], T CommonT]() runner[Suite, T] { - name := reflectutil.NameOf[Suite]() - if name == reflectutil.NameOf[singleton[T]]() { - name = "" +func newRunner[Suite suite[T], T CommonT](t common) runner[Suite, T] { + suiteName := reflectutil.NameOf[Suite]() + if suiteName == reflectutil.NameOf[singleton[T]]() { + suiteName = "" } + namer := testnamer.New() + return runner[Suite, T]{ - suiteName: name, - testNamer: testnamer.New(), + caller: namer.Name(t.Name(), suiteName), + suiteName: suiteName, + testNamer: namer, } } func (r *runner[Suite, T]) collectTests( t TestingT, - caller string, - suite Suite, ) suiteTests[Suite, T] { t.Helper() collector := testsCollector[Suite, T]{ - CallerName: caller, + CallerName: r.caller, TestNamer: r.testNamer, } - return collector.Collect(t, suite) + return collector.Collect(t) } func (r *runner[Suite, T]) runSuite( @@ -269,9 +271,7 @@ func (r *runner[Suite, T]) runSuite( options = append(getOptions(), options...) - caller := r.testNamer.Name(testingT.Name(), r.suiteName) - - tests := r.collectTests(testingT, caller, suite) + tests := r.collectTests(testingT) suiteInfo := testoreflect.SuiteInfo{ Parent: parentSuite, @@ -291,7 +291,7 @@ func (r *runner[Suite, T]) runSuite( t.testNamer = r.testNamer t.reflection.Suite = suiteInfo t.reflection.Test = testoreflect.RegularTestInfo{ - Name: caller, + Name: r.caller, RawBaseName: r.suiteName, } }, @@ -343,7 +343,9 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T allTests := r.applyPlan( t, suiteInfo, - tests.Collect(s), + tests.Collect(s, func(name string) string { + return r.testNamer.Name(r.caller, name) + }), ) t.unwrap().testingT.Run(parallelWrapperTest, func(testingT *testing.T) { From d9893a19dc90f28b66343a5d03bf2f319c05855b Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 22 May 2026 23:31:48 +0300 Subject: [PATCH 16/23] use RunTest options as test scoped, not suite scoped --- collector.go | 1 + runner.go | 7 ++++--- suite.go | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/collector.go b/collector.go index 484f716..d5f3ea6 100644 --- a/collector.go +++ b/collector.go @@ -189,6 +189,7 @@ func (st suiteTests[Suite, T]) Collect(s Suite, name func(string) string) []anno }, Run: func(_ Suite, t T) { s.test(t) }, }, + Options: s.options, }) } diff --git a/runner.go b/runner.go index 0cd94e3..ed28d6f 100644 --- a/runner.go +++ b/runner.go @@ -117,11 +117,12 @@ func RunTest[T CommonT]( testingT.Helper() s := singleton[T]{ - test: f, - name: path.Base(testingT.Name()), + test: f, + name: path.Base(testingT.Name()), + options: options, } - return RunSuite(testingT, s, options...) + return RunSuite(testingT, s) } // RunSuite runs tests under a suite. diff --git a/suite.go b/suite.go index 2f85a86..904a259 100644 --- a/suite.go +++ b/suite.go @@ -1,13 +1,16 @@ package testo +import "github.com/ozontech/testo/testoplugin" + // singleton is a special (virtual) suite with a single test. // // Used by [RunTest] & [Test] functions to invoke a single suite-less test. type singleton[T CommonT] struct { Suite[T] - name string - test func(t T) + name string + test func(t T) + options []testoplugin.Option } type suite[T CommonT] interface { From f866685c779d6fe04808e76e11fc45c86e19faa8 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 09:25:57 +0300 Subject: [PATCH 17/23] update docs --- doc.go | 62 +++++++++++++++++++++++++++++++++++++++++++++----- docs/how-to.md | 38 +++++++++++++++++++++++++++++++ runner.go | 21 +++++++++++++---- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/doc.go b/doc.go index 2f71552..5130436 100644 --- a/doc.go +++ b/doc.go @@ -13,14 +13,64 @@ // "github.com/ozontech/testo" // ) // -// type T struct { *testo.T } +// func Test(t *testing.T) { +// testo.RunTest(t, func(t *testo.T) { +// t.Log("Hello, Testo!") +// }) +// } +// +// # Plugins +// +// Plugins are the core feature of Testo. +// Plugins can generate reports, add custom methods to T, +// override built-in methods, plan test execution and more. +// +// Plugins are installed by defining our own T with embedded [T] and plugins: +// +// type T struct { +// *testo.T +// *myplugin.PluginFoo +// *myplugin.PluginBar +// } +// +// func Test(t *testing.T) { +// testo.RunTest(t, func(t T) { +// t.Log("Hello, Testo!") +// }) +// } +// +// Notice that we now use our T instead of *testo.T in a test. +// +// # Suites & Standalone Tests +// +// Testo supports several ways to run tests. +// +// Suites: +// // type Suite struct { testo.Suite[T] } // -// func (Suite) Test(t T) { t.Log("Hello, world!") } +// func (Suite) TestFoo(t T) { t.Log("Foo") } +// func (Suite) TestBar(t T) { t.Log("Bar") } +// +// func Test(t *testing.T) { +// testo.RunSuite(t, new(Suite)) +// } +// +// Standalone: +// +// func TestFoo(t *testing.T) { +// testo.RunTest(t, func(t T) { +// t.Log("Foo") +// }) +// } // -// func Test(t *testing.T) { testo.RunSuite(t, new(Suite)) } +// func Test(t *testing.T) { +// t.Run("Foo", testo.Test(func(t T) { +// t.Log("Foo") +// })) // -// Notice the `Test(t *testing.T)` - since [RunSuite] requires -// an instance of `testing.T` we must declare a regular go test first, -// and only inside it we will be able to actually call our Testo suite. +// t.Run("Bar", testo.Test(func(t T) { +// t.Log("Bar") +// })) +// } package testo diff --git a/docs/how-to.md b/docs/how-to.md index 3234e2c..facc865 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -2,6 +2,44 @@ Learn how to use the features of Testo. +## How to write parametrized tests + +Parametrized tests are defined as regular tests with a second argument: + +```go +func (*Suite) TestFoo(t *testo.T, p struct{ Name string; Age int }) { + t.Logf("Using name=%q and age=%d", p.Name, p.Age) +} +``` + +To define all possible parameter values create a special `CasesXxx` method in a suite: + +```go +func (*Suite) CasesName() []string { + return []string{"John", "Joe"} +} + +func (*Suite) CasesAge() []int { + return []int{18, 60, 6} +} +``` + +> [!TIP] +> `CasesXxx` are invoked *after* `BeforeAll` hook. + +Field names used in a `struct{ Name string; Age int}` must be equal to existing `CasesXxx` functions. + +Given that, test `TestFoo` will be invoked with all possible combinations of names and ages: + +```python +TestFoo(name=John, age=18) +TestFoo(name=John, age=60) +TestFoo(name=John, age=6) +TestFoo(name=Joe, age=18) +TestFoo(name=Joe, age=60) +TestFoo(name=Joe, age=6) +``` + ## How to write parallel tests You can use your regular `t.Parallel` method to mark a test as parallel. diff --git a/runner.go b/runner.go index ed28d6f..ca9f616 100644 --- a/runner.go +++ b/runner.go @@ -23,6 +23,8 @@ const parallelWrapperTest = "testo!" // Test constructs a new test ready to run as a native [testing] test. // +// # Examples +// // func Test(t *testing.T) { // t.Run("My awesome test", testo.Test(func(t T) { // // your test goes here @@ -39,6 +41,11 @@ const parallelWrapperTest = "testo!" // }) // } // +// # Options +// +// This function accepts plugin options, see [testoplugin.Option]. +// Passed options are treated as test scoped, not suite scoped. +// //nolint:unexported-return // users should use it as func(*testing.T), might change later func Test[T CommonT](f func(t T), options ...testoplugin.Option) testFunc[*testing.T] { return func(t *testing.T) { @@ -83,10 +90,7 @@ func (f testFunc[T]) Parallel() testFunc[T] { // Under the hood it constructs a special singleton suite with one test, named // as the parent test, and calls [RunSuite]. // -// It also accepts options for the plugins which can be used to configure those plugins. -// See [testoplugin.Option]. -// -// For example: +// # Examples // // func TestFoo(t *testing.T) { // testo.RunTest(t, func(t T) { @@ -104,7 +108,14 @@ func (f testFunc[T]) Parallel() testFunc[T] { // })) // } // -// NOTE: running this function more than once inside the same test +// # Options +// +// This function accepts plugin options, see [testoplugin.Option]. +// Passed options are treated as test scoped, not suite scoped. +// +// # Note +// +// Running this function more than once inside the same test // means rerunning the same test, not running several different tests. // If you want to run several suite-less tests from a single test, use [Test]. // From 935a4f8d887e77058100949d9f35d7e70111655f Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 09:27:20 +0300 Subject: [PATCH 18/23] make fmt --- collector.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/collector.go b/collector.go index d5f3ea6..5258b80 100644 --- a/collector.go +++ b/collector.go @@ -160,7 +160,10 @@ type annotatedSuiteTest[Suite suite[T], T CommonT] struct { // // Suite instance is required here to get // parameter cases (CasesXXX funcs), not to invoke the actual tests. -func (st suiteTests[Suite, T]) Collect(s Suite, name func(string) string) []annotatedSuiteTest[Suite, T] { +func (st suiteTests[Suite, T]) Collect( + s Suite, + name func(string) string, +) []annotatedSuiteTest[Suite, T] { tests := make([]annotatedSuiteTest[Suite, T], 0, len(st.Regular)) for _, r := range st.Regular { From f779b1a805a96ea0df08c464b6a6e2192a14f1e3 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 10:06:01 +0300 Subject: [PATCH 19/23] update README --- README.md | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1089d12..a644f5e 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,9 @@ import ( "github.com/ozontech/testo" ) -// A special construct that describes what plugins to use. -// Here we use the base T without plugins. -type T struct { *testo.T } - func Test(t *testing.T) { - testo.RunTest(t, func(t T) { - t.Log("Hello Testo!") + testo.RunTest(t, func(t *testo.T) { + t.Log("Hello, Testo!") }) } ``` @@ -71,21 +67,8 @@ And run it with `go test` as usual: go test . ``` -Testo also supports suites: - -```go -type T struct { *testo.T } - -type MySuite struct { testo.Suite[T] } - -func (MySuite) TestHello(t T) { - t.Log("Hello from Testo Suite!") -} - -func Test(t *testing.T) { - testo.RunSuite(t, new(MySuite)) -} -``` +But there is more! +Testo supports suites, parametrized tests & plugins, see [Next steps](#next-steps). See also [VS Code extension for Testo](#vs-code-extension). From db16c3054ea61570639f6bc0e618580b34984f0d Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 21:03:46 +0300 Subject: [PATCH 20/23] update examples --- collector.go | 3 +++ examples/{01_minimal => 01_suite}/Makefile | 0 examples/{01_minimal => 01_suite}/main_test.go | 0 examples/{01_minimal => 01_suite}/output.golden | 2 +- examples/{09_suiteless => 01_suiteless}/Makefile | 0 examples/{09_suiteless => 01_suiteless}/main_test.go | 6 +++--- examples/{09_suiteless => 01_suiteless}/output.golden | 8 ++++---- examples/README.md | 3 ++- 8 files changed, 13 insertions(+), 9 deletions(-) rename examples/{01_minimal => 01_suite}/Makefile (100%) rename examples/{01_minimal => 01_suite}/main_test.go (100%) rename examples/{01_minimal => 01_suite}/output.golden (81%) rename examples/{09_suiteless => 01_suiteless}/Makefile (100%) rename examples/{09_suiteless => 01_suiteless}/main_test.go (83%) rename examples/{09_suiteless => 01_suiteless}/output.golden (91%) diff --git a/collector.go b/collector.go index 5258b80..7014144 100644 --- a/collector.go +++ b/collector.go @@ -153,6 +153,7 @@ type suiteTests[Suite suite[T], T CommonT] struct { type annotatedSuiteTest[Suite suite[T], T CommonT] struct { suiteTest[Suite, T] + // Options to pass specifically for this test. Options []testoplugin.Option } @@ -180,6 +181,8 @@ func (st suiteTests[Suite, T]) Collect( } // special case for [Test] and [RunTest]. + // + // NOTE(metafates): future "special" suites should be handled here in a type switch. if s, ok := any(s).(singleton[T]); ok { tests = append(tests, annotatedSuiteTest[Suite, T]{ suiteTest: suiteTest[Suite, T]{ diff --git a/examples/01_minimal/Makefile b/examples/01_suite/Makefile similarity index 100% rename from examples/01_minimal/Makefile rename to examples/01_suite/Makefile diff --git a/examples/01_minimal/main_test.go b/examples/01_suite/main_test.go similarity index 100% rename from examples/01_minimal/main_test.go rename to examples/01_suite/main_test.go diff --git a/examples/01_minimal/output.golden b/examples/01_suite/output.golden similarity index 81% rename from examples/01_minimal/output.golden rename to examples/01_suite/output.golden index b8e755c..ca0f09f 100644 --- a/examples/01_minimal/output.golden +++ b/examples/01_suite/output.golden @@ -7,4 +7,4 @@ --- PASS: Test/Suite/testo! (0.00s) --- PASS: Test/Suite/testo!/TestMath (0.00s) PASS -ok github.com/ozontech/testo/examples/01_minimal 0.212s +ok github.com/ozontech/testo/examples/01_suite 0.212s diff --git a/examples/09_suiteless/Makefile b/examples/01_suiteless/Makefile similarity index 100% rename from examples/09_suiteless/Makefile rename to examples/01_suiteless/Makefile diff --git a/examples/09_suiteless/main_test.go b/examples/01_suiteless/main_test.go similarity index 83% rename from examples/09_suiteless/main_test.go rename to examples/01_suiteless/main_test.go index 142d422..412b099 100644 --- a/examples/09_suiteless/main_test.go +++ b/examples/01_suiteless/main_test.go @@ -12,17 +12,17 @@ type T = *testo.T func TestSimple(t *testing.T) { testo.RunTest(t, func(t T) { - t.Log("Hello from testo!") + t.Log(t.Name()) }) } func TestMultiple(t *testing.T) { t.Run("First test", testo.Test(func(t T) { - t.Log("Hello from the first test!") + t.Log(t.Name()) })) t.Run("Second test", testo.Test(func(t T) { - t.Log("Hello from the second test!") + t.Log(t.Name()) })) } diff --git a/examples/09_suiteless/output.golden b/examples/01_suiteless/output.golden similarity index 91% rename from examples/09_suiteless/output.golden rename to examples/01_suiteless/output.golden index 9ad54dd..50cd608 100644 --- a/examples/09_suiteless/output.golden +++ b/examples/01_suiteless/output.golden @@ -2,7 +2,7 @@ === RUN TestSimple/#00 === RUN TestSimple/#00/testo! === RUN TestSimple/#00/testo!/TestSimple - main_test.go:15: Hello from testo! + main_test.go:15: TestSimple/#00/TestSimple --- PASS: TestSimple (0.00s) --- PASS: TestSimple/#00 (0.00s) --- PASS: TestSimple/#00/testo! (0.00s) @@ -12,12 +12,12 @@ === RUN TestMultiple/First_test/#00 === RUN TestMultiple/First_test/#00/testo! === RUN TestMultiple/First_test/#00/testo!/First_test - main_test.go:21: Hello from the first test! + main_test.go:21: TestMultiple/First_test/#00/First_test === RUN TestMultiple/Second_test === RUN TestMultiple/Second_test/#00 === RUN TestMultiple/Second_test/#00/testo! === RUN TestMultiple/Second_test/#00/testo!/Second_test - main_test.go:25: Hello from the second test! + main_test.go:25: TestMultiple/Second_test/#00/Second_test --- PASS: TestMultiple (0.00s) --- PASS: TestMultiple/First_test (0.00s) --- PASS: TestMultiple/First_test/#00 (0.00s) @@ -52,4 +52,4 @@ --- PASS: TestMultipleParallel/Second_test/#00/testo! (0.00s) --- PASS: TestMultipleParallel/Second_test/#00/testo!/Second_test (0.00s) PASS -ok github.com/ozontech/testo/examples/09_suiteless 0.665s +ok github.com/ozontech/testo/examples/01_suiteless 0.209s diff --git a/examples/README.md b/examples/README.md index 871a965..8d0a055 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,7 +3,8 @@ Showcase of various Testo features. Examples are sorted by their simplicity in ascending order from basic to advanced. -You may want to start from the simplest example [01_minimal](./01_minimal/main_test.go). +You may want to start from the simplest +examples such as [01_suite](./01_suite/main_test.go) or [01_suiteless](./01_suiteless/main_test.go). To run each test execute the following command in the example directory: From c275e539685137e6ea8e42e931a5ed9d50667787 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 22:27:10 +0300 Subject: [PATCH 21/23] better parallelization in RunTest --- .golangci.yaml | 2 ++ collector.go | 6 ++++- examples/01_suiteless/main_test.go | 8 ++++-- examples/01_suiteless/output.golden | 18 ++++++++------ runner.go | 38 ++++------------------------- t.go | 20 ++++++++++++++- 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 6fae40c..3d215f4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -57,6 +57,8 @@ linters: - thelper settings: + funlen: + lines: 70 gosec: excludes: - G304 diff --git a/collector.go b/collector.go index 7014144..cf8d752 100644 --- a/collector.go +++ b/collector.go @@ -155,6 +155,8 @@ type annotatedSuiteTest[Suite suite[T], T CommonT] struct { // Options to pass specifically for this test. Options []testoplugin.Option + + Configure func(*testoT) } // Collect all suite tests. @@ -196,6 +198,9 @@ func (st suiteTests[Suite, T]) Collect( Run: func(_ Suite, t T) { s.test(t) }, }, Options: s.options, + Configure: func(tt *testoT) { + tt.propagateParallel = true + }, }) } @@ -330,7 +335,6 @@ type suiteTestParametrized[Suite suite[T], T CommonT] struct { Tests func(Suite) []annotatedSuiteTest[Suite, T] } -//nolint:funlen // no way to reduce length without losing readability func (tc *testsCollector[Suite, T]) newParametrizedTest( method reflect.Method, cases map[string]suiteCase[Suite, T], diff --git a/examples/01_suiteless/main_test.go b/examples/01_suiteless/main_test.go index 412b099..4cdabe6 100644 --- a/examples/01_suiteless/main_test.go +++ b/examples/01_suiteless/main_test.go @@ -28,10 +28,14 @@ func TestMultiple(t *testing.T) { func TestMultipleParallel(t *testing.T) { t.Run("First test", testo.Test(func(t T) { + t.Parallel() + t.Log("Hello from the first test!") - }).Parallel()) + })) t.Run("Second test", testo.Test(func(t T) { + t.Parallel() + t.Log("Hello from the second test!") - }).Parallel()) + })) } diff --git a/examples/01_suiteless/output.golden b/examples/01_suiteless/output.golden index 50cd608..466f423 100644 --- a/examples/01_suiteless/output.golden +++ b/examples/01_suiteless/output.golden @@ -29,19 +29,21 @@ --- PASS: TestMultiple/Second_test/#00/testo!/Second_test (0.00s) === RUN TestMultipleParallel === RUN TestMultipleParallel/First_test -=== PAUSE TestMultipleParallel/First_test -=== RUN TestMultipleParallel/Second_test -=== PAUSE TestMultipleParallel/Second_test -=== CONT TestMultipleParallel/First_test === RUN TestMultipleParallel/First_test/#00 === RUN TestMultipleParallel/First_test/#00/testo! === RUN TestMultipleParallel/First_test/#00/testo!/First_test - main_test.go:31: Hello from the first test! -=== CONT TestMultipleParallel/Second_test +=== PAUSE TestMultipleParallel/First_test +=== RUN TestMultipleParallel/Second_test === RUN TestMultipleParallel/Second_test/#00 === RUN TestMultipleParallel/Second_test/#00/testo! === RUN TestMultipleParallel/Second_test/#00/testo!/Second_test - main_test.go:35: Hello from the second test! +=== PAUSE TestMultipleParallel/Second_test +=== CONT TestMultipleParallel/First_test +=== NAME TestMultipleParallel/First_test/#00/testo!/First_test + main_test.go:33: Hello from the first test! +=== CONT TestMultipleParallel/Second_test +=== NAME TestMultipleParallel/Second_test/#00/testo!/Second_test + main_test.go:39: Hello from the second test! --- PASS: TestMultipleParallel (0.00s) --- PASS: TestMultipleParallel/First_test (0.00s) --- PASS: TestMultipleParallel/First_test/#00 (0.00s) @@ -52,4 +54,4 @@ --- PASS: TestMultipleParallel/Second_test/#00/testo! (0.00s) --- PASS: TestMultipleParallel/Second_test/#00/testo!/Second_test (0.00s) PASS -ok github.com/ozontech/testo/examples/01_suiteless 0.209s +ok github.com/ozontech/testo/examples/01_suiteless 0.318s diff --git a/runner.go b/runner.go index ca9f616..6eef2d6 100644 --- a/runner.go +++ b/runner.go @@ -45,9 +45,7 @@ const parallelWrapperTest = "testo!" // // This function accepts plugin options, see [testoplugin.Option]. // Passed options are treated as test scoped, not suite scoped. -// -//nolint:unexported-return // users should use it as func(*testing.T), might change later -func Test[T CommonT](f func(t T), options ...testoplugin.Option) testFunc[*testing.T] { +func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(*testing.T) { return func(t *testing.T) { t.Helper() @@ -55,36 +53,6 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) testFunc[*testi } } -// it's only used with [testing.T] for now, but -// type param makes it more readable for the end user: -// -// Test(f, options) testFunc[*testing.T ] // vs -// Test(f, options) testFunc -type testFunc[T common] func(t T) - -// Parallel wraps this test with call to Parallel. -// -// func Test(t *testing.T) { -// t.Run("first", testo.Test(func(t T) { -// t.Log("...") -// }).Parallel() -// -// t.Run("second", testo.Test(func(t T) { -// t.Log("...") -// }).Parallel() -// } -// -// NOTE: calling this function more than once will cause panic upon test execution. -func (f testFunc[T]) Parallel() testFunc[T] { - return func(t T) { - t.Helper() - - t.Parallel() - - f(t) - } -} - // RunTest runs a single test without a suite. // // Under the hood it constructs a special singleton suite with one test, named @@ -372,6 +340,10 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T t.testNamer = r.testNamer t.reflection.Suite = suiteInfo t.reflection.Test = test.Info + + if test.Configure != nil { + test.Configure(t) + } }, test.Options..., ) diff --git a/t.go b/t.go index 6e15608..80050d8 100644 --- a/t.go +++ b/t.go @@ -80,6 +80,12 @@ type ( failureKind atomicInt[testoreflect.TestFailureKind] hasFatalSubtest atomic.Bool + // propagateParallel if enabled, will route .Parallel() calls + // to suite's TestingT. + // + // Used in [RunTest] and [Test]. + propagateParallel bool + plugins map[reflect.Type]testoplugin.Plugin } @@ -121,7 +127,19 @@ func (t *T) Context() context.Context { func (t *T) Parallel() { t.Helper() - t.spec.Overrides.Parallel.Call(t.common.Parallel)() + t.spec.Overrides.Parallel.Call(t.parallel)() +} + +func (t *T) parallel() { + t.Helper() + + if t.propagateParallel { + t.reflection.Suite.TestingT.Parallel() + + return + } + + t.common.Parallel() } // Setenv calls os.Setenv(key, value) and uses Cleanup to From 84a444190fbd68751d6019c8b37c9980d8b7cc11 Mon Sep 17 00:00:00 2001 From: metafates Date: Sat, 23 May 2026 22:50:24 +0300 Subject: [PATCH 22/23] remove old comment --- t.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/t.go b/t.go index 80050d8..c83d0a6 100644 --- a/t.go +++ b/t.go @@ -114,16 +114,6 @@ func (t *T) Context() context.Context { // other parallel tests. When a test is run multiple times due to use of // -test.count or -test.cpu, multiple instances of a single test never run in // parallel with each other. -// -// NOTE: top-level calls to this function in [Test] or [RunTest] are effectively no-op. -// -// func Test(t *testing.T) { -// t.Parallel() // call parallel there instead -// -// testo.RunTest(t, func(t T) { -// t.Parallel() // no-op -// }) -// } func (t *T) Parallel() { t.Helper() From 941ec3576097b0086a1cf1f498558ca83befcebb Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 24 May 2026 10:24:22 +0300 Subject: [PATCH 23/23] update README --- README.md | 2 +- runner.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a644f5e..60e8d1d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Testo banner showing its chef gopher macost](./banner.svg) +[![Testo banner showing its chef gopher mascot on a blue background](./banner.svg)](https://github.com/ozontech/testo) # Testo diff --git a/runner.go b/runner.go index 6eef2d6..0b5cc7c 100644 --- a/runner.go +++ b/runner.go @@ -85,7 +85,7 @@ func Test[T CommonT](f func(t T), options ...testoplugin.Option) func(*testing.T // // Running this function more than once inside the same test // means rerunning the same test, not running several different tests. -// If you want to run several suite-less tests from a single test, use [Test]. +// If you want to run several suite-less tests from a single test see [Test]. // // RunTest reports whether f succeeded. func RunTest[T CommonT](