From 6832e93e4b7795fa1118f2ddc0fb5a51c932af2d Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 24 Jun 2026 11:48:10 +0300 Subject: [PATCH 01/14] better empty cases warning message changed "provides zero values" to simpler "returned empty slice", also changed "won't" to "will not" --- CHANGELOG.md | 6 ++++++ collector.go | 2 +- docs/how-to.md | 2 +- examples/06_errors/main_test.go | 2 +- examples/06_errors/output.golden | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb96a3..a7bd0d8 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 + +### Changed + +- Better wording for a warning then `CasesXxx` returnes empty slice. + ## [1.5.1] - 2026-06-17 ### Fixed diff --git a/collector.go b/collector.go index 48dce28..ae2688a 100644 --- a/collector.go +++ b/collector.go @@ -392,7 +392,7 @@ func (tc *testsCollector[Suite, T]) newParametrizedTest( warnf( tb, - "(%[1]s).Cases%[2]s provides zero values, (%[1]s).%[3]s won't run", + "(%[1]s).Cases%[2]s returned empty slice, (%[1]s).%[3]s will not run", structName, caseName, method.Name, diff --git a/docs/how-to.md b/docs/how-to.md index 81a52c9..3fc1f9d 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 won't run +main_test.go:15: testo: (*main.Suite).CasesName returned empty slice, (*main.Suite).TestFoo will not run ``` To turn this log into fatal error and do not proceed with further execution diff --git a/examples/06_errors/main_test.go b/examples/06_errors/main_test.go index 5f2ef30..35d15e4 100644 --- a/examples/06_errors/main_test.go +++ b/examples/06_errors/main_test.go @@ -38,7 +38,7 @@ type EmptyCases struct{ testo.Suite[T] } func (EmptyCases) CasesFoo() []int { return nil } -// testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run +// testo: warning: (*main.EmptyCases).CasesFoo returned empty slice, (*main.EmptyCases).Test will not run func (EmptyCases) Test(t T, p struct{ Foo int }) {} type WrongT struct{ testo.Suite[T] } diff --git a/examples/06_errors/output.golden b/examples/06_errors/output.golden index c76f382..e8fc325 100644 --- a/examples/06_errors/output.golden +++ b/examples/06_errors/output.golden @@ -13,7 +13,7 @@ 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:57: testo: warning: (*main.EmptyCases).CasesFoo provides zero values, (*main.EmptyCases).Test won't run + main_test.go:57: testo: warning: (*main.EmptyCases).CasesFoo returned empty slice, (*main.EmptyCases).Test will not run === RUN Test/empty_cases/EmptyCases/testo! === RUN Test/wrong_t === RUN Test/wrong_t/WrongT From 44fb1047461c4f4483dc9e239bf31f5af7af2775 Mon Sep 17 00:00:00 2001 From: metafates Date: Tue, 21 Jul 2026 23:37:06 +0300 Subject: [PATCH 02/14] apply bug fixes --- annotations.go | 12 ++++- construct.go | 111 ++++++++++++++++++++------------------------ construct_test.go | 14 +++++- plugin.go | 5 +- runner.go | 75 ++++++++++++++++++++---------- t.go | 45 +++++++++++++++++- testocache/cache.go | 38 +++++++++++++-- 7 files changed, 207 insertions(+), 93 deletions(-) diff --git a/annotations.go b/annotations.go index 10868b8..db3be47 100644 --- a/annotations.go +++ b/annotations.go @@ -86,5 +86,15 @@ func getID[Suite any](test reflect.Value) testID { name = strings.ReplaceAll(name, "(*"+suiteName+")", suiteName) - return testID(name) + // The runtime erases generic type arguments to "[...]" in function names, + // which would make all instantiations of a generic suite share annotations. + // Normalize the erased pointer-receiver form and scope the id by the + // concrete suite name (which keeps its type arguments). + if base, _, isGeneric := strings.Cut(suiteName, "["); isGeneric { + erased := base + "[...]" + + name = strings.ReplaceAll(name, "(*"+erased+")", erased) + } + + return testID(suiteName + "|" + name) } diff --git a/construct.go b/construct.go index 5714b45..7aacb2b 100644 --- a/construct.go +++ b/construct.go @@ -3,6 +3,7 @@ package testo import ( "fmt" "reflect" + "slices" "github.com/ozontech/testo/internal/reflectutil" "github.com/ozontech/testo/internal/stack" @@ -61,66 +62,48 @@ func construct[T CommonT]( )) } - //nolint:nestif // TODO: factor out common logic - if parent == nil { - pluginTypes := make(map[reflect.Type]struct{}) - - collectPlugins(realType, pluginTypes) - - delete(pluginTypes, realType) - - plugins := make(map[reflect.Type]testoplugin.Plugin, len(pluginTypes)) - - for pluginType := range pluginTypes { - var child testoplugin.Plugin - - if pluginType == reflect.TypeFor[*testoT]() { - child = &seed - } else { - v := reflect.New(pluginType.Elem()) - - reflectutil.Fill(v) - - child = v.Interface().(testoplugin.Plugin) - } - - plugins[pluginType] = child - } - - seed.plugins = plugins + if parent != nil { + seed.pluginOrder = (*parent).unwrap().pluginOrder } else { - parentUnwrapped := (*parent).unwrap() + seen := make(map[reflect.Type]struct{}) - plugins := make(map[reflect.Type]testoplugin.Plugin, len(parentUnwrapped.plugins)) + collectPlugins(realType, seen, &seed.pluginOrder) + + // the T type itself is not a plugin on its own. + seed.pluginOrder = slices.DeleteFunc(seed.pluginOrder, func(typ reflect.Type) bool { + return typ == realType + }) + } - for pluginType := range parentUnwrapped.plugins { - var child testoplugin.Plugin + plugins := make(map[reflect.Type]testoplugin.Plugin, len(seed.pluginOrder)) - if pluginType == reflect.TypeFor[*testoT]() { - child = &seed - } else { - v := reflect.New(pluginType.Elem()) + for _, pluginType := range seed.pluginOrder { + var child testoplugin.Plugin - reflectutil.Fill(v) + if pluginType == reflect.TypeFor[*testoT]() { + child = &seed + } else { + v := reflect.New(pluginType.Elem()) - child = v.Interface().(testoplugin.Plugin) - } + reflectutil.Fill(v) - plugins[pluginType] = child + child = v.Interface().(testoplugin.Plugin) } - seed.plugins = plugins + plugins[pluginType] = child } + seed.plugins = plugins + specsStack := stack.New[typedPlugin]() - for pluginType, pluginValue := range seed.plugins { + for _, pluginType := range seed.pluginOrder { specsStack.Push(typedPlugin{ - Plugin: pluginValue, + Plugin: seed.plugins[pluginType], Type: pluginType, }) - setPlugins(reflect.ValueOf(pluginValue), seed.plugins, &specsStack) + setPlugins(reflect.ValueOf(seed.plugins[pluginType]), seed.plugins, &specsStack) } setPlugins(value, seed.plugins, &specsStack) @@ -137,18 +120,28 @@ func construct[T CommonT]( continue } + // parentPlugin stays a nil interface for top-level tests, + // as documented in [testoplugin.Plugin]. var parentPlugin testoplugin.Plugin if parent != nil { parentPlugin = (*parent).unwrap().plugins[p.Type] - } else { - parentPlugin = reflect.New(p.Type).Elem().Interface().(testoplugin.Plugin) } specs[p.Type] = p.Plugin.Plugin(parentPlugin, seed.options()...) } - seed.spec = mergeSpecs(t, mapValues(specs)...) + // merge specs in the declaration order of plugins inside T so that + // equal-priority hooks and overrides run deterministically. + orderedSpecs := make([]testoplugin.Spec, 0, len(specs)) + + for _, pluginType := range seed.pluginOrder { + if spec, ok := specs[pluginType]; ok { + orderedSpecs = append(orderedSpecs, spec) + } + } + + seed.spec = mergeSpecs(t, orderedSpecs...) return value.Elem().Interface().(T) } @@ -205,7 +198,9 @@ func setPlugins( if field.Kind() != reflect.Pointer { panic( fmt.Sprintf( - "testo: all exported fields in T must be pointers, got: %s", + "testo: all exported fields in T and plugins must be pointers: field %s.%s has non-pointer type %s", + v.Type(), + v.Type().Field(i).Name, field.Type(), ), ) @@ -217,9 +212,15 @@ func setPlugins( var pluginInterfaceType = reflect.TypeFor[testoplugin.Plugin]() -func collectPlugins(typ reflect.Type, plugins map[reflect.Type]struct{}) { +// collectPlugins gathers plugin types in their declaration order: +// a depth-first walk over the exported fields of typ. +func collectPlugins(typ reflect.Type, seen map[reflect.Type]struct{}, order *[]reflect.Type) { if typ.Implements(pluginInterfaceType) { - plugins[typ] = struct{}{} + if _, ok := seen[typ]; !ok { + seen[typ] = struct{}{} + + *order = append(*order, typ) + } } typ = reflectutil.Elem(typ) @@ -232,17 +233,7 @@ func collectPlugins(typ reflect.Type, plugins map[reflect.Type]struct{}) { field := typ.Field(i) if field.IsExported() { - collectPlugins(field.Type, plugins) + collectPlugins(field.Type, seen, order) } } } - -func mapValues[K comparable, V any](m map[K]V) []V { - values := make([]V, 0, len(m)) - - for _, v := range m { - values = append(values, v) - } - - return values -} diff --git a/construct_test.go b/construct_test.go index 12fcc93..95f5408 100644 --- a/construct_test.go +++ b/construct_test.go @@ -38,7 +38,11 @@ func (m *MockPluginWithoutT) Plugin( parent testoplugin.Plugin, options ...testoplugin.Option, ) testoplugin.Spec { - m.parent = parent.(*MockPluginWithoutT) + // parent is nil for top-level tests, as documented in [testoplugin.Plugin]. + if parent != nil { + m.parent = parent.(*MockPluginWithoutT) + } + m.options = options m.initCalled++ @@ -122,6 +126,14 @@ func TestConstruct(t *testing.T) { } } + if res.MockPluginWithoutT.parent != nil { + t.Error("res.MockPluginWithoutT.parent is not nil for a top-level test") + } + + if child.MockPluginWithoutT.parent != res.MockPluginWithoutT { + t.Error("child.MockPluginWithoutT.parent does not point to the parent's plugin") + } + if !reflect.DeepEqual(res.T, child.T.parent) { t.Error("res.T not equal to child.T.parent") } diff --git a/plugin.go b/plugin.go index ba02540..74bf3f5 100644 --- a/plugin.go +++ b/plugin.go @@ -226,7 +226,10 @@ func mergeOverride[F any]( getter func(testoplugin.Overrides) testoplugin.Override[F], ) func(F) F { return func(f F) F { - for _, o := range overrides { + // wrap in reverse so that the override with the lowest priority + // becomes the outermost wrapper, i.e. is called first - + // as documented on [testoplugin.Overrides.Priority]. + for _, o := range slices.Backward(overrides) { if override := getter(o); override != nil { f = override(f) } diff --git a/runner.go b/runner.go index dd04c50..5535155 100644 --- a/runner.go +++ b/runner.go @@ -5,6 +5,7 @@ import ( "path" "reflect" "runtime/debug" + "strings" "testing" "github.com/ozontech/testo/internal/reflectutil" @@ -179,10 +180,17 @@ func Run[T CommonT]( parentSuite := parentT.unwrap().reflection.Load().Suite + // Derive the virtual name from the native one, which is + // deduplicated synchronously at Run-call time. Deduplicating + // independently here would let concurrent Run calls with equal + // names pair virtual names with the wrong native subtests. + nativeParent := parentT.unwrap().testingT.Name() + baseName := strings.TrimPrefix(testingT.Name(), nativeParent+"/") + t.reflection.Modify(func(r *testoreflect.Reflection) { r.Suite = parentSuite r.Test = testoreflect.RegularTestInfo{ - Name: parentT.unwrap().testNamer.Name(parentT.unwrap().Name(), name), + Name: parentT.unwrap().Name() + "/" + baseName, RawBaseName: name, Level: t.level(), IsSubtest: true, @@ -194,17 +202,8 @@ func Run[T CommonT]( ) defer func() { - if r := recover(); r != nil { - trace := string(debug.Stack()) - - t.unwrap().reflection.Modify(func(r *testoreflect.Reflection) { - r.Panic = &testoreflect.PanicInfo{ - Value: r, - Trace: trace, - } - }) - - t.Fatalf("testo: test %q panicked: %v\n\n%s", t.Name(), r, trace) + if rec := recover(); rec != nil { + recordPanic(t, rec) } }() @@ -212,7 +211,7 @@ func Run[T CommonT]( runHook(t, t.unwrap().spec.Hooks.BeforeEachSub) - f(t) + runProtected(t, func() { f(t) }) }) } @@ -383,17 +382,8 @@ func (r *runner[Suite, T]) runSuiteTest( t.Helper() defer func() { - if r := recover(); r != nil { - trace := string(debug.Stack()) - - t.unwrap().reflection.Modify(func(ref *testoreflect.Reflection) { - ref.Panic = &testoreflect.PanicInfo{ - Value: r, - Trace: trace, - } - }) - - t.Fatalf("testo: test %q panicked: %v\n\n%s", t.Name(), r, trace) + if rec := recover(); rec != nil { + recordPanic(t, rec) } }() @@ -405,7 +395,42 @@ func (r *runner[Suite, T]) runSuiteTest( s.BeforeEach(t) - test.Run(s, t) + runProtected(t, func() { test.Run(s, t) }) +} + +// runProtected runs f, converting a panic into a recorded test failure +// before deferred after-hooks run, so that they observe the failed state +// instead of executing mid-unwinding against a seemingly passing test. +func runProtected[T CommonT](t T, f func()) { + t.Helper() + + defer func() { + if rec := recover(); rec != nil { + recordPanic(t, rec) + } + }() + + f() +} + +// recordPanic stores rec as the test's panic information and fails the test. +// An already recorded panic is kept, so the original panic value survives +// even if an after-hook panics during unwinding. +func recordPanic[T CommonT](t T, rec any) { + t.Helper() + + trace := string(debug.Stack()) + + t.unwrap().reflection.Modify(func(ref *testoreflect.Reflection) { + if ref.Panic == nil { + ref.Panic = &testoreflect.PanicInfo{ + Value: rec, + Trace: trace, + } + } + }) + + t.Fatalf("testo: test %q panicked: %v\n\n%s", t.Name(), rec, trace) } func (r *runner[Suite, T]) applyPlan( diff --git a/t.go b/t.go index dabaf09..1266764 100644 --- a/t.go +++ b/t.go @@ -81,6 +81,12 @@ type ( failureKind atomicInt[testoreflect.TestFailureKind] hasFatalSubtest atomic.Bool + // isParallel and denyParallel mirror the testing package guards: + // Setenv and Chdir mutate process-wide state and therefore conflict + // with Parallel. See [T.checkParallel]. + isParallel atomic.Bool + denyParallel atomic.Bool + // propagateParallel if enabled, will route .Parallel() calls // to suite's TestingT. // @@ -88,6 +94,11 @@ type ( propagateParallel bool plugins map[reflect.Type]testoplugin.Plugin + + // pluginOrder holds plugin types in their declaration order inside + // the user T struct. Specs are merged in this order so that + // equal-priority hooks and overrides run deterministically. + pluginOrder []reflect.Type } testoT = T @@ -124,6 +135,12 @@ func (t *T) Parallel() { func (t *T) parallel() { t.Helper() + if t.denyParallel.Load() { + panic("testo: test using t.Setenv or t.Chdir can not use t.Parallel") + } + + t.isParallel.Store(true) + if t.propagateParallel { t.reflection.Load().Suite.TestingT.Parallel() @@ -133,6 +150,22 @@ func (t *T) parallel() { t.common.Parallel() } +// checkParallel mirrors testing.common.checkParallel: methods that mutate +// process-wide state (Setenv, Chdir) cannot be used in parallel tests or +// tests with parallel ancestors, and forbid a later t.Parallel call. +func (t *T) checkParallel(method string) { + for c := t; c != nil; c = c.parent { + if c.isParallel.Load() { + panic( + "testo: t." + method + " called after t.Parallel; " + + "it cannot be used in parallel tests or tests with parallel ancestors", + ) + } + } + + t.denyParallel.Store(true) +} + // Setenv calls os.Setenv(key, value) and uses Cleanup to // restore the environment variable to its original value // after the test. @@ -151,6 +184,8 @@ func (t *T) Setenv(key, value string) { func (t *T) setenv(key, value string) { t.Helper() + t.checkParallel("Setenv") + prevValue, ok := os.LookupEnv(key) if err := os.Setenv(key, value); err != nil { @@ -352,6 +387,8 @@ func (t *T) Chdir(dir string) { func (t *T) chdir(dir string) { t.Helper() + t.checkParallel("Chdir") + oldwd, err := os.Open(".") if err != nil { t.Fatal(err) @@ -443,7 +480,13 @@ func (t *T) markFailure(kind testoreflect.TestFailureKind) { return } - t.failureKind.Store(kind) + // never downgrade an already recorded fatal failure - e.g. t.Error + // called from a cleanup after t.Fatal must keep the fatal kind. + if !t.failureKind.CompareAndSwap(testoreflect.TestFailureKindNone, kind) && + kind == testoreflect.TestFailureKindFatal { + t.failureKind.Store(kind) + } + t.failureSource.Store(testoreflect.TestFailureSourceSelf) parent := t.parent diff --git a/testocache/cache.go b/testocache/cache.go index 63c21cb..99e2c89 100644 --- a/testocache/cache.go +++ b/testocache/cache.go @@ -312,7 +312,7 @@ func (c Cache) dir() (string, error) { } func readEntry(p, key string) ([]byte, error) { - info, err := os.Lstat(p) + linfo, err := os.Lstat(p) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound @@ -321,11 +321,11 @@ func readEntry(p, key string) ([]byte, error) { return nil, err } - if !info.Mode().IsRegular() { + if !linfo.Mode().IsRegular() { return nil, ErrNotFound } - value, err := os.ReadFile(p) + f, err := os.Open(p) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound @@ -333,6 +333,23 @@ func readEntry(p, key string) ([]byte, error) { return nil, err } + defer f.Close() + + // Stat the opened descriptor and require identity with the Lstat result, + // so a symlink swapped in between the two calls cannot be followed. + finfo, err := f.Stat() + if err != nil { + return nil, err + } + + if !finfo.Mode().IsRegular() || !os.SameFile(linfo, finfo) { + return nil, ErrNotFound + } + + value, err := io.ReadAll(f) + if err != nil { + return nil, err + } storedKey, value, ok := bytes.Cut(value, []byte{0}) if !ok || string(storedKey) != key { @@ -416,7 +433,20 @@ func writeFileAtomic(p string, data []byte) (err error) { return err } - return os.Rename(tmp.Name(), p) + err = os.Rename(tmp.Name(), p) + if err != nil { + return err + } + + // The rename is only durable across a crash once the parent directory is + // synced. Best-effort: syncing a directory is not supported everywhere + // (e.g. Windows), and the cache promises no durability. + if dir, dirErr := os.Open(filepath.Dir(p)); dirErr == nil { + _ = dir.Sync() + _ = dir.Close() + } + + return nil } func validateKey(key string) error { From b6502a7f90ca975dc7fde88c5bf099f9426f2b14 Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 22 Jul 2026 12:43:22 +0300 Subject: [PATCH 03/14] improve tests and rollback nil top-level plugin parent --- construct.go | 8 ++++++-- construct_test.go | 24 +++++++++++++++++++----- t_test.go | 7 ++++++- testocache/cache.go | 18 ++++-------------- testoplugin/plugin.go | 11 ++++++++++- 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/construct.go b/construct.go index 7aacb2b..261210f 100644 --- a/construct.go +++ b/construct.go @@ -120,12 +120,16 @@ func construct[T CommonT]( continue } - // parentPlugin stays a nil interface for top-level tests, - // as documented in [testoplugin.Plugin]. + // For top-level tests the parent is a typed-nil instance of the + // plugin's own type: the interface is non-nil, so unconditional + // parent.(*MyPlugin) assertions keep working, and the concrete + // pointer is nil. See [testoplugin.Plugin]. var parentPlugin testoplugin.Plugin if parent != nil { parentPlugin = (*parent).unwrap().plugins[p.Type] + } else { + parentPlugin = reflect.New(p.Type).Elem().Interface().(testoplugin.Plugin) } specs[p.Type] = p.Plugin.Plugin(parentPlugin, seed.options()...) diff --git a/construct_test.go b/construct_test.go index 95f5408..da4d894 100644 --- a/construct_test.go +++ b/construct_test.go @@ -29,16 +29,21 @@ func (m *MockPluginWithT) Plugin(testoplugin.Plugin, ...testoplugin.Option) test } type MockPluginWithoutT struct { - parent *MockPluginWithoutT - options []testoplugin.Option - initCalled int + parent *MockPluginWithoutT + parentNonNil bool + options []testoplugin.Option + initCalled int } func (m *MockPluginWithoutT) Plugin( parent testoplugin.Plugin, options ...testoplugin.Option, ) testoplugin.Spec { - // parent is nil for top-level tests, as documented in [testoplugin.Plugin]. + // For top-level tests parent is a typed-nil *MockPluginWithoutT inside a + // non-nil interface, as documented in [testoplugin.Plugin]. Recording the + // interface comparison separately from the asserted pointer lets + // TestConstruct distinguish typed-nil from a true nil interface. + m.parentNonNil = parent != nil if parent != nil { m.parent = parent.(*MockPluginWithoutT) } @@ -126,8 +131,17 @@ func TestConstruct(t *testing.T) { } } + // Top level: parent must be a typed-nil instance of the plugin type - + // a non-nil interface wrapping a nil pointer - so that plugins doing + // an unconditional parent.(*MyPlugin) assertion keep working. + if !res.MockPluginWithoutT.parentNonNil { + t.Error( + "res.MockPluginWithoutT: top-level parent interface is nil, want typed-nil instance", + ) + } + if res.MockPluginWithoutT.parent != nil { - t.Error("res.MockPluginWithoutT.parent is not nil for a top-level test") + t.Error("res.MockPluginWithoutT.parent is not a nil pointer for a top-level test") } if child.MockPluginWithoutT.parent != res.MockPluginWithoutT { diff --git a/t_test.go b/t_test.go index 7a46588..07a6371 100644 --- a/t_test.go +++ b/t_test.go @@ -90,7 +90,7 @@ type TSuiteT struct { type TSuitePlugin struct{} -var tSuiteOverridesCalls struct { +type tSuiteCallCounts struct { log, parallel, setenv, @@ -109,6 +109,8 @@ var tSuiteOverridesCalls struct { cleanup int } +var tSuiteOverridesCalls tSuiteCallCounts + func (p *TSuitePlugin) Plugin(testoplugin.Plugin, ...testoplugin.Option) testoplugin.Spec { return testoplugin.Spec{ Overrides: testoplugin.Overrides{ @@ -234,6 +236,9 @@ func (TSuite) Test(t TSuiteT) { } func TestSuiteT(t *testing.T) { + // reset the counters so the test stays correct under -count>1 + tSuiteOverridesCalls = tSuiteCallCounts{} + t.Parallel() if !RunSuite(t, new(TSuite)) { diff --git a/testocache/cache.go b/testocache/cache.go index 99e2c89..92ef5de 100644 --- a/testocache/cache.go +++ b/testocache/cache.go @@ -433,20 +433,10 @@ func writeFileAtomic(p string, data []byte) (err error) { return err } - err = os.Rename(tmp.Name(), p) - if err != nil { - return err - } - - // The rename is only durable across a crash once the parent directory is - // synced. Best-effort: syncing a directory is not supported everywhere - // (e.g. Windows), and the cache promises no durability. - if dir, dirErr := os.Open(filepath.Dir(p)); dirErr == nil { - _ = dir.Sync() - _ = dir.Close() - } - - return nil + // No fsync of the parent directory after the rename: it roughly doubles + // the cost of every Set (F_FULLFSYNC on darwin), and the cache promises + // no durability across a crash anyway. + return os.Rename(tmp.Name(), p) } func validateKey(key string) error { diff --git a/testoplugin/plugin.go b/testoplugin/plugin.go index e391ad8..c9b15b0 100644 --- a/testoplugin/plugin.go +++ b/testoplugin/plugin.go @@ -5,7 +5,16 @@ // Plugins can implement [Plugin] interface to be registered as such. // // Method "Plugin" will be called for each plugin before running a suite. -// Parent is nil for top-level tests. For sub-tests it referes to the plugin instance of the parent test. +// For sub-tests, parent refers to the plugin instance of the parent test. +// For top-level tests, parent is a typed-nil instance of the plugin's own +// type: the interface itself is non-nil, but the concrete pointer is nil, +// so an unconditional type assertion like parent.(*PluginFoo) is safe. +// To detect the top level, nil-check the asserted pointer, not the interface: +// +// prev, _ := parent.(*PluginFoo) +// if prev != nil { +// // sub-test: use prev +// } // // It is encouraged to ensure a plugin implements [Plugin] interface with the following line: // From e71e0b0189fd176d973d7d9e673b999d89693e8a Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 22 Jul 2026 18:24:09 +0300 Subject: [PATCH 04/14] rollback name fix --- runner.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/runner.go b/runner.go index 5535155..43ef0ac 100644 --- a/runner.go +++ b/runner.go @@ -5,7 +5,6 @@ import ( "path" "reflect" "runtime/debug" - "strings" "testing" "github.com/ozontech/testo/internal/reflectutil" @@ -180,17 +179,10 @@ func Run[T CommonT]( parentSuite := parentT.unwrap().reflection.Load().Suite - // Derive the virtual name from the native one, which is - // deduplicated synchronously at Run-call time. Deduplicating - // independently here would let concurrent Run calls with equal - // names pair virtual names with the wrong native subtests. - nativeParent := parentT.unwrap().testingT.Name() - baseName := strings.TrimPrefix(testingT.Name(), nativeParent+"/") - t.reflection.Modify(func(r *testoreflect.Reflection) { r.Suite = parentSuite r.Test = testoreflect.RegularTestInfo{ - Name: parentT.unwrap().Name() + "/" + baseName, + Name: parentT.unwrap().testNamer.Name(parentT.unwrap().Name(), name), RawBaseName: name, Level: t.level(), IsSubtest: true, From 9971d21756d73c51589a0ac650fc82d0dd1d674e Mon Sep 17 00:00:00 2001 From: metafates Date: Wed, 22 Jul 2026 18:29:24 +0300 Subject: [PATCH 05/14] set helper calls --- runner.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/runner.go b/runner.go index 43ef0ac..8d7f8a2 100644 --- a/runner.go +++ b/runner.go @@ -194,6 +194,8 @@ func Run[T CommonT]( ) defer func() { + t.Helper() + if rec := recover(); rec != nil { recordPanic(t, rec) } @@ -203,7 +205,11 @@ func Run[T CommonT]( runHook(t, t.unwrap().spec.Hooks.BeforeEachSub) - runProtected(t, func() { f(t) }) + runProtected(t, func() { + t.Helper() + + f(t) + }) }) } @@ -374,6 +380,8 @@ func (r *runner[Suite, T]) runSuiteTest( t.Helper() defer func() { + t.Helper() + if rec := recover(); rec != nil { recordPanic(t, rec) } @@ -387,16 +395,21 @@ func (r *runner[Suite, T]) runSuiteTest( s.BeforeEach(t) - runProtected(t, func() { test.Run(s, t) }) + runProtected(t, func() { + t.Helper() + + test.Run(s, t) + }) } // runProtected runs f, converting a panic into a recorded test failure -// before deferred after-hooks run, so that they observe the failed state -// instead of executing mid-unwinding against a seemingly passing test. +// before deferred after-hooks run, so that they observe the failed state. func runProtected[T CommonT](t T, f func()) { t.Helper() defer func() { + t.Helper() + if rec := recover(); rec != nil { recordPanic(t, rec) } From 7abc3b132bca9e7461b7694783dd25f48fae9aa1 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 00:17:27 +0300 Subject: [PATCH 06/14] improve comments --- annotations.go | 16 ++++++++++++---- plugin.go | 3 --- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/annotations.go b/annotations.go index db3be47..e2af428 100644 --- a/annotations.go +++ b/annotations.go @@ -86,10 +86,18 @@ func getID[Suite any](test reflect.Value) testID { name = strings.ReplaceAll(name, "(*"+suiteName+")", suiteName) - // The runtime erases generic type arguments to "[...]" in function names, - // which would make all instantiations of a generic suite share annotations. - // Normalize the erased pointer-receiver form and scope the id by the - // concrete suite name (which keeps its type arguments). + // Runtime erases generic type arguments to "[...]" in function names: + // + // KVSuite[int].TestGet -> "pkg.KVSuite[...].TestGet" + // KVSuite[string].TestGet -> "pkg.KVSuite[...].TestGet" (same!) + // + // while suiteName keeps them ("KVSuite[int]"). + // + // Code below gets us: + // + // KVSuite[int]|pkg.KVSuite[...].TestGet + // KVSuite[string]|pkg.KVSuite[...].TestGet + if base, _, isGeneric := strings.Cut(suiteName, "["); isGeneric { erased := base + "[...]" diff --git a/plugin.go b/plugin.go index 74bf3f5..b1cd1d2 100644 --- a/plugin.go +++ b/plugin.go @@ -226,9 +226,6 @@ func mergeOverride[F any]( getter func(testoplugin.Overrides) testoplugin.Override[F], ) func(F) F { return func(f F) F { - // wrap in reverse so that the override with the lowest priority - // becomes the outermost wrapper, i.e. is called first - - // as documented on [testoplugin.Overrides.Priority]. for _, o := range slices.Backward(overrides) { if override := getter(o); override != nil { f = override(f) From 379a7357b3c9d603316943c091e5b6bf140effbb Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 12:09:05 +0300 Subject: [PATCH 07/14] update CHANGELOG --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee43d2..21c0351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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 + +### Fixed + +- Panics in tests now record the actual recovered value, are observed by after-hooks as a failed test. +- `t.Setenv` and `t.Chdir` now enforce the standard library's conflict guards against `t.Parallel`. +- Plugin hooks and overrides with equal priority now run deterministically, in the declaration order of plugins. +- Overrides are now applied in the documented priority order, consistent with hooks. +- `t.Error` called after `t.Fatal` (e.g. from a cleanup) no longer downgrades the recorded fatal failure kind. +- Annotations no longer leak between instantiations of a generic suite. +- Persistent cache reads no longer follow a symlink swapped in after validation. +- The error for a non-pointer exported field now names the owning type and field. + ## [1.6.0] - 2026-07-17 ### Added From c1e3fbf533f917b7c3e2dd4a0c9ceb274133f01f Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 12:22:56 +0300 Subject: [PATCH 08/14] update technical overview --- docs/technical-overview.md | 5 +++-- testocache/cache.go | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 904164c..f83e9b9 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -10,7 +10,8 @@ When you call `testo.RunSuite` the following happens: 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. + Plugins are collected and initialized with ".Plugin(parent, options)" method call, if implemented. Innermost plugins are initialized first. + At this top level, parent is a typed-nil instance of the plugin's own type: the interface is non-nil, but the concrete pointer inside is nil. "BeforeAll" plugin hooks are called. "BeforeAll" suite hook is called. @@ -57,7 +58,7 @@ Panic in these hooks will result in suite tests not running. ## Plugins -Testo uses dependency-injection-like mechanism to enable cross-plugin commuincation. +Testo uses dependency-injection-like mechanism to enable cross-plugin communication. For example, assume we have plugin `X` and plugin `Y`. Plugin `X` needs to interact with plugin `Y`. To make it possible, `X` needs to embed `Y`: diff --git a/testocache/cache.go b/testocache/cache.go index 92ef5de..ba84226 100644 --- a/testocache/cache.go +++ b/testocache/cache.go @@ -433,9 +433,6 @@ func writeFileAtomic(p string, data []byte) (err error) { return err } - // No fsync of the parent directory after the rename: it roughly doubles - // the cost of every Set (F_FULLFSYNC on darwin), and the cache promises - // no durability across a crash anyway. return os.Rename(tmp.Name(), p) } From 28fe0a08d98d9fbd2ad33a95dfe491ee54dea216 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 12:29:09 +0300 Subject: [PATCH 09/14] add helper call to checkParallel --- t.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/t.go b/t.go index 1266764..24f7097 100644 --- a/t.go +++ b/t.go @@ -154,6 +154,8 @@ func (t *T) parallel() { // process-wide state (Setenv, Chdir) cannot be used in parallel tests or // tests with parallel ancestors, and forbid a later t.Parallel call. func (t *T) checkParallel(method string) { + t.Helper() + for c := t; c != nil; c = c.parent { if c.isParallel.Load() { panic( From 8e01f0d08fe1730060900563408487e110e7a21d Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 15:31:17 +0300 Subject: [PATCH 10/14] add plugin init order test --- construct_test.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/construct_test.go b/construct_test.go index da4d894..e6f44c5 100644 --- a/construct_test.go +++ b/construct_test.go @@ -193,6 +193,128 @@ func TestConstruct(t *testing.T) { }) } +// nestedPluginCalls records the order in which Plugin is called for the mock +// plugins below. Only TestConstructNestedPluginOrder and its plugins touch it. +var nestedPluginCalls []string + +type MockInnerPlugin struct{ initCalled int } + +func (m *MockInnerPlugin) Plugin(testoplugin.Plugin, ...testoplugin.Option) testoplugin.Spec { + m.initCalled++ + nestedPluginCalls = append(nestedPluginCalls, "Inner") + + return testoplugin.Spec{} +} + +type MockOuterPlugin struct { + Inner *MockInnerPlugin + + initCalled int +} + +func (m *MockOuterPlugin) Plugin(testoplugin.Plugin, ...testoplugin.Option) testoplugin.Spec { + m.initCalled++ + nestedPluginCalls = append(nestedPluginCalls, "Outer") + + return testoplugin.Spec{} +} + +type MockDeepPlugin struct{ initCalled int } + +func (m *MockDeepPlugin) Plugin(testoplugin.Plugin, ...testoplugin.Option) testoplugin.Spec { + m.initCalled++ + nestedPluginCalls = append(nestedPluginCalls, "Deep") + + return testoplugin.Spec{} +} + +type MockMidPlugin struct { + Deep *MockDeepPlugin + + initCalled int +} + +func (m *MockMidPlugin) Plugin(testoplugin.Plugin, ...testoplugin.Option) testoplugin.Spec { + m.initCalled++ + nestedPluginCalls = append(nestedPluginCalls, "Mid") + + return testoplugin.Spec{} +} + +type NestedMockT struct { + *T + + Outer *MockOuterPlugin + Mid *MockMidPlugin +} + +func TestConstructNestedPluginOrder(t *testing.T) { + t.Parallel() + + nestedPluginCalls = nil + + res := construct[NestedMockT](t, nil, nil) + + if res.Outer.Inner == nil { + t.Fatal("res.Outer.Inner is nil") + } + + if res.Mid.Deep == nil { + t.Fatal("res.Mid.Deep is nil") + } + + for _, counter := range []struct { + name string + value int + }{ + {"Outer", res.Outer.initCalled}, + {"Inner", res.Outer.Inner.initCalled}, + {"Mid", res.Mid.initCalled}, + {"Deep", res.Mid.Deep.initCalled}, + } { + if counter.value != 1 { + t.Errorf("%s: initCalled = %d, want 1", counter.name, counter.value) + } + } + + // Plugin must be called on a nested plugin before its container, so that + // the container observes fully initialized nested plugins. Sibling + // subtrees have no guaranteed relative order, so only pairwise + // nested-before-container positions are asserted. + for _, pair := range []struct{ nested, container string }{ + {"Inner", "Outer"}, + {"Deep", "Mid"}, + } { + nestedIdx := slices.Index(nestedPluginCalls, pair.nested) + containerIdx := slices.Index(nestedPluginCalls, pair.container) + + if nestedIdx < 0 || containerIdx < 0 { + t.Fatalf("missing Plugin calls in %v", nestedPluginCalls) + } + + if nestedIdx > containerIdx { + t.Errorf( + "Plugin called for container %s before nested %s: %v", + pair.container, pair.nested, nestedPluginCalls, + ) + } + } + + // pluginOrder drives spec merging and must stay in declaration order: + // containers before their nested plugins, siblings as declared in T. + wantOrder := []reflect.Type{ + reflect.TypeFor[*T](), + reflect.TypeFor[*MockOuterPlugin](), + reflect.TypeFor[*MockInnerPlugin](), + reflect.TypeFor[*MockMidPlugin](), + reflect.TypeFor[*MockDeepPlugin](), + } + + if !slices.Equal(wantOrder, res.unwrap().pluginOrder) { + t.Errorf("pluginOrder = %v, want %v", res.unwrap().pluginOrder, wantOrder) + } +} + func mustPanic(t *testing.T, f func()) { t.Helper() From d92399e7c9a3627c426d0e167f9b01e04cb3dd63 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 15:52:19 +0300 Subject: [PATCH 11/14] better panic protection with informative trace --- docs/technical-overview.md | 2 +- runner.go | 88 +++++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/docs/technical-overview.md b/docs/technical-overview.md index f83e9b9..836aff3 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -53,7 +53,7 @@ A root test named the same as a suite is run { Testo **will** catch panics from tests, including `BeforeEach`, `BeforeEachSub`, `AfterEachSub` & `AfterEach` hooks. Other tests will run even if some tests are panicking. -Testo **will not** catch panics from `BeforeAll` & `AfterAll`. +Testo **will** catch panics from `BeforeAll` & `AfterAll`. Panic in these hooks will result in suite tests not running. ## Plugins diff --git a/runner.go b/runner.go index 8d7f8a2..97a4562 100644 --- a/runner.go +++ b/runner.go @@ -196,16 +196,20 @@ func Run[T CommonT]( defer func() { t.Helper() - if rec := recover(); rec != nil { - recordPanic(t, rec) - } + runProtected(t, "plugin after each sub hook", func() { + t.Helper() + + runHook(t, t.unwrap().spec.Hooks.AfterEachSub) + }) }() - defer runHook(t, t.unwrap().spec.Hooks.AfterEachSub) + runProtected(t, "plugin before each sub hook", func() { + t.Helper() - runHook(t, t.unwrap().spec.Hooks.BeforeEachSub) + runHook(t, t.unwrap().spec.Hooks.BeforeEachSub) + }) - runProtected(t, func() { + runProtected(t, "sub-test", func() { t.Helper() f(t) @@ -303,20 +307,44 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T t.Helper() defer func() { - if !t.Skipped() { - runHook(t, t.unwrap().spec.Hooks.AfterAll) + t.Helper() + + if t.Skipped() { + return } + + runProtected(t, "plugin after all hook", func() { + t.Helper() + + runHook(t, t.unwrap().spec.Hooks.AfterAll) + }) }() - runHook(t, t.unwrap().spec.Hooks.BeforeAll) + runProtected(t, "plugin before all hook", func() { + t.Helper() + + runHook(t, t.unwrap().spec.Hooks.BeforeAll) + }) defer func() { - if !t.Skipped() { - s.AfterAll(t) + t.Helper() + + if t.Skipped() { + return } + + runProtected(t, "suite after all hook", func() { + t.Helper() + + s.AfterAll(t) + }) }() - s.BeforeAll(t) + runProtected(t, "suite before all hook", func() { + t.Helper() + + s.BeforeAll(t) + }) suiteReflection := t.unwrap().reflection.Load().Suite @@ -379,23 +407,31 @@ func (r *runner[Suite, T]) runSuiteTest( ) { t.Helper() - defer func() { + defer runProtected(t, "plugin after each hook", func() { t.Helper() - if rec := recover(); rec != nil { - recordPanic(t, rec) - } - }() + runHook(t, t.unwrap().spec.Hooks.AfterEach) + }) + + runProtected(t, "plugin before each hook", func() { + t.Helper() + + runHook(t, t.unwrap().spec.Hooks.BeforeEach) + }) - defer runHook(t, t.unwrap().spec.Hooks.AfterEach) + defer runProtected(t, "suite after each hook", func() { + t.Helper() - runHook(t, t.unwrap().spec.Hooks.BeforeEach) + s.AfterEach(t) + }) - defer s.AfterEach(t) + runProtected(t, "suite before each hook", func() { + t.Helper() - s.BeforeEach(t) + s.BeforeEach(t) + }) - runProtected(t, func() { + runProtected(t, "test", func() { t.Helper() test.Run(s, t) @@ -404,14 +440,14 @@ func (r *runner[Suite, T]) runSuiteTest( // runProtected runs f, converting a panic into a recorded test failure // before deferred after-hooks run, so that they observe the failed state. -func runProtected[T CommonT](t T, f func()) { +func runProtected[T CommonT](t T, kind string, f func()) { t.Helper() defer func() { t.Helper() if rec := recover(); rec != nil { - recordPanic(t, rec) + recordPanic(t, kind, rec) } }() @@ -421,7 +457,7 @@ func runProtected[T CommonT](t T, f func()) { // recordPanic stores rec as the test's panic information and fails the test. // An already recorded panic is kept, so the original panic value survives // even if an after-hook panics during unwinding. -func recordPanic[T CommonT](t T, rec any) { +func recordPanic[T CommonT](t T, kind string, rec any) { t.Helper() trace := string(debug.Stack()) @@ -435,7 +471,7 @@ func recordPanic[T CommonT](t T, rec any) { } }) - t.Fatalf("testo: test %q panicked: %v\n\n%s", t.Name(), rec, trace) + t.Fatalf("testo: %s in %q panicked: %v\n\n%s", kind, t.Name(), rec, trace) } func (r *runner[Suite, T]) applyPlan( From 13999a6396e9ecb1ba8d7b697b6fa7b272c7b3a1 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 16:53:38 +0300 Subject: [PATCH 12/14] simplify protected hook run --- .golangci.yaml | 2 +- runner.go | 58 +++++++++++++++++--------------------------------- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index cb16585..143e526 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -60,7 +60,7 @@ linters: cyclop: max-complexity: 15 funlen: - lines: 80 + lines: 90 gosec: excludes: - G304 diff --git a/runner.go b/runner.go index 97a4562..cf824b3 100644 --- a/runner.go +++ b/runner.go @@ -193,21 +193,9 @@ func Run[T CommonT]( options..., ) - defer func() { - t.Helper() - - runProtected(t, "plugin after each sub hook", func() { - t.Helper() - - runHook(t, t.unwrap().spec.Hooks.AfterEachSub) - }) - }() + defer runProtectedHook(t, "plugin AfterEachSub hook", t.unwrap().spec.Hooks.AfterEachSub) - runProtected(t, "plugin before each sub hook", func() { - t.Helper() - - runHook(t, t.unwrap().spec.Hooks.BeforeEachSub) - }) + runProtectedHook(t, "plugin BeforeEachSub hook", t.unwrap().spec.Hooks.BeforeEachSub) runProtected(t, "sub-test", func() { t.Helper() @@ -313,18 +301,10 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T return } - runProtected(t, "plugin after all hook", func() { - t.Helper() - - runHook(t, t.unwrap().spec.Hooks.AfterAll) - }) + runProtectedHook(t, "plugin AfterAll hook", t.unwrap().spec.Hooks.AfterAll) }() - runProtected(t, "plugin before all hook", func() { - t.Helper() - - runHook(t, t.unwrap().spec.Hooks.BeforeAll) - }) + runProtectedHook(t, "plugin BeforeAll hook", t.unwrap().spec.Hooks.BeforeAll) defer func() { t.Helper() @@ -333,14 +313,14 @@ func (r *runner[Suite, T]) runSuiteTests(t T, s Suite, tests suiteTests[Suite, T return } - runProtected(t, "suite after all hook", func() { + runProtected(t, "suite AfterAll hook", func() { t.Helper() s.AfterAll(t) }) }() - runProtected(t, "suite before all hook", func() { + runProtected(t, "suite BeforeAll hook", func() { t.Helper() s.BeforeAll(t) @@ -407,25 +387,17 @@ func (r *runner[Suite, T]) runSuiteTest( ) { t.Helper() - defer runProtected(t, "plugin after each hook", func() { - t.Helper() + defer runProtectedHook(t, "plugin AfterEach hook", t.unwrap().spec.Hooks.AfterEach) - runHook(t, t.unwrap().spec.Hooks.AfterEach) - }) + runProtectedHook(t, "plugin BeforeEach hook", t.unwrap().spec.Hooks.BeforeEach) - runProtected(t, "plugin before each hook", func() { - t.Helper() - - runHook(t, t.unwrap().spec.Hooks.BeforeEach) - }) - - defer runProtected(t, "suite after each hook", func() { + defer runProtected(t, "suite AfterEach hook", func() { t.Helper() s.AfterEach(t) }) - runProtected(t, "suite before each hook", func() { + runProtected(t, "suite BeforeEach hook", func() { t.Helper() s.BeforeEach(t) @@ -454,6 +426,16 @@ func runProtected[T CommonT](t T, kind string, f func()) { f() } +func runProtectedHook[T CommonT](t T, kind string, hook testoplugin.Hook) { + t.Helper() + + runProtected(t, kind, func() { + t.Helper() + + runHook(t, hook) + }) +} + // recordPanic stores rec as the test's panic information and fails the test. // An already recorded panic is kept, so the original panic value survives // even if an after-hook panics during unwinding. From a7759034e893135156960aa474412f94e3da0fb7 Mon Sep 17 00:00:00 2001 From: metafates Date: Thu, 23 Jul 2026 17:02:23 +0300 Subject: [PATCH 13/14] update changelog --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c0351..f147019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Panic failure messages now name the phase that panicked (test, sub-test, or the specific suite/plugin hook). +- Panics in `BeforeAll` & `AfterAll` hooks (both suite and plugin) are now caught and recorded as test failures; a panic in `BeforeAll` still prevents the suite tests from running. +- `t.Setenv` and `t.Chdir` now enforce the standard library's conflict guards against `t.Parallel`. +- The error for a non-pointer exported field now names the owning type and field. + ### Fixed - Panics in tests now record the actual recovered value, are observed by after-hooks as a failed test. -- `t.Setenv` and `t.Chdir` now enforce the standard library's conflict guards against `t.Parallel`. - Plugin hooks and overrides with equal priority now run deterministically, in the declaration order of plugins. - Overrides are now applied in the documented priority order, consistent with hooks. - `t.Error` called after `t.Fatal` (e.g. from a cleanup) no longer downgrades the recorded fatal failure kind. - Annotations no longer leak between instantiations of a generic suite. - Persistent cache reads no longer follow a symlink swapped in after validation. -- The error for a non-pointer exported field now names the owning type and field. ## [1.6.0] - 2026-07-17 From 8c28eecc52c7d3c5ca62df70cdc496fdaa0e4c9f Mon Sep 17 00:00:00 2001 From: metafates Date: Fri, 24 Jul 2026 13:54:54 +0300 Subject: [PATCH 14/14] fix CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c7b10..af68e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Panics in `BeforeAll` & `AfterAll` hooks (both suite and plugin) are now caught and recorded as test failures; a panic in `BeforeAll` still prevents the suite tests from running. - `t.Setenv` and `t.Chdir` now enforce the standard library's conflict guards against `t.Parallel`. - The error for a non-pointer exported field now names the owning type and field. -- Better wording for a warning then `CasesXxx` returnes empty slice. +- Better wording for a warning when `CasesXxx` returns empty slice. ### Fixed