From 400e2db92837a7ea223896a89a00317ce86f8249 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 10:13:27 +0300 Subject: [PATCH 01/13] handle empty cases better with optional strict mode --- collector.go | 23 ++++++++++++++++------- flag.go | 2 ++ runner.go | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/collector.go b/collector.go index cf8d752..b0aa52a 100644 --- a/collector.go +++ b/collector.go @@ -3,7 +3,6 @@ package testo import ( "fmt" "maps" - "os" "reflect" "slices" "strings" @@ -164,9 +163,12 @@ 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( + tb testing.TB, s Suite, name func(string) string, ) []annotatedSuiteTest[Suite, T] { + tb.Helper() + tests := make([]annotatedSuiteTest[Suite, T], 0, len(st.Regular)) for _, r := range st.Regular { @@ -177,7 +179,7 @@ func (st suiteTests[Suite, T]) Collect( } for _, p := range st.Parametrized { - cases := p.Tests(s) + cases := p.Tests(tb, s) tests = append(tests, cases...) } @@ -332,7 +334,7 @@ func (tc *testsCollector[Suite, T]) Collect( } type suiteTestParametrized[Suite suite[T], T CommonT] struct { - Tests func(Suite) []annotatedSuiteTest[Suite, T] + Tests func(testing.TB, Suite) []annotatedSuiteTest[Suite, T] } func (tc *testsCollector[Suite, T]) newParametrizedTest( @@ -340,7 +342,9 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( cases map[string]suiteCase[Suite, T], ) suiteTestParametrized[Suite, T] { return suiteTestParametrized[Suite, T]{ - Tests: func(s Suite) []annotatedSuiteTest[Suite, T] { + Tests: func(tb testing.TB, s Suite) []annotatedSuiteTest[Suite, T] { + tb.Helper() + casesValues := make(map[string][]reflect.Value, len(cases)) for caseName, c := range cases { @@ -349,14 +353,19 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( if len(values) == 0 { structName := method.Type.In(0).String() - fmt.Fprintf( - os.Stderr, - "testo: warning: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s will not run\n", + msg := fmt.Sprintf( + "testo: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s will not run", structName, caseName, method.Name, ) + if *flagStrict { + tb.Fatal(msg) + } else { + tb.Log(msg) + } + return nil } diff --git a/flag.go b/flag.go index f8aa6b1..b6e4cf0 100644 --- a/flag.go +++ b/flag.go @@ -8,6 +8,8 @@ import ( _ "github.com/ozontech/testo/testocache" ) +var flagStrict = flag.Bool("testo.strict", false, "run tests in strict mode") + var flagMethod = flagRegexp{Regexp: regexp.MustCompile("")} func init() { diff --git a/runner.go b/runner.go index eff605f..a1f95f5 100644 --- a/runner.go +++ b/runner.go @@ -337,7 +337,7 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T allTests := r.applyPlan( t, suiteInfo, - tests.Collect(s, func(name string) string { + tests.Collect(t, s, func(name string) string { return r.testNamer.Name(r.caller, name) }), ) From 4eeb20f943b506eef01ade40b03baf3cf2f056c5 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 11:42:56 +0300 Subject: [PATCH 02/13] add testo.strict env var fallback --- docs/how-to.md | 20 ++++++++++++++++++++ flag.go | 8 +++++++- internal/parse/parse.go | 12 ++++++++++++ testocache/cache.go | 10 +++------- 4 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 internal/parse/parse.go diff --git a/docs/how-to.md b/docs/how-to.md index facc865..e1db9fd 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -40,6 +40,26 @@ TestFoo(name=Joe, age=60) TestFoo(name=Joe, age=6) ``` +If for at least one required parameter function `CasesXxx` +returns zero values Testo will log a warning with similar message: + +```txt +main_test.go:15: testo: (*main.Suite).CasesName provides zero values, (*main.Suite).TestFoo will not run +``` + +To turn this log into fatal error and do not proceed with further execution +pass flag `-testo.strict` to the `go test` command invocation: + +```bash +go test ./... -testo.strict +``` + +> [!TIP] +> You can also set `TESTO_STRICT` environment variable to `true` +> for the same effect. +> +> Flags have higher priority than environment variables. + ## How to write parallel tests You can use your regular `t.Parallel` method to mark a test as parallel. diff --git a/flag.go b/flag.go index b6e4cf0..e4edd24 100644 --- a/flag.go +++ b/flag.go @@ -2,13 +2,19 @@ package testo import ( "flag" + "os" "regexp" + "github.com/ozontech/testo/internal/parse" // so that cache flags are always available. _ "github.com/ozontech/testo/testocache" ) -var flagStrict = flag.Bool("testo.strict", false, "run tests in strict mode") +var flagStrict = flag.Bool( + "testo.strict", + parse.Bool(os.Getenv("TESTO_STRICT")), + "run tests in strict mode", +) var flagMethod = flagRegexp{Regexp: regexp.MustCompile("")} diff --git a/internal/parse/parse.go b/internal/parse/parse.go new file mode 100644 index 0000000..353770a --- /dev/null +++ b/internal/parse/parse.go @@ -0,0 +1,12 @@ +// Package parse provides parsing utilities. +package parse + +import "strconv" + +// Bool parses string as bool treating it as false +// in case of a failure. +func Bool(s string) bool { + b, _ := strconv.ParseBool(s) + + return b +} diff --git a/testocache/cache.go b/testocache/cache.go index 0c5ddf4..700d4d5 100644 --- a/testocache/cache.go +++ b/testocache/cache.go @@ -25,6 +25,8 @@ import ( "slices" "strconv" "sync" + + "github.com/ozontech/testo/internal/parse" ) var ( @@ -35,7 +37,7 @@ var ( ) flagDisable = flag.Bool( "cache.disable", - parseBool(os.Getenv("TESTO_CACHE_DISABLE")), + parse.Bool(os.Getenv("TESTO_CACHE_DISABLE")), "disable caching in testo", ) ) @@ -282,12 +284,6 @@ func cacheDir() (string, error) { return dir, nil } -func parseBool(s string) bool { - b, _ := strconv.ParseBool(s) - - return b -} - func validate(key string) error { if slices.Contains([]byte(key), 0) { return ErrInvalidKey From c7a5148894bc32e3ee0be73e024bc71521d6f2ae Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 12:05:54 +0300 Subject: [PATCH 03/13] move env variables into constants --- flag.go | 7 +++--- internal/env/env.go | 16 +++++++++++++ internal/env/env_test.go | 17 +++++++++++++ internal/parse/parse.go | 7 ++++-- internal/parse/parse_test.go | 46 ++++++++++++++++++++++++++++++++++++ testocache/cache.go | 5 ++-- 6 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 internal/env/env.go create mode 100644 internal/env/env_test.go create mode 100644 internal/parse/parse_test.go diff --git a/flag.go b/flag.go index e4edd24..ba1f25a 100644 --- a/flag.go +++ b/flag.go @@ -2,18 +2,19 @@ package testo import ( "flag" - "os" "regexp" + "github.com/ozontech/testo/internal/env" "github.com/ozontech/testo/internal/parse" + // so that cache flags are always available. _ "github.com/ozontech/testo/testocache" ) var flagStrict = flag.Bool( "testo.strict", - parse.Bool(os.Getenv("TESTO_STRICT")), - "run tests in strict mode", + parse.Bool(env.TestoStrict.Get()), + "turn warnings into errors", ) var flagMethod = flagRegexp{Regexp: regexp.MustCompile("")} diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..1a44349 --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,16 @@ +// Package env holds all environment variables used by Testo. +package env + +import "os" + +const ( + TestoStrict Env = "TESTO_STRICT" + TestoCacheDir Env = "TESTO_CACHE_DIR" + TestoCacheDisable Env = "TESTO_CACHE_DISABLE" +) + +type Env string + +func (e Env) Get() string { + return os.Getenv(string(e)) +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 0000000..14ba06b --- /dev/null +++ b/internal/env/env_test.go @@ -0,0 +1,17 @@ +package env + +import ( + "os" + "testing" +) + +func TestEnv(t *testing.T) { + const Var Env = "TEST_EXAMPLE" + const value = "lorem ipsum" + + t.Setenv(string(Var), value) + + if Var.Get() != os.Getenv(string(Var)) { + t.Fatal("Env.Get must be equal to os.Getenv") + } +} diff --git a/internal/parse/parse.go b/internal/parse/parse.go index 353770a..ff1ee50 100644 --- a/internal/parse/parse.go +++ b/internal/parse/parse.go @@ -1,12 +1,15 @@ // Package parse provides parsing utilities. package parse -import "strconv" +import ( + "strconv" + "strings" +) // Bool parses string as bool treating it as false // in case of a failure. func Bool(s string) bool { - b, _ := strconv.ParseBool(s) + b, _ := strconv.ParseBool(strings.TrimSpace(s)) return b } diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go new file mode 100644 index 0000000..ed70841 --- /dev/null +++ b/internal/parse/parse_test.go @@ -0,0 +1,46 @@ +package parse + +import "testing" + +func TestBool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + s string + want bool + }{ + { + name: "simple value", + s: "true", + want: true, + }, + { + name: "padded value", + s: " 1 ", + want: true, + }, + { + name: "empty value", + s: "", + want: false, + }, + { + name: "padded false value", + s: " f ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := Bool(tt.s) + + if tt.want != got { + t.Errorf("Bool() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/testocache/cache.go b/testocache/cache.go index 700d4d5..95cf154 100644 --- a/testocache/cache.go +++ b/testocache/cache.go @@ -26,18 +26,19 @@ import ( "strconv" "sync" + "github.com/ozontech/testo/internal/env" "github.com/ozontech/testo/internal/parse" ) var ( flagDir = flag.String( "cache.dir", - cmp.Or(os.Getenv("TESTO_CACHE_DIR"), ".testo_cache"), + cmp.Or(env.TestoCacheDir.Get(), ".testo_cache"), "directory where the testo cache is stored", ) flagDisable = flag.Bool( "cache.disable", - parse.Bool(os.Getenv("TESTO_CACHE_DISABLE")), + parse.Bool(env.TestoCacheDisable.Get()), "disable caching in testo", ) ) From 5e8a8baebc5c0819c69b4aee9ed00648bda8bd06 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 12:52:10 +0300 Subject: [PATCH 04/13] shorter error message --- collector.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collector.go b/collector.go index b0aa52a..4cb0264 100644 --- a/collector.go +++ b/collector.go @@ -354,7 +354,7 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( structName := method.Type.In(0).String() msg := fmt.Sprintf( - "testo: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s will not run", + "testo: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run", structName, caseName, method.Name, From 1d3d262b682bf45685ca2121126dee10a4285239 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 12:52:29 +0300 Subject: [PATCH 05/13] collect tests inside suite sub-test --- runner.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runner.go b/runner.go index a1f95f5..072ad5d 100644 --- a/runner.go +++ b/runner.go @@ -260,8 +260,6 @@ func (r *runner[Suite, T]) runSuite( options = append(getOptions(), options...) - tests := r.collectTests(testingT) - suiteInfo := testoreflect.SuiteInfo{ Parent: parentSuite, Name: r.suiteName, @@ -273,6 +271,8 @@ func (r *runner[Suite, T]) runSuite( return testingT.Run(r.suiteName, func(testingT *testing.T) { testingT.Helper() + tests := r.collectTests(testingT) + t := construct[T]( testingT, nil, From f3891e146bbd92b2f417d7f0847ee92efd6073d6 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 13:09:55 +0300 Subject: [PATCH 06/13] update examples output and linter --- .golangci.yaml | 4 ++-- examples/06_errors/output.golden | 10 ++++++++-- examples_test.go | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 3d215f4..98e2de0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,7 +2,6 @@ version: "2" formatters: enable: - - gci - gofumpt - goimports - golines @@ -15,6 +14,7 @@ formatters: - pattern: "interface{}" replacement: "any" gofumpt: + module-path: github.com/ozontech/testo extra-rules: true linters: @@ -58,7 +58,7 @@ linters: settings: funlen: - lines: 70 + lines: 80 gosec: excludes: - G304 diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index db0f609..12f1659 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -1,21 +1,27 @@ === RUN Test === RUN Test/missing_cases +=== RUN Test/missing_cases/MissingCases main_test.go:38: testo: wrong param signature for (*main.MissingCases).Test: missing (*main.MissingCases).CasesFoo() []int for param "Foo" === RUN Test/invalid_cases +=== RUN Test/invalid_cases/InvalidCases main_test.go:39: testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int === RUN Test/empty_cases === RUN Test/empty_cases/EmptyCases -testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test will not run + main_test.go:40: testo: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t +=== RUN Test/wrong_t/WrongT main_test.go:41: testo: wrong signature for (*main.WrongT).Test, must be: func (*main.WrongT).Test(main.T) or func (*main.WrongT).Test(main.T, struct{...}) --- FAIL: Test (0.00s) --- FAIL: Test/missing_cases (0.00s) + --- FAIL: Test/missing_cases/MissingCases (0.00s) --- FAIL: Test/invalid_cases (0.00s) + --- FAIL: Test/invalid_cases/InvalidCases (0.00s) --- PASS: Test/empty_cases (0.00s) --- PASS: Test/empty_cases/EmptyCases (0.00s) --- PASS: Test/empty_cases/EmptyCases/testo! (0.00s) --- FAIL: Test/wrong_t (0.00s) + --- FAIL: Test/wrong_t/WrongT (0.00s) FAIL -FAIL github.com/ozontech/testo/examples/06_errors 0.316s +FAIL github.com/ozontech/testo/examples/06_errors 0.207s FAIL diff --git a/examples_test.go b/examples_test.go index 8942d00..a31d540 100644 --- a/examples_test.go +++ b/examples_test.go @@ -71,7 +71,11 @@ func requireEqualLines(t *testing.T, a, b string, ignoreOrder bool) { } if !slices.Equal(aLines, bLines) { - t.Fatalf("lines not equal:\n\n%s\n\n%s", aLines, bLines) + t.Fatalf( + "lines not equal:\n\n%s\n\n%s", + strings.Join(aLines, "\n"), + strings.Join(bLines, "\n"), + ) } } From 9eabfa39d2df84252d38f6006f98e9e2c777553b Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 13:54:38 +0300 Subject: [PATCH 07/13] move warnings into helper func --- collector.go | 41 +++++++++++++++++++++----------- examples/06_errors/output.golden | 4 ++-- runner.go | 4 +--- t.go | 18 ++++++++++++++ 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/collector.go b/collector.go index 4cb0264..c4a4108 100644 --- a/collector.go +++ b/collector.go @@ -1,7 +1,6 @@ package testo import ( - "fmt" "maps" "reflect" "slices" @@ -149,6 +148,10 @@ type suiteTests[Suite suite[T], T CommonT] struct { Parametrized []suiteTestParametrized[Suite, T] } +func (st suiteTests[Suite, T]) isEmpty() bool { + return len(st.Regular) == 0 && len(st.Parametrized) == 0 +} + type annotatedSuiteTest[Suite suite[T], T CommonT] struct { suiteTest[Suite, T] @@ -219,22 +222,33 @@ 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, -) suiteTests[Suite, T] { +func (tc *testsCollector[Suite, T]) Collect(tb testing.TB) suiteTests[Suite, T] { tb.Helper() - cases := suiteCasesOf[Suite](tb) - suiteTyp := reflect.TypeFor[Suite]() + if suiteTyp == reflect.TypeFor[singleton[T]]() { + return suiteTests[Suite, T]{} + } + + cases := suiteCasesOf[Suite](tb) + var tests suiteTests[Suite, T] for i := range suiteTyp.NumMethod() { method := suiteTyp.Method(i) if !isTest(method.Name, "Test") { - continue + if !strings.HasPrefix(method.Name, "Test") { + continue + } + + // identical to native go test behavior + tb.Fatalf( + "testo: (%s).%s has malformed name: first letter after 'Test' must not be lowercase", + suiteTyp, + method.Name, + ) } raiseWrongSignatureError := func() { @@ -330,6 +344,10 @@ func (tc *testsCollector[Suite, T]) Collect( } } + if tests.isEmpty() { + warnf(tb, "suite %s has no tests", suiteTyp) + } + return tests } @@ -353,19 +371,14 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( if len(values) == 0 { structName := method.Type.In(0).String() - msg := fmt.Sprintf( + warnf( + tb, "testo: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run", structName, caseName, method.Name, ) - if *flagStrict { - tb.Fatal(msg) - } else { - tb.Log(msg) - } - return nil } diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index 12f1659..96a09d2 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -7,7 +7,7 @@ main_test.go:39: testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int === RUN Test/empty_cases === RUN Test/empty_cases/EmptyCases - main_test.go:40: testo: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run + main_test.go:40: testo: warning: testo: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t === RUN Test/wrong_t/WrongT @@ -23,5 +23,5 @@ --- FAIL: Test/wrong_t (0.00s) --- FAIL: Test/wrong_t/WrongT (0.00s) FAIL -FAIL github.com/ozontech/testo/examples/06_errors 0.207s +FAIL github.com/ozontech/testo/examples/06_errors 0.200s FAIL diff --git a/runner.go b/runner.go index 072ad5d..dd04c50 100644 --- a/runner.go +++ b/runner.go @@ -237,9 +237,7 @@ func newRunner[Suite suite[T], T CommonT](t common) runner[Suite, T] { } } -func (r *runner[Suite, T]) collectTests( - t TestingT, -) suiteTests[Suite, T] { +func (r *runner[Suite, T]) collectTests(t TestingT) suiteTests[Suite, T] { t.Helper() collector := testsCollector[Suite, T]{ diff --git a/t.go b/t.go index 3a2f62d..dabaf09 100644 --- a/t.go +++ b/t.go @@ -584,3 +584,21 @@ func (a *atomicInt[T]) Store(value T) { func (a *atomicInt[T]) CompareAndSwap(oldvalue, newvalue T) bool { return (*atomic.Int64)(a).CompareAndSwap(int64(oldvalue), int64(newvalue)) } + +func warnf(tb testing.TB, f string, args ...any) { + tb.Helper() + + warn(tb, fmt.Sprintf(f, args...)) +} + +func warn(tb testing.TB, args ...any) { + tb.Helper() + + const prefix = "testo: " + + if *flagStrict { + tb.Fatal(prefix + fmt.Sprint(args...)) + } + + tb.Log(prefix + "warning: " + fmt.Sprint(args...)) +} From db85eb05d89054b5848a0c2c340b279aca14cfa2 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 15:16:47 +0300 Subject: [PATCH 08/13] fix duplicate testo prefix --- collector.go | 2 +- examples/06_errors/output.golden | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/collector.go b/collector.go index c4a4108..5c9a316 100644 --- a/collector.go +++ b/collector.go @@ -373,7 +373,7 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( warnf( tb, - "testo: (%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run", + "(%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run", structName, caseName, method.Name, diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index 96a09d2..373c47f 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -7,7 +7,7 @@ main_test.go:39: testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int === RUN Test/empty_cases === RUN Test/empty_cases/EmptyCases - main_test.go:40: testo: warning: testo: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run + main_test.go:40: testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t === RUN Test/wrong_t/WrongT @@ -23,5 +23,5 @@ --- FAIL: Test/wrong_t (0.00s) --- FAIL: Test/wrong_t/WrongT (0.00s) FAIL -FAIL github.com/ozontech/testo/examples/06_errors 0.200s +FAIL github.com/ozontech/testo/examples/06_errors 0.217s FAIL From bb17989f50f92b3fc1fb8cdb45d4a31e95c95ecf Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 15:23:59 +0300 Subject: [PATCH 09/13] split wrong signature error into multiple lines --- collector.go | 2 +- examples/06_errors/output.golden | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/collector.go b/collector.go index 5c9a316..9fb080d 100644 --- a/collector.go +++ b/collector.go @@ -256,7 +256,7 @@ func (tc *testsCollector[Suite, T]) Collect(tb testing.TB) suiteTests[Suite, T] //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{...})", + "testo: wrong signature for (%[1]s).%[2]s, must be either:\nfunc (%[1]s).%[2]s(%[3]s)\nfunc (%[1]s).%[2]s(%[3]s, struct{...})", suiteTyp, method.Name, reflect.TypeFor[T](), diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index 373c47f..614c177 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -11,7 +11,9 @@ === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t === RUN Test/wrong_t/WrongT - main_test.go:41: testo: wrong signature for (*main.WrongT).Test, must be: func (*main.WrongT).Test(main.T) or func (*main.WrongT).Test(main.T, struct{...}) + main_test.go:41: testo: wrong signature for (*main.WrongT).Test, must be either: + func (*main.WrongT).Test(main.T) + func (*main.WrongT).Test(main.T, struct{...}) --- FAIL: Test (0.00s) --- FAIL: Test/missing_cases (0.00s) --- FAIL: Test/missing_cases/MissingCases (0.00s) @@ -23,5 +25,5 @@ --- FAIL: Test/wrong_t (0.00s) --- FAIL: Test/wrong_t/WrongT (0.00s) FAIL -FAIL github.com/ozontech/testo/examples/06_errors 0.217s +FAIL github.com/ozontech/testo/examples/06_errors 0.315s FAIL From 344d4d5e1d50c71cc827814fa6d86129b3eb2768 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 16:16:57 +0300 Subject: [PATCH 10/13] handle malformed method names --- CHANGELOG.md | 11 +++++++++++ collector.go | 22 +++++++++++++++++----- examples/06_errors/main_test.go | 20 +++++++++++++++++++- examples/06_errors/output.golden | 27 ++++++++++++++++++++++----- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 458e8b4..94cb10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 + +- Added strict mode which turns warnings into hard errors. Can be enabled with `-testo.strict` command line argument and `TESTO_STRICT` environment variable (lower priority). + +### Changed + +- Testo error messages are now more descriptive. +- Malformed method names (e.g. lowercase letter after `Test` prefix) raise fatal errors, similar to native `go test` runner. + ## [1.4.0] - 2026-06-07 ### Added diff --git a/collector.go b/collector.go index 9fb080d..b2e24fc 100644 --- a/collector.go +++ b/collector.go @@ -79,7 +79,16 @@ func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase const prefix = "Cases" if !isTest(method.Name, prefix) { - continue + if !strings.HasPrefix(method.Name, prefix) { + continue + } + + tb.Fatalf( + "testo: (%s).%s has malformed name: first letter after '%s' must not be lowercase", + reflect.TypeFor[Suite](), + method.Name, + prefix, + ) } name := strings.TrimPrefix(method.Name, prefix) @@ -93,7 +102,7 @@ func suiteCasesOf[Suite suite[T], T CommonT](tb testing.TB) map[string]suiteCase if !isValidIn || !isValidOut { tb.Fatalf( - "testo: wrong signature for %[1]s.%[2]s, must be: func (%[1]s) %[2]s() []...", + "testo: wrong signature for %[1]s.%[2]s, must be: func (%[1]s) %[2]s() []Type", reflect.TypeFor[Suite](), method.Name, tb, ) } @@ -238,16 +247,19 @@ func (tc *testsCollector[Suite, T]) Collect(tb testing.TB) suiteTests[Suite, T] for i := range suiteTyp.NumMethod() { method := suiteTyp.Method(i) - if !isTest(method.Name, "Test") { - if !strings.HasPrefix(method.Name, "Test") { + const prefix = "Test" + + if !isTest(method.Name, prefix) { + if !strings.HasPrefix(method.Name, prefix) { continue } // identical to native go test behavior tb.Fatalf( - "testo: (%s).%s has malformed name: first letter after 'Test' must not be lowercase", + "testo: (%s).%s has malformed name: first letter after '%s' must not be lowercase", suiteTyp, method.Name, + prefix, ) } diff --git a/examples/06_errors/main_test.go b/examples/06_errors/main_test.go index c79c311..fddd4aa 100644 --- a/examples/06_errors/main_test.go +++ b/examples/06_errors/main_test.go @@ -22,11 +22,23 @@ func (InvalidCases) CasesFoo() []string { return []string{"one", "two"} } // testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int func (InvalidCases) Test(t T, p struct{ Foo int }) {} +type MalformedCases struct{ testo.Suite[T] } + +// testo: (*main.MalformedCases).Casesfoo has malformed name: first letter after 'Cases' must not be lowercase +func (MalformedCases) Casesfoo() []string { return []string{"a", "b"} } + +func (MalformedCases) Test(t T, p struct{ Foo string }) {} + +type MalformedTest struct{ testo.Suite[T] } + +// testo: (*main.MalformedTest).Testimony has malformed name: first letter after 'Test' must not be lowercase +func (MalformedTest) Testimony(t T) {} + type EmptyCases struct{ testo.Suite[T] } func (EmptyCases) CasesFoo() []int { return nil } -// testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test will not run +// testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run func (EmptyCases) Test(t T, p struct{ Foo int }) {} type WrongT struct{ testo.Suite[T] } @@ -34,9 +46,15 @@ type WrongT struct{ testo.Suite[T] } // testo: wrong signature for (*main.WrongT).Test, must be: func (*main.WrongT).Test(main.T) or func (*main.WrongT).Test(main.T, struct{...}) func (WrongT) Test(t *testing.T) {} +// testo: warning: suite *main.MissingTests has no tests +type MissingTests struct{ testo.Suite[T] } + func Test(t *testing.T) { + t.Run("malformed cases", func(t *testing.T) { testo.RunSuite(t, new(MalformedCases)) }) + t.Run("malformed test", func(t *testing.T) { testo.RunSuite(t, new(MalformedTest)) }) t.Run("missing cases", func(t *testing.T) { testo.RunSuite(t, new(MissingCases)) }) t.Run("invalid cases", func(t *testing.T) { testo.RunSuite(t, new(InvalidCases)) }) t.Run("empty cases", func(t *testing.T) { testo.RunSuite(t, new(EmptyCases)) }) t.Run("wrong t", func(t *testing.T) { testo.RunSuite(t, new(WrongT)) }) + t.Run("missing tests", func(t *testing.T) { testo.RunSuite(t, new(MissingTests)) }) } diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index 614c177..e3afc51 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -1,20 +1,34 @@ === RUN Test +=== RUN Test/malformed_cases +=== RUN Test/malformed_cases/MalformedCases + main_test.go:53: testo: (*main.MalformedCases).Casesfoo has malformed name: first letter after 'Cases' must not be lowercase +=== RUN Test/malformed_test +=== RUN Test/malformed_test/MalformedTest + main_test.go:54: testo: (*main.MalformedTest).Testimony has malformed name: first letter after 'Test' must not be lowercase === RUN Test/missing_cases === RUN Test/missing_cases/MissingCases - main_test.go:38: testo: wrong param signature for (*main.MissingCases).Test: missing (*main.MissingCases).CasesFoo() []int for param "Foo" + main_test.go:55: testo: wrong param signature for (*main.MissingCases).Test: missing (*main.MissingCases).CasesFoo() []int for param "Foo" === RUN Test/invalid_cases === RUN Test/invalid_cases/InvalidCases - main_test.go:39: testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int + main_test.go:56: testo: wrong param signature for (*main.InvalidCases).Test: (*main.InvalidCases).CasesFoo provides string values, not assignable to param "Foo" of type int === RUN Test/empty_cases === RUN Test/empty_cases/EmptyCases - main_test.go:40: testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run + main_test.go:57: testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t === RUN Test/wrong_t/WrongT - main_test.go:41: testo: wrong signature for (*main.WrongT).Test, must be either: + main_test.go:58: testo: wrong signature for (*main.WrongT).Test, must be either: func (*main.WrongT).Test(main.T) func (*main.WrongT).Test(main.T, struct{...}) +=== RUN Test/missing_tests +=== RUN Test/missing_tests/MissingTests + main_test.go:59: testo: warning: suite *main.MissingTests has no tests +=== RUN Test/missing_tests/MissingTests/testo! --- FAIL: Test (0.00s) + --- FAIL: Test/malformed_cases (0.00s) + --- FAIL: Test/malformed_cases/MalformedCases (0.00s) + --- FAIL: Test/malformed_test (0.00s) + --- FAIL: Test/malformed_test/MalformedTest (0.00s) --- FAIL: Test/missing_cases (0.00s) --- FAIL: Test/missing_cases/MissingCases (0.00s) --- FAIL: Test/invalid_cases (0.00s) @@ -24,6 +38,9 @@ --- PASS: Test/empty_cases/EmptyCases/testo! (0.00s) --- FAIL: Test/wrong_t (0.00s) --- FAIL: Test/wrong_t/WrongT (0.00s) + --- PASS: Test/missing_tests (0.00s) + --- PASS: Test/missing_tests/MissingTests (0.00s) + --- PASS: Test/missing_tests/MissingTests/testo! (0.00s) FAIL -FAIL github.com/ozontech/testo/examples/06_errors 0.315s +FAIL github.com/ozontech/testo/examples/06_errors 0.334s FAIL From 4b26c627f34cd888eb79626d6a74868a40bac934 Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 16:18:46 +0300 Subject: [PATCH 11/13] update technical overview --- docs/technical-overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 4bac8ba..904164c 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -7,9 +7,9 @@ A technical overview of Testo. When you call `testo.RunSuite` the following happens: ```txt -Suite tests are collected and verified. - A root test named the same as a suite is run { + Suite tests are collected and verified. + Plugins are collected and initialized with ".Plugin(parent: nil, options)" method call, if implemented. Innermost plugins are initialized first. "BeforeAll" plugin hooks are called. From 08c4c34aa4781f4b20ded1da816aa8b56147c2fb Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 16:24:10 +0300 Subject: [PATCH 12/13] update warning log example --- docs/how-to.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to.md b/docs/how-to.md index e1db9fd..81a52c9 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -44,7 +44,7 @@ If for at least one required parameter function `CasesXxx` returns zero values Testo will log a warning with similar message: ```txt -main_test.go:15: testo: (*main.Suite).CasesName provides zero values, (*main.Suite).TestFoo will not run +main_test.go:15: testo: (*main.Suite).CasesName provides zero values, (*main.Suite).TestFoo won't run ``` To turn this log into fatal error and do not proceed with further execution From 73d92ab594076c97248f9d1f096ed5d42f13d59a Mon Sep 17 00:00:00 2001 From: metafates Date: Sun, 14 Jun 2026 21:57:21 +0300 Subject: [PATCH 13/13] update linter --- .golangci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.golangci.yaml b/.golangci.yaml index 98e2de0..cb16585 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -57,6 +57,8 @@ linters: - thelper settings: + cyclop: + max-complexity: 15 funlen: lines: 80 gosec: